parent
3e76649b71
commit
1a11bfe2d1
|
@ -206,6 +206,25 @@ config :mobilizon, Oban,
|
|||
prune: {:maxlen, 10_000},
|
||||
queues: [default: 10, search: 20, background: 5]
|
||||
|
||||
config :mobilizon, :rich_media,
|
||||
parsers: [
|
||||
Mobilizon.Service.RichMedia.Parsers.OEmbed,
|
||||
Mobilizon.Service.RichMedia.Parsers.OGP,
|
||||
Mobilizon.Service.RichMedia.Parsers.TwitterCard,
|
||||
Mobilizon.Service.RichMedia.Parsers.Fallback
|
||||
]
|
||||
|
||||
config :mobilizon, :resource_providers,
|
||||
types: [pad: :etherpad, calc: :ethercalc, visio: :jitsi],
|
||||
providers: %{}
|
||||
|
||||
config :mobilizon, :external_resource_providers, %{
|
||||
"https://drive.google.com/" => :google_drive,
|
||||
"https://docs.google.com/document/" => :google_docs,
|
||||
"https://docs.google.com/presentation/" => :google_presentation,
|
||||
"https://docs.google.com/spreadsheets/" => :google_spreadsheets
|
||||
}
|
||||
|
||||
# Import environment specific config. This must remain at the bottom
|
||||
# of this file so it overrides the configuration defined above.
|
||||
import_config "#{Mix.env()}.exs"
|
||||
|
|
|
@ -1,21 +1,39 @@
|
|||
{"__schema":
|
||||
{"types":[
|
||||
{
|
||||
"possibleTypes":[
|
||||
{"name":"Person"},
|
||||
{"name":"Group"}
|
||||
],
|
||||
"name":"Actor",
|
||||
"kind":"INTERFACE"
|
||||
},
|
||||
{
|
||||
"possibleTypes":[
|
||||
{"name":"Event"},
|
||||
{"name":"Person"},
|
||||
{"name":"Group"}
|
||||
],
|
||||
"name":"SearchResult",
|
||||
"kind":"UNION"}
|
||||
]
|
||||
{
|
||||
"__schema": {
|
||||
"types": [
|
||||
{
|
||||
"kind": "INTERFACE",
|
||||
"name": "ActionLogObject",
|
||||
"possibleTypes": [
|
||||
{
|
||||
"name": "Event"
|
||||
},
|
||||
{
|
||||
"name": "Comment"
|
||||
},
|
||||
{
|
||||
"name": "Report"
|
||||
},
|
||||
{
|
||||
"name": "ReportNote"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"kind": "INTERFACE",
|
||||
"name": "Actor",
|
||||
"possibleTypes": [
|
||||
{
|
||||
"name": "Person"
|
||||
},
|
||||
{
|
||||
"name": "Group"
|
||||
},
|
||||
{
|
||||
"name": "Application"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
const fetch = require('node-fetch');
|
||||
const fs = require('fs');
|
||||
|
||||
fetch(`http://localhost:4001`, {
|
||||
fetch(`http://localhost:4000/api`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
|
|
|
@ -31,10 +31,10 @@
|
|||
"bulma-divider": "^0.2.0",
|
||||
"graphql": "^14.5.8",
|
||||
"graphql-tag": "^2.10.1",
|
||||
"intersection-observer": "^0.7.0",
|
||||
"intersection-observer": "^0.10.0",
|
||||
"javascript-time-ago": "^2.0.4",
|
||||
"leaflet": "^1.4.0",
|
||||
"leaflet.locatecontrol": "^0.70.0",
|
||||
"leaflet.locatecontrol": "^0.71.0",
|
||||
"lodash": "^4.17.11",
|
||||
"ngeohash": "^0.6.3",
|
||||
"phoenix": "^1.4.11",
|
||||
|
@ -50,7 +50,8 @@
|
|||
"vue-property-decorator": "^8.1.0",
|
||||
"vue-router": "^3.0.6",
|
||||
"vue-scrollto": "^2.17.1",
|
||||
"vue2-leaflet": "^2.0.3"
|
||||
"vue2-leaflet": "^2.0.3",
|
||||
"vuedraggable": "^2.23.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/chai": "^4.2.3",
|
||||
|
@ -58,6 +59,7 @@
|
|||
"@types/leaflet.locatecontrol": "^0.60.7",
|
||||
"@types/lodash": "^4.14.141",
|
||||
"@types/mocha": "^7.0.1",
|
||||
"@types/vuedraggable": "^2.23.0",
|
||||
"@vue/cli-plugin-babel": "^4.0.3",
|
||||
"@vue/cli-plugin-e2e-cypress": "^4.0.3",
|
||||
"@vue/cli-plugin-pwa": "^4.0.3",
|
||||
|
@ -66,7 +68,7 @@
|
|||
"@vue/cli-plugin-unit-mocha": "^4.0.3",
|
||||
"@vue/cli-service": "^4.0.3",
|
||||
"@vue/eslint-config-typescript": "^5.0.0",
|
||||
"@vue/test-utils": "^1.0.0-beta.32",
|
||||
"@vue/test-utils": "^1.0.0",
|
||||
"apollo-link-error": "^1.1.12",
|
||||
"chai": "^4.2.0",
|
||||
"dotenv-webpack": "^1.7.0",
|
||||
|
|
|
@ -13,7 +13,7 @@
|
|||
<template slot-scope="props">
|
||||
<div class="media">
|
||||
<div class="media-left">
|
||||
<img width="32" :src="props.option.avatar.url" v-if="props.option.avatar">
|
||||
<img width="32" :src="props.option.avatar.url" v-if="props.option.avatar" alt="">
|
||||
<b-icon v-else icon="account-circle" />
|
||||
</div>
|
||||
<div class="media-content">
|
||||
|
@ -45,16 +45,14 @@ const SEARCH_PERSON_LIMIT = 10;
|
|||
|
||||
@Component
|
||||
export default class ActorAutoComplete extends Vue {
|
||||
@Model('change', { type: Object }) readonly defaultSelected!: IPerson;
|
||||
@Model('change', { type: Object }) readonly defaultSelected!: IPerson|null;
|
||||
|
||||
baseData: IPerson[] = [];
|
||||
selected: IPerson = this.defaultSelected;
|
||||
name: string = this.defaultSelected.preferredUsername;
|
||||
selected: IPerson|null = this.defaultSelected;
|
||||
name: string = this.defaultSelected ? this.defaultSelected.preferredUsername : '';
|
||||
page: number = 1;
|
||||
totalPages: number = 1;
|
||||
|
||||
SEARCH_PERSON_LIMIT = SEARCH_PERSON_LIMIT;
|
||||
|
||||
mounted() {
|
||||
this.selected = this.defaultSelected;
|
||||
}
|
||||
|
@ -100,4 +98,4 @@ export default class ActorAutoComplete extends Vue {
|
|||
this.baseData.push(...searchPersons.elements);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</script>
|
||||
|
|
|
@ -20,12 +20,12 @@
|
|||
</article>
|
||||
</template>
|
||||
<script lang="ts">
|
||||
import {Component, Prop, Vue} from "vue-property-decorator";
|
||||
import { Component, Prop, Vue } from 'vue-property-decorator';
|
||||
|
||||
@Component
|
||||
export default class ConversationComment extends Vue {
|
||||
@Prop({ required: true, type: Object }) comment;
|
||||
}
|
||||
@Component
|
||||
export default class ConversationComment extends Vue {
|
||||
@Prop({ required: true, type: Object }) comment;
|
||||
}
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
@import "@/variables.scss";
|
||||
|
|
|
@ -13,21 +13,21 @@
|
|||
</router-link>
|
||||
</template>
|
||||
<script lang="ts">
|
||||
import {Component, Prop, Vue} from "vue-property-decorator";
|
||||
import {IConversation} from "@/types/conversations";
|
||||
import {RouteName} from "@/router";
|
||||
import { Component, Prop, Vue } from 'vue-property-decorator';
|
||||
import { IConversation } from '@/types/conversations';
|
||||
import { RouteName } from '@/router';
|
||||
|
||||
@Component
|
||||
export default class ConversationListItem extends Vue {
|
||||
@Prop({ required: true, type: Object }) conversation!: IConversation;
|
||||
RouteName = RouteName;
|
||||
@Component
|
||||
export default class ConversationListItem extends Vue {
|
||||
@Prop({ required: true, type: Object }) conversation!: IConversation;
|
||||
RouteName = RouteName;
|
||||
|
||||
htmlTextEllipsis(HTMLText: string) {
|
||||
const element = document.createElement('div');
|
||||
element.innerHTML = HTMLText.replace(/<br\s*\/?>/gi, ' ').replace(/<p>/gi, ' ');
|
||||
return element.innerText;
|
||||
}
|
||||
}
|
||||
htmlTextEllipsis(HTMLText: string) {
|
||||
const element = document.createElement('div');
|
||||
element.innerHTML = HTMLText.replace(/<br\s*\/?>/gi, ' ').replace(/<p>/gi, ' ');
|
||||
return element.innerText;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
.conversation-minimalist-card-wrapper {
|
||||
|
|
|
@ -10,13 +10,13 @@
|
|||
</div>
|
||||
</template>
|
||||
<script lang="ts">
|
||||
import {Component, Prop, Vue} from "vue-property-decorator";
|
||||
import { Component, Prop, Vue } from 'vue-property-decorator';
|
||||
|
||||
@Component
|
||||
export default class EventMetadataBlock extends Vue {
|
||||
@Prop({ required: false, type: String }) icon!: String;
|
||||
@Prop({ required: true, type: String }) title!: String;
|
||||
}
|
||||
@Component
|
||||
export default class EventMetadataBlock extends Vue {
|
||||
@Prop({ required: false, type: String }) icon!: String;
|
||||
@Prop({ required: true, type: String }) title!: String;
|
||||
}
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
h3 {
|
||||
|
|
|
@ -9,20 +9,20 @@
|
|||
</router-link>
|
||||
</template>
|
||||
<script lang="ts">
|
||||
import {Component, Prop, Vue} from "vue-property-decorator";
|
||||
import {IEvent} from "@/types/event.model";
|
||||
import DateCalendarIcon from '@/components/Event/DateCalendarIcon.vue';
|
||||
import { RouteName } from '@/router';
|
||||
import { Component, Prop, Vue } from 'vue-property-decorator';
|
||||
import { IEvent } from '@/types/event.model';
|
||||
import DateCalendarIcon from '@/components/Event/DateCalendarIcon.vue';
|
||||
import { RouteName } from '@/router';
|
||||
|
||||
@Component({
|
||||
components: {
|
||||
DateCalendarIcon
|
||||
}
|
||||
})
|
||||
export default class EventMinimalistCard extends Vue {
|
||||
@Prop({ required: true, type: Object }) event!: IEvent;
|
||||
RouteName = RouteName;
|
||||
}
|
||||
@Component({
|
||||
components: {
|
||||
DateCalendarIcon,
|
||||
},
|
||||
})
|
||||
export default class EventMinimalistCard extends Vue {
|
||||
@Prop({ required: true, type: Object }) event!: IEvent;
|
||||
RouteName = RouteName;
|
||||
}
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
.event-minimalist-card-wrapper {
|
||||
|
|
|
@ -39,25 +39,25 @@
|
|||
</template>
|
||||
<script lang="ts">
|
||||
import { Component, Prop, Vue, Watch } from 'vue-property-decorator';
|
||||
import {IGroup, IMember, IPerson} from '@/types/actor';
|
||||
import { IGroup, IMember, IPerson } from '@/types/actor';
|
||||
import GroupPicker from '@/components/Group/GroupPicker.vue';
|
||||
import {PERSON_MEMBERSHIPS} from "@/graphql/actor";
|
||||
import {Paginate} from "@/types/paginate";
|
||||
import { PERSON_MEMBERSHIPS } from '@/graphql/actor';
|
||||
import { Paginate } from '@/types/paginate';
|
||||
|
||||
@Component({
|
||||
components: { GroupPicker },
|
||||
apollo: {
|
||||
groupMemberships: {
|
||||
query: PERSON_MEMBERSHIPS,
|
||||
variables() {
|
||||
return {
|
||||
id: this.identity.id,
|
||||
};
|
||||
},
|
||||
update: data => data.person.memberships,
|
||||
skip() { return !this.identity.id; },
|
||||
},
|
||||
apollo: {
|
||||
groupMemberships: {
|
||||
query: PERSON_MEMBERSHIPS,
|
||||
variables() {
|
||||
return {
|
||||
id: this.identity.id,
|
||||
};
|
||||
},
|
||||
update: data => data.person.memberships,
|
||||
skip() { return !this.identity.id; },
|
||||
},
|
||||
},
|
||||
})
|
||||
export default class GroupPickerWrapper extends Vue {
|
||||
@Prop({ type: Object, required: true }) value!: IGroup;
|
||||
|
|
|
@ -0,0 +1,152 @@
|
|||
<template>
|
||||
<div class="resource-wrapper">
|
||||
<router-link
|
||||
:to="{ name: RouteName.RESOURCE_FOLDER, params: { path: resourcePathArray(resource), preferredUsername: group.preferredUsername } }"
|
||||
>
|
||||
<div class="preview">
|
||||
<b-icon icon="folder" size="is-large" />
|
||||
</div>
|
||||
<div class="body">
|
||||
<h3>{{ resource.title }}</h3>
|
||||
<span class="host" v-if="inline">{{ resource.updatedAt | formatDateTimeString }}</span>
|
||||
</div>
|
||||
<draggable
|
||||
v-if="!inline"
|
||||
class="dropzone"
|
||||
v-model="list"
|
||||
:sort="false"
|
||||
:group="groupObject"
|
||||
@change="onChange"
|
||||
/>
|
||||
</router-link>
|
||||
<resource-dropdown
|
||||
class="actions"
|
||||
v-if="!inline"
|
||||
@delete="$emit('delete', resource.id)"
|
||||
@move="$emit('move', resource.id)"
|
||||
@rename="$emit('rename', resource)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<script lang="ts">
|
||||
import { Component, Mixins, Prop } from "vue-property-decorator";
|
||||
import { IResource } from "@/types/resource";
|
||||
import { RouteName } from "@/router";
|
||||
import ResourceMixin from "@/mixins/resource";
|
||||
import Draggable from "vuedraggable";
|
||||
import { UPDATE_RESOURCE } from "../../graphql/resources";
|
||||
import { IGroup } from "@/types/actor";
|
||||
import ResourceDropdown from "@/components/Resource/ResourceDropdown.vue";
|
||||
|
||||
@Component({
|
||||
components: { Draggable, ResourceDropdown }
|
||||
})
|
||||
export default class FolderItem extends Mixins(ResourceMixin) {
|
||||
@Prop({ required: true, type: Object }) resource!: IResource;
|
||||
@Prop({ required: true, type: Object }) group!: IGroup;
|
||||
@Prop({ required: false, default: false }) inline!: boolean;
|
||||
|
||||
list = [];
|
||||
groupObject: object = {
|
||||
name: `folder-${this.resource.title}`,
|
||||
pull: false,
|
||||
put: ["resources"]
|
||||
};
|
||||
|
||||
RouteName = RouteName;
|
||||
|
||||
async onChange(evt) {
|
||||
console.log("into folder item");
|
||||
console.log(evt);
|
||||
if (evt.added && evt.added.element) {
|
||||
const movedResource = evt.added.element as IResource;
|
||||
const updatedResource = await this.moveResource(movedResource);
|
||||
if (updatedResource && this.resource.path) {
|
||||
// @ts-ignore
|
||||
return this.$router.push({
|
||||
name: RouteName.RESOURCE_FOLDER,
|
||||
params: {
|
||||
path: this.resourcePathArray(this.resource),
|
||||
preferredUsername: this.group.preferredUsername
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async moveResource(resource: IResource): Promise<IResource | undefined> {
|
||||
const { data } = await this.$apollo.mutate<{ updateResource: IResource }>({
|
||||
mutation: UPDATE_RESOURCE,
|
||||
variables: {
|
||||
id: resource.id,
|
||||
path: `${this.resource.path}/${resource.title}`,
|
||||
parentId: this.resource.id
|
||||
}
|
||||
});
|
||||
if (!data) {
|
||||
console.error("Error while updating resource");
|
||||
}
|
||||
return data?.updateResource;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
@import "@/variables.scss";
|
||||
|
||||
.resource-wrapper {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
align-items: center;
|
||||
|
||||
.actions {
|
||||
flex: 0;
|
||||
display: block;
|
||||
margin: auto 1rem auto 2rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
.dropzone {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
a {
|
||||
display: flex;
|
||||
font-size: 14px;
|
||||
color: #444b5d;
|
||||
text-decoration: none;
|
||||
overflow: hidden;
|
||||
flex: 1;
|
||||
position: relative;
|
||||
|
||||
.preview {
|
||||
flex: 0 0 100px;
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.body {
|
||||
padding: 10px 8px 8px;
|
||||
flex: 1 1 auto;
|
||||
overflow: hidden;
|
||||
|
||||
h3 {
|
||||
white-space: nowrap;
|
||||
display: block;
|
||||
font-weight: 500;
|
||||
margin-bottom: 5px;
|
||||
color: $primary;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
text-decoration: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,24 @@
|
|||
<template>
|
||||
<b-dropdown aria-role="list" position="is-bottom-left">
|
||||
<b-icon icon="dots-horizontal" slot="trigger" />
|
||||
|
||||
<b-dropdown-item aria-role="listitem" @click="$emit('rename')">
|
||||
<b-icon icon="pencil" />
|
||||
{{ $t('Rename') }}
|
||||
</b-dropdown-item>
|
||||
<b-dropdown-item aria-role="listitem" @click="$emit('move')">
|
||||
<b-icon icon="folder-move" />
|
||||
{{ $t('Move') }}
|
||||
</b-dropdown-item>
|
||||
<b-dropdown-item aria-role="listitem" @click="$emit('delete')">
|
||||
<b-icon icon="delete" />
|
||||
{{ $t('Delete') }}
|
||||
</b-dropdown-item>
|
||||
</b-dropdown>
|
||||
</template>
|
||||
<script lang="ts">
|
||||
import { Component, Vue, Prop } from "vue-property-decorator";
|
||||
|
||||
@Component
|
||||
export default class ResourceDropdown extends Vue {}
|
||||
</script>
|
|
@ -0,0 +1,135 @@
|
|||
<template>
|
||||
<div class="resource-wrapper">
|
||||
<a :href="resource.resourceUrl" target="_blank">
|
||||
<div class="preview">
|
||||
<div v-if="resource.type && Object.keys(mapServiceTypeToIcon).includes(resource.type)">
|
||||
<b-icon :icon="mapServiceTypeToIcon[resource.type]" size="is-large" />
|
||||
</div>
|
||||
<div
|
||||
class="preview-image"
|
||||
v-else-if="resource.metadata && resource.metadata.imageRemoteUrl"
|
||||
:style="`background-image: url(${resource.metadata.imageRemoteUrl})`"
|
||||
/>
|
||||
<div class="preview-type" v-else>
|
||||
<b-icon icon="link" size="is-large" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="body">
|
||||
<img
|
||||
class="favicon"
|
||||
v-if="resource.metadata && resource.metadata.faviconUrl"
|
||||
:src="resource.metadata.faviconUrl"
|
||||
/>
|
||||
<h3>{{ resource.title }}</h3>
|
||||
<span class="host" v-if="inline">{{ resource.updatedAt | formatDateTimeString }}</span>
|
||||
<span class="host" v-else>{{ urlHostname(resource.resourceUrl) }}</span>
|
||||
</div>
|
||||
</a>
|
||||
<resource-dropdown
|
||||
class="actions"
|
||||
v-if="!inline"
|
||||
@delete="$emit('delete', resource.id)"
|
||||
@move="$emit('move', resource.id)"
|
||||
@rename="$emit('rename', resource)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<script lang="ts">
|
||||
import { Component, Prop, Vue } from "vue-property-decorator";
|
||||
import { IResource, mapServiceTypeToIcon } from "@/types/resource";
|
||||
import ResourceDropdown from "@/components/Resource/ResourceDropdown.vue";
|
||||
|
||||
@Component({
|
||||
components: { ResourceDropdown }
|
||||
})
|
||||
export default class ResourceItem extends Vue {
|
||||
@Prop({ required: true, type: Object }) resource!: IResource;
|
||||
@Prop({ required: false, default: false }) inline!: boolean;
|
||||
|
||||
list = [];
|
||||
mapServiceTypeToIcon = mapServiceTypeToIcon;
|
||||
|
||||
urlHostname(url: string): string {
|
||||
return new URL(url).hostname.replace(/^(www\.)/, "");
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
@import "@/variables.scss";
|
||||
|
||||
.resource-wrapper {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
align-items: center;
|
||||
|
||||
.actions {
|
||||
flex: 0;
|
||||
display: block;
|
||||
margin: auto 1rem auto 2rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
a {
|
||||
display: flex;
|
||||
font-size: 14px;
|
||||
color: #444b5d;
|
||||
text-decoration: none;
|
||||
overflow: hidden;
|
||||
flex: 1;
|
||||
|
||||
.preview {
|
||||
flex: 0 0 100px;
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
.preview-image {
|
||||
border-radius: 4px 0 0 4px;
|
||||
display: block;
|
||||
margin: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
background-size: cover;
|
||||
background-position: 50%;
|
||||
}
|
||||
}
|
||||
|
||||
.body {
|
||||
padding: 10px 8px 8px;
|
||||
flex: 1 1 auto;
|
||||
overflow: hidden;
|
||||
|
||||
img.favicon {
|
||||
display: inline-block;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
margin-right: 6px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
h3 {
|
||||
white-space: nowrap;
|
||||
display: inline-block;
|
||||
font-weight: 500;
|
||||
margin-bottom: 5px;
|
||||
color: $primary;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
text-decoration: none;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.host {
|
||||
display: block;
|
||||
margin-top: 5px;
|
||||
font-size: 13px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -31,6 +31,7 @@ export default class Todo extends Vue {
|
|||
@Prop({ required: true, type: Object }) todo!: ITodo;
|
||||
RouteName = RouteName;
|
||||
editMode: boolean = false;
|
||||
debounceUpdateTodo;
|
||||
|
||||
// We put this in data because of issues like https://github.com/vuejs/vue-class-component/issues/263
|
||||
data() {
|
||||
|
@ -52,8 +53,8 @@ export default class Todo extends Vue {
|
|||
this.debounceUpdateTodo({ assignedToId: (person ? person.id : null) });
|
||||
}
|
||||
|
||||
get dueDate(): Date { return new Date(this.todo.dueDate); }
|
||||
set dueDate(dueDate: Date) { this.debounceUpdateTodo({ dueDate }); }
|
||||
get dueDate(): Date|undefined { return new Date(this.todo.dueDate); }
|
||||
set dueDate(dueDate: Date|undefined) { this.debounceUpdateTodo({ dueDate }); }
|
||||
|
||||
updateTodo(params: object) {
|
||||
this.$apollo.mutate({
|
||||
|
@ -66,4 +67,4 @@ export default class Todo extends Vue {
|
|||
this.editMode = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</script>
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import gql from 'graphql-tag';
|
||||
import {CONVERSATION_BASIC_FIELDS_FRAGMENT} from "@/graphql/conversation";
|
||||
import { CONVERSATION_BASIC_FIELDS_FRAGMENT } from '@/graphql/conversation';
|
||||
import { RESOURCE_METADATA_BASIC_FIELDS_FRAGMENT } from '@/graphql/resources';
|
||||
|
||||
export const FETCH_PERSON = gql`
|
||||
query($username: String!) {
|
||||
|
@ -367,6 +368,7 @@ export const FETCH_GROUP = gql`
|
|||
}
|
||||
organizedEvents {
|
||||
elements {
|
||||
id,
|
||||
uuid,
|
||||
title,
|
||||
beginsOn
|
||||
|
@ -394,10 +396,18 @@ export const FETCH_GROUP = gql`
|
|||
},
|
||||
total
|
||||
},
|
||||
collections {
|
||||
resources(page: 1, limit: 3) {
|
||||
elements {
|
||||
id,
|
||||
title
|
||||
title,
|
||||
resourceUrl,
|
||||
summary,
|
||||
updatedAt,
|
||||
type,
|
||||
path,
|
||||
metadata {
|
||||
...ResourceMetadataBasicFields
|
||||
}
|
||||
},
|
||||
total
|
||||
},
|
||||
|
@ -409,7 +419,12 @@ export const FETCH_GROUP = gql`
|
|||
elements {
|
||||
id,
|
||||
title,
|
||||
status
|
||||
status,
|
||||
dueDate,
|
||||
assignedTo {
|
||||
id,
|
||||
preferredUsername
|
||||
}
|
||||
},
|
||||
total
|
||||
}
|
||||
|
@ -419,7 +434,8 @@ export const FETCH_GROUP = gql`
|
|||
}
|
||||
}
|
||||
${CONVERSATION_BASIC_FIELDS_FRAGMENT}
|
||||
`;
|
||||
${RESOURCE_METADATA_BASIC_FIELDS_FRAGMENT}`
|
||||
;
|
||||
|
||||
export const CREATE_GROUP = gql`
|
||||
mutation CreateGroup(
|
||||
|
|
|
@ -50,6 +50,11 @@ query {
|
|||
geocoding {
|
||||
provider,
|
||||
autocomplete
|
||||
},
|
||||
resourceProviders {
|
||||
type,
|
||||
endpoint,
|
||||
software
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import gql from "graphql-tag";
|
||||
import gql from 'graphql-tag';
|
||||
|
||||
export const CONVERSATION_BASIC_FIELDS_FRAGMENT = gql`
|
||||
fragment ConversationBasicFields on Conversation {
|
||||
|
|
|
@ -0,0 +1,113 @@
|
|||
import gql from 'graphql-tag';
|
||||
|
||||
export const RESOURCE_METADATA_BASIC_FIELDS_FRAGMENT = gql`
|
||||
fragment ResourceMetadataBasicFields on ResourceMetadata {
|
||||
imageRemoteUrl,
|
||||
height,
|
||||
width,
|
||||
type,
|
||||
faviconUrl
|
||||
},
|
||||
`;
|
||||
|
||||
export const GET_RESOURCE = gql`
|
||||
query GetResource($path: String!, $username: String!) {
|
||||
resource(path: $path, username: $username) {
|
||||
id,
|
||||
title,
|
||||
summary,
|
||||
url,
|
||||
path,
|
||||
metadata {
|
||||
...ResourceMetadataBasicFields
|
||||
authorName,
|
||||
authorUrl,
|
||||
providerName,
|
||||
providerUrl,
|
||||
html
|
||||
},
|
||||
parent {
|
||||
id
|
||||
},
|
||||
actor {
|
||||
id,
|
||||
preferredUsername
|
||||
},
|
||||
children {
|
||||
total,
|
||||
elements {
|
||||
id,
|
||||
title,
|
||||
summary,
|
||||
url,
|
||||
type,
|
||||
path,
|
||||
resourceUrl,
|
||||
metadata {
|
||||
...ResourceMetadataBasicFields
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
${RESOURCE_METADATA_BASIC_FIELDS_FRAGMENT}`;
|
||||
|
||||
|
||||
export const CREATE_RESOURCE = gql`
|
||||
mutation CreateResource($title: String!, $parentId: ID, $summary: String, $actorId: ID!, $resourceUrl: String, $type: String, $path: String) {
|
||||
createResource(title: $title, parentId: $parentId, summary: $summary, actorId: $actorId, resourceUrl: $resourceUrl, type: $type, path: $path) {
|
||||
id,
|
||||
title,
|
||||
summary,
|
||||
url,
|
||||
resourceUrl,
|
||||
updatedAt,
|
||||
path,
|
||||
type,
|
||||
metadata {
|
||||
...ResourceMetadataBasicFields
|
||||
authorName,
|
||||
authorUrl,
|
||||
providerName,
|
||||
providerUrl,
|
||||
html
|
||||
}
|
||||
}
|
||||
}
|
||||
${RESOURCE_METADATA_BASIC_FIELDS_FRAGMENT}`;
|
||||
|
||||
export const UPDATE_RESOURCE = gql`
|
||||
mutation UpdateResource($id: ID!, $title: String, $summary: String, $parentId: ID, $resourceUrl: String) {
|
||||
updateResource(id: $id, title: $title, parentId: $parentId, summary: $summary, resourceUrl: $resourceUrl) {
|
||||
id,
|
||||
title,
|
||||
summary,
|
||||
url,
|
||||
path,
|
||||
resourceUrl
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const DELETE_RESOURCE = gql`
|
||||
mutation DeleteResource($id: ID!) {
|
||||
deleteResource(id: $id) {
|
||||
id
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const PREVIEW_RESOURCE_LINK = gql`
|
||||
mutation PreviewResourceLink($resourceUrl: String!) {
|
||||
previewResourceLink(resourceUrl: $resourceUrl) {
|
||||
title,
|
||||
description,
|
||||
...ResourceMetadataBasicFields
|
||||
authorName,
|
||||
authorUrl,
|
||||
providerName,
|
||||
providerUrl,
|
||||
html
|
||||
}
|
||||
}
|
||||
${RESOURCE_METADATA_BASIC_FIELDS_FRAGMENT}`;
|
|
@ -517,5 +517,6 @@
|
|||
"No one is going to this event": "No one is going to this event|One person going|{going} persons going",
|
||||
"By @{group}": "By @{group}",
|
||||
"Date and time": "Date and time",
|
||||
"Location": "Location"
|
||||
"Location": "Location",
|
||||
"No resources selected": "No resources selected|One resources selected|{count} resources selected"
|
||||
}
|
||||
|
|
|
@ -525,5 +525,6 @@
|
|||
"No one is going to this event": "Personne n'a va encore|Une personne y va|{going} personnes y vont",
|
||||
"By @{group}": "Par @{group}",
|
||||
"Date and time": "Date et heure",
|
||||
"Location": "Lieu"
|
||||
"Location": "Lieu",
|
||||
"No resources selected": "Aucune ressource sélectionnée|Une ressource sélectionnée|{count} ressources sélectionnées"
|
||||
}
|
||||
|
|
|
@ -0,0 +1,17 @@
|
|||
import { Component, Vue } from 'vue-property-decorator';
|
||||
import { IResource } from '@/types/resource';
|
||||
|
||||
@Component
|
||||
export default class ResourceMixin extends Vue {
|
||||
resourcePath(resource: IResource): string {
|
||||
const path = resource.path;
|
||||
if (path && path[0] === '/') {
|
||||
return path.slice(1);
|
||||
}
|
||||
return path || '';
|
||||
}
|
||||
|
||||
resourcePathArray(resource: IResource): string[] {
|
||||
return this.resourcePath(resource).split('/');
|
||||
}
|
||||
}
|
|
@ -1,34 +1,34 @@
|
|||
import {RouteConfig} from "vue-router";
|
||||
import CreateConversation from "@/views/Conversations/Create.vue";
|
||||
import ConversationsList from "@/views/Conversations/ConversationsList.vue";
|
||||
import Conversation from "@/views/Conversations/Conversation.vue";
|
||||
import { RouteConfig } from 'vue-router';
|
||||
import CreateConversation from '@/views/Conversations/Create.vue';
|
||||
import ConversationsList from '@/views/Conversations/ConversationsList.vue';
|
||||
import Conversation from '@/views/Conversations/Conversation.vue';
|
||||
|
||||
export enum ConversationRouteName {
|
||||
CONVERSATION_LIST = 'CONVERSATION_LIST',
|
||||
CREATE_CONVERSATION = 'CREATE_CONVERSATION',
|
||||
CONVERSATION = 'CONVERSATION'
|
||||
CONVERSATION = 'CONVERSATION',
|
||||
}
|
||||
|
||||
export const conversationRoutes: RouteConfig[] = [
|
||||
{
|
||||
path: '/@:preferredUsername/conversations',
|
||||
name: ConversationRouteName.CONVERSATION_LIST,
|
||||
component: ConversationsList,
|
||||
props: true,
|
||||
meta: { requiredAuth: false },
|
||||
},
|
||||
{
|
||||
path: '/@:preferredUsername/conversations/new',
|
||||
name: ConversationRouteName.CREATE_CONVERSATION,
|
||||
component: CreateConversation,
|
||||
props: true,
|
||||
meta: { requiredAuth: true },
|
||||
},
|
||||
{
|
||||
path: '/@:preferredUsername/:slug/:id/:comment_id?',
|
||||
name: ConversationRouteName.CONVERSATION,
|
||||
component: Conversation,
|
||||
props: true,
|
||||
meta: { requiredAuth: false },
|
||||
},
|
||||
{
|
||||
path: '/@:preferredUsername/conversations',
|
||||
name: ConversationRouteName.CONVERSATION_LIST,
|
||||
component: ConversationsList,
|
||||
props: true,
|
||||
meta: { requiredAuth: false },
|
||||
},
|
||||
{
|
||||
path: '/@:preferredUsername/conversations/new',
|
||||
name: ConversationRouteName.CREATE_CONVERSATION,
|
||||
component: CreateConversation,
|
||||
props: true,
|
||||
meta: { requiredAuth: true },
|
||||
},
|
||||
{
|
||||
path: '/@:preferredUsername/:slug/:id/:comment_id?',
|
||||
name: ConversationRouteName.CONVERSATION,
|
||||
component: Conversation,
|
||||
props: true,
|
||||
meta: { requiredAuth: false },
|
||||
},
|
||||
];
|
||||
|
|
|
@ -2,11 +2,19 @@ import { RouteConfig } from 'vue-router';
|
|||
import TodoLists from '@/views/Todos/TodoLists.vue';
|
||||
import TodoList from '@/views/Todos/TodoList.vue';
|
||||
import Todo from '@/views/Todos/Todo.vue';
|
||||
import Settings from '@/views/Group/Settings.vue';
|
||||
import Resources from '@/views/Resources/Resources.vue';
|
||||
import ResourceFolder from '@/views/Resources/ResourceFolder.vue';
|
||||
|
||||
export enum GroupsRouteName {
|
||||
TODO_LISTS = 'TODO_LISTS',
|
||||
TODO_LIST = 'TODO_LIST',
|
||||
TODO = 'TODO',
|
||||
TODO_LISTS = 'TODO_LISTS',
|
||||
TODO_LIST = 'TODO_LIST',
|
||||
TODO = 'TODO',
|
||||
GROUP_SETTINGS = 'GROUP_SETTINGS',
|
||||
PUBLIC_SETTINGS = 'PUBLIC_SETTINGS',
|
||||
RESOURCES = 'RESOURCES',
|
||||
RESOURCE_FOLDER_ROOT = 'RESOURCE_FOLDER_ROOT',
|
||||
RESOURCE_FOLDER = 'RESOURCE_FOLDER',
|
||||
}
|
||||
|
||||
export const groupsRoutes: RouteConfig[] = [
|
||||
|
@ -31,4 +39,32 @@ export const groupsRoutes: RouteConfig[] = [
|
|||
props: true,
|
||||
meta: { requiredAuth: true },
|
||||
},
|
||||
{
|
||||
path: '/@:preferredUsername/resources',
|
||||
name: GroupsRouteName.RESOURCE_FOLDER_ROOT,
|
||||
component: ResourceFolder,
|
||||
props: { path: '/' },
|
||||
meta: { requiredAuth: true },
|
||||
},
|
||||
{
|
||||
path: '/@:preferredUsername/resources/:path+',
|
||||
name: GroupsRouteName.RESOURCE_FOLDER,
|
||||
component: ResourceFolder,
|
||||
props: true,
|
||||
meta: { requiredAuth: true },
|
||||
},
|
||||
{
|
||||
path: '/settings',
|
||||
component: Settings,
|
||||
props: true,
|
||||
meta: { requiredAuth: true },
|
||||
redirect: { name: GroupsRouteName.PUBLIC_SETTINGS },
|
||||
name: GroupsRouteName.GROUP_SETTINGS,
|
||||
children: [
|
||||
{
|
||||
path: 'public',
|
||||
name: GroupsRouteName.PUBLIC_SETTINGS,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
|
|
@ -11,7 +11,7 @@ import { authGuardIfNeeded } from '@/router/guards/auth-guard';
|
|||
import Search from '@/views/Search.vue';
|
||||
import { SettingsRouteName, settingsRoutes } from '@/router/settings';
|
||||
import { GroupsRouteName, groupsRoutes } from '@/router/groups';
|
||||
import {ConversationRouteName, conversationRoutes} from "@/router/conversation";
|
||||
import { ConversationRouteName, conversationRoutes } from '@/router/conversation';
|
||||
|
||||
Vue.use(Router);
|
||||
|
||||
|
|
|
@ -1,9 +1,9 @@
|
|||
import {Actor, ActorType, IActor} from '@/types/actor/actor.model';
|
||||
import {Paginate} from '@/types/paginate';
|
||||
import {ICollection} from '@/types/collection';
|
||||
import {ITodoList} from '@/types/todos';
|
||||
import {IEvent} from '@/types/event.model';
|
||||
import {IConversation} from "@/types/conversations";
|
||||
import { Actor, ActorType, IActor } from '@/types/actor/actor.model';
|
||||
import { Paginate } from '@/types/paginate';
|
||||
import { IResource } from '@/types/resource';
|
||||
import { ITodoList } from '@/types/todos';
|
||||
import { IEvent } from '@/types/event.model';
|
||||
import { IConversation } from '@/types/conversations';
|
||||
|
||||
export enum MemberRole {
|
||||
PENDING,
|
||||
|
@ -14,7 +14,7 @@ export enum MemberRole {
|
|||
|
||||
export interface IGroup extends IActor {
|
||||
members: Paginate<IMember>;
|
||||
collections: Paginate<ICollection>;
|
||||
resources: Paginate<IResource>;
|
||||
todoLists: Paginate<ITodoList>;
|
||||
conversations: Paginate<IConversation>;
|
||||
organizedEvents: Paginate<IEvent>;
|
||||
|
@ -29,7 +29,7 @@ export interface IMember {
|
|||
|
||||
export class Group extends Actor implements IGroup {
|
||||
members: Paginate<IMember> = { elements: [], total: 0 };
|
||||
collections: Paginate<ICollection> = { elements: [], total: 0 };
|
||||
resources: Paginate<IResource> = { elements: [], total: 0 };
|
||||
todoLists: Paginate<ITodoList> = { elements: [], total: 0 };
|
||||
conversations: Paginate<IConversation> = { elements: [], total: 0 };
|
||||
organizedEvents!: Paginate<IEvent>;
|
||||
|
|
|
@ -1,13 +0,0 @@
|
|||
import { Paginate } from '@/types/paginate';
|
||||
|
||||
export interface ICollection {
|
||||
id: string;
|
||||
title: string;
|
||||
resources: Paginate<IResource>;
|
||||
}
|
||||
|
||||
export interface IResource {
|
||||
id: string;
|
||||
title: string;
|
||||
url: string;
|
||||
}
|
|
@ -55,4 +55,9 @@ export interface IConfig {
|
|||
type: InstanceTermsType;
|
||||
url: string;
|
||||
};
|
||||
resourceProviders: {
|
||||
type: string,
|
||||
endpoint: string,
|
||||
software: string,
|
||||
};
|
||||
}
|
||||
|
|
|
@ -1,13 +1,13 @@
|
|||
import {IActor, IPerson} from "@/types/actor";
|
||||
import {IComment} from "@/types/comment.model";
|
||||
import {Paginate} from "@/types/paginate";
|
||||
import { IActor, IPerson } from '@/types/actor';
|
||||
import { IComment } from '@/types/comment.model';
|
||||
import { Paginate } from '@/types/paginate';
|
||||
|
||||
export interface IConversation {
|
||||
id: string;
|
||||
title: string;
|
||||
slug: string;
|
||||
creator: IPerson;
|
||||
actor: IActor;
|
||||
lastComment: IComment;
|
||||
comments: Paginate<IComment>
|
||||
id: string;
|
||||
title: string;
|
||||
slug: string;
|
||||
creator: IPerson;
|
||||
actor: IActor;
|
||||
lastComment: IComment;
|
||||
comments: Paginate<IComment>;
|
||||
}
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import {Actor, Group, IActor, IPerson} from './actor';
|
||||
import { Actor, Group, IActor, IPerson } from './actor';
|
||||
import { Address, IAddress } from '@/types/address.model';
|
||||
import { ITag } from '@/types/tag.model';
|
||||
import { IPicture } from '@/types/picture.model';
|
||||
|
|
|
@ -0,0 +1,41 @@
|
|||
import { Paginate } from '@/types/paginate';
|
||||
import { IActor } from '@/types/actor';
|
||||
|
||||
export interface IResource {
|
||||
id?: string;
|
||||
title: string;
|
||||
summary?: string;
|
||||
actor?: IActor;
|
||||
url?: string;
|
||||
resourceUrl: string;
|
||||
path?: string;
|
||||
children: Paginate<IResource>;
|
||||
parent?: IResource;
|
||||
metadata: IResourceMetadata;
|
||||
insertedAt?: Date;
|
||||
updatedAt?: Date;
|
||||
creator?: IActor;
|
||||
type?: string;
|
||||
}
|
||||
|
||||
export interface IResourceMetadata {
|
||||
title?: string;
|
||||
description?: string;
|
||||
imageRemoteUrl?: string;
|
||||
height?: number;
|
||||
width?: number;
|
||||
type?: string;
|
||||
authorName?: string;
|
||||
authorUrl?: string;
|
||||
providerName?: string;
|
||||
providerUrl?: string;
|
||||
html?: string;
|
||||
faviconUrl?: string;
|
||||
}
|
||||
|
||||
|
||||
export const mapServiceTypeToIcon: object = {
|
||||
pad: "file-document-outline",
|
||||
calc: "google-spreadsheet",
|
||||
visio: "webcam"
|
||||
}
|
|
@ -59,127 +59,127 @@
|
|||
</div>
|
||||
</template>
|
||||
<script lang="ts">
|
||||
import {Component, Prop, Vue} from "vue-property-decorator";
|
||||
import {GET_CONVERSATION, REPLY_TO_CONVERSATION, UPDATE_CONVERSATION} from "@/graphql/conversation";
|
||||
import {IConversation} from "@/types/conversations";
|
||||
import ConversationComment from "@/components/Conversation/ConversationComment.vue";
|
||||
import {RouteName} from '@/router';
|
||||
import { Component, Prop, Vue } from 'vue-property-decorator';
|
||||
import { GET_CONVERSATION, REPLY_TO_CONVERSATION, UPDATE_CONVERSATION } from '@/graphql/conversation';
|
||||
import { IConversation } from '@/types/conversations';
|
||||
import ConversationComment from '@/components/Conversation/ConversationComment.vue';
|
||||
import { RouteName } from '@/router';
|
||||
|
||||
@Component({
|
||||
apollo: {
|
||||
conversation: {
|
||||
query: GET_CONVERSATION,
|
||||
variables() {
|
||||
return {
|
||||
id: this.id,
|
||||
page: 1,
|
||||
};
|
||||
},
|
||||
skip() {
|
||||
return !this.id;
|
||||
}
|
||||
},
|
||||
@Component({
|
||||
apollo: {
|
||||
conversation: {
|
||||
query: GET_CONVERSATION,
|
||||
variables() {
|
||||
return {
|
||||
id: this.id,
|
||||
page: 1,
|
||||
};
|
||||
},
|
||||
skip() {
|
||||
return !this.id;
|
||||
},
|
||||
},
|
||||
},
|
||||
components: {
|
||||
ConversationComment,
|
||||
editor: () => import(/* webpackChunkName: "editor" */ '@/components/Editor.vue'),
|
||||
},
|
||||
})
|
||||
export default class Conversation extends Vue {
|
||||
@Prop({ type: String, required: true }) id!: string;
|
||||
conversation!: IConversation;
|
||||
newComment: string = '';
|
||||
newTitle: string = '';
|
||||
editTitleMode: boolean = false;
|
||||
page: number = 1;
|
||||
hasMoreComments = true;
|
||||
|
||||
RouteName = RouteName;
|
||||
|
||||
async reply() {
|
||||
await this.$apollo.mutate({
|
||||
mutation: REPLY_TO_CONVERSATION,
|
||||
variables: {
|
||||
conversationId: this.conversation.id,
|
||||
text: this.newComment,
|
||||
},
|
||||
update: (store, { data: { replyToConversation } }) => {
|
||||
const conversationData = store.readQuery<{ conversation: IConversation }>({
|
||||
query: GET_CONVERSATION,
|
||||
variables: {
|
||||
id: this.id,
|
||||
page: this.page,
|
||||
},
|
||||
});
|
||||
if (!conversationData) return;
|
||||
const { conversation } = conversationData;
|
||||
conversation.lastComment = replyToConversation.lastComment;
|
||||
conversation.comments.elements.push(replyToConversation.lastComment);
|
||||
conversation.comments.total += 1;
|
||||
store.writeQuery({
|
||||
query: GET_CONVERSATION,
|
||||
variables: { id: this.id, page: this.page },
|
||||
data: { conversation },
|
||||
});
|
||||
},
|
||||
});
|
||||
this.newComment = '';
|
||||
}
|
||||
|
||||
async loadMoreComments() {
|
||||
this.page += 1;
|
||||
try {
|
||||
console.log(this.$apollo.queries.conversation);
|
||||
await this.$apollo.queries.conversation.fetchMore({
|
||||
// New variables
|
||||
variables: {
|
||||
id: this.id,
|
||||
page: this.page,
|
||||
},
|
||||
components: {
|
||||
ConversationComment,
|
||||
editor: () => import(/* webpackChunkName: "editor" */ '@/components/Editor.vue'),
|
||||
// Transform the previous result with new data
|
||||
updateQuery: (previousResult, { fetchMoreResult }) => {
|
||||
if (!fetchMoreResult) return previousResult;
|
||||
const newComments = fetchMoreResult.conversation.comments.elements;
|
||||
this.hasMoreComments = newComments.length === 1;
|
||||
const conversation = previousResult.conversation;
|
||||
conversation.comments.elements = [...previousResult.conversation.comments.elements, ...newComments];
|
||||
|
||||
return { conversation };
|
||||
},
|
||||
})
|
||||
export default class Conversation extends Vue {
|
||||
@Prop({type: String, required: true}) id!: string;
|
||||
conversation!: IConversation;
|
||||
newComment: string = '';
|
||||
newTitle: string = '';
|
||||
editTitleMode: boolean = false;
|
||||
page: number = 1;
|
||||
hasMoreComments = true;
|
||||
|
||||
RouteName = RouteName;
|
||||
|
||||
async reply() {
|
||||
await this.$apollo.mutate({
|
||||
mutation: REPLY_TO_CONVERSATION,
|
||||
variables: {
|
||||
conversationId: this.conversation.id,
|
||||
text: this.newComment,
|
||||
},
|
||||
update: (store, {data: {replyToConversation}}) => {
|
||||
const conversationData = store.readQuery<{ conversation: IConversation }>({
|
||||
query: GET_CONVERSATION,
|
||||
variables: {
|
||||
id: this.id,
|
||||
page: this.page
|
||||
},
|
||||
});
|
||||
if (!conversationData) return;
|
||||
const {conversation} = conversationData;
|
||||
conversation.lastComment = replyToConversation.lastComment;
|
||||
conversation.comments.elements.push(replyToConversation.lastComment);
|
||||
conversation.comments.total += 1;
|
||||
store.writeQuery({
|
||||
query: GET_CONVERSATION,
|
||||
variables: {id: this.id, page: this.page},
|
||||
data: {conversation}
|
||||
})
|
||||
},
|
||||
});
|
||||
this.newComment = '';
|
||||
}
|
||||
|
||||
async loadMoreComments() {
|
||||
this.page += 1;
|
||||
try {
|
||||
console.log(this.$apollo.queries.conversation)
|
||||
await this.$apollo.queries.conversation.fetchMore({
|
||||
// New variables
|
||||
variables: {
|
||||
id: this.id,
|
||||
page: this.page,
|
||||
},
|
||||
// Transform the previous result with new data
|
||||
updateQuery: (previousResult, {fetchMoreResult}) => {
|
||||
if (!fetchMoreResult) return previousResult;
|
||||
const newComments = fetchMoreResult.conversation.comments.elements;
|
||||
this.hasMoreComments = newComments.length === 1;
|
||||
const conversation = previousResult.conversation;
|
||||
conversation.comments.elements = [...previousResult.conversation.comments.elements, ...newComments];
|
||||
|
||||
return {conversation};
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
|
||||
async updateConversation() {
|
||||
await this.$apollo.mutate({
|
||||
mutation: UPDATE_CONVERSATION,
|
||||
variables: {
|
||||
conversationId: this.conversation.id,
|
||||
title: this.newTitle
|
||||
},
|
||||
update: (store, {data: {updateConversation}}) => {
|
||||
const conversationData = store.readQuery<{ conversation: IConversation }>({
|
||||
query: GET_CONVERSATION,
|
||||
variables: {
|
||||
id: this.id,
|
||||
page: this.page
|
||||
},
|
||||
});
|
||||
if (!conversationData) return;
|
||||
const {conversation} = conversationData;
|
||||
conversation.title = updateConversation.title;
|
||||
store.writeQuery({
|
||||
query: GET_CONVERSATION,
|
||||
variables: {id: this.id, page: this.page},
|
||||
data: {conversation}
|
||||
})
|
||||
},
|
||||
});
|
||||
this.editTitleMode = false;
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
|
||||
async updateConversation() {
|
||||
await this.$apollo.mutate({
|
||||
mutation: UPDATE_CONVERSATION,
|
||||
variables: {
|
||||
conversationId: this.conversation.id,
|
||||
title: this.newTitle,
|
||||
},
|
||||
update: (store, { data: { updateConversation } }) => {
|
||||
const conversationData = store.readQuery<{ conversation: IConversation }>({
|
||||
query: GET_CONVERSATION,
|
||||
variables: {
|
||||
id: this.id,
|
||||
page: this.page,
|
||||
},
|
||||
});
|
||||
if (!conversationData) return;
|
||||
const { conversation } = conversationData;
|
||||
conversation.title = updateConversation.title;
|
||||
store.writeQuery({
|
||||
query: GET_CONVERSATION,
|
||||
variables: { id: this.id, page: this.page },
|
||||
data: { conversation },
|
||||
});
|
||||
},
|
||||
});
|
||||
this.editTitleMode = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
div.container.section {
|
||||
|
|
|
@ -1,42 +1,67 @@
|
|||
<template>
|
||||
<section class="container section">
|
||||
<div v-if="group && group.conversations.elements.length > 0">
|
||||
<conversation-list-item :conversation="conversation" v-for="conversation in group.conversations.elements" :key="conversation.id" />
|
||||
</div>
|
||||
<b-button tag="router-link" :to="{ name: RouteName.CREATE_CONVERSATION, params: { preferredUsername: this.preferredUsername } }">{{ $t('New conversation') }}</b-button>
|
||||
</section>
|
||||
<div class="container section" v-if="group">
|
||||
<nav class="breadcrumb" aria-label="breadcrumbs">
|
||||
<ul>
|
||||
<li>
|
||||
<router-link :to="{ name: RouteName.MY_GROUPS }">{{ $t('My groups') }}</router-link>
|
||||
</li>
|
||||
<li>
|
||||
<router-link
|
||||
:to="{ name: RouteName.GROUP, params:{ preferredUsername: group.preferredUsername } }">
|
||||
{{ `@${group.preferredUsername}` }}
|
||||
</router-link>
|
||||
</li>
|
||||
<li class="is-active">
|
||||
<router-link
|
||||
:to="{ name: RouteName.CONVERSATION_LIST, params:{ preferredUsername: group.preferredUsername } }">
|
||||
{{ $t('Conversations') }}
|
||||
</router-link>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
<section>
|
||||
<div v-if="group.conversations.elements.length > 0">
|
||||
<conversation-list-item :conversation="conversation"
|
||||
v-for="conversation in group.conversations.elements" :key="conversation.id"/>
|
||||
</div>
|
||||
<b-button tag="router-link"
|
||||
:to="{ name: RouteName.CREATE_CONVERSATION, params: { preferredUsername: this.preferredUsername } }">
|
||||
{{ $t('New conversation') }}
|
||||
</b-button>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
<script lang="ts">
|
||||
import {Component, Prop, Vue} from "vue-property-decorator";
|
||||
import {FETCH_GROUP} from "@/graphql/actor";
|
||||
import {IGroup} from "@/types/actor";
|
||||
import {RouteName} from "@/router";
|
||||
import ConversationListItem from "@/components/Conversation/ConversationListItem.vue";
|
||||
import { Component, Prop, Vue } from 'vue-property-decorator';
|
||||
import { FETCH_GROUP } from '@/graphql/actor';
|
||||
import { IGroup } from '@/types/actor';
|
||||
import { RouteName } from '@/router';
|
||||
import ConversationListItem from '@/components/Conversation/ConversationListItem.vue';
|
||||
|
||||
@Component({
|
||||
components: {ConversationListItem},
|
||||
apollo: {
|
||||
group: {
|
||||
query: FETCH_GROUP,
|
||||
variables() {
|
||||
return {
|
||||
name: this.preferredUsername,
|
||||
};
|
||||
},
|
||||
skip() {
|
||||
return !this.preferredUsername;
|
||||
}
|
||||
},
|
||||
}
|
||||
})
|
||||
export default class ConversationsList extends Vue {
|
||||
@Prop({type: String, required: true}) preferredUsername!: string;
|
||||
group!: IGroup;
|
||||
RouteName = RouteName;
|
||||
}
|
||||
@Component({
|
||||
components: { ConversationListItem },
|
||||
apollo: {
|
||||
group: {
|
||||
query: FETCH_GROUP,
|
||||
variables() {
|
||||
return {
|
||||
name: this.preferredUsername,
|
||||
};
|
||||
},
|
||||
skip() {
|
||||
return !this.preferredUsername;
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
export default class ConversationsList extends Vue {
|
||||
@Prop({ type: String, required: true }) preferredUsername!: string;
|
||||
group!: IGroup;
|
||||
RouteName = RouteName;
|
||||
}
|
||||
</script>
|
||||
<style lang="scss">
|
||||
section.container.section {
|
||||
div.container.section {
|
||||
background: white;
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
<section class="section container">
|
||||
<h1>{{ $t('Create a new conversation') }}</h1>
|
||||
|
||||
<div>
|
||||
<form @submit.prevent="createConversation">
|
||||
<b-field :label="$t('Title')">
|
||||
<b-input aria-required="true" required v-model="conversation.title"/>
|
||||
</b-field>
|
||||
|
@ -11,70 +11,70 @@
|
|||
<editor v-model="conversation.text" />
|
||||
</b-field>
|
||||
|
||||
<button class="button is-primary" @click="createConversation()">
|
||||
<button class="button is-primary" type="submit">
|
||||
{{ $t('Create my group') }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import {Component, Prop, Vue} from 'vue-property-decorator';
|
||||
import {IGroup, IPerson} from '@/types/actor';
|
||||
import {CURRENT_ACTOR_CLIENT, FETCH_GROUP} from '@/graphql/actor';
|
||||
import {CREATE_CONVERSATION} from "@/graphql/conversation";
|
||||
import {RouteName} from "@/router";
|
||||
import { Component, Prop, Vue } from 'vue-property-decorator';
|
||||
import { IGroup, IPerson } from '@/types/actor';
|
||||
import { CURRENT_ACTOR_CLIENT, FETCH_GROUP } from '@/graphql/actor';
|
||||
import { CREATE_CONVERSATION } from '@/graphql/conversation';
|
||||
import { RouteName } from '@/router';
|
||||
|
||||
@Component({
|
||||
components: {
|
||||
editor: () => import(/* webpackChunkName: "editor" */ '@/components/Editor.vue'),
|
||||
@Component({
|
||||
components: {
|
||||
editor: () => import(/* webpackChunkName: "editor" */ '@/components/Editor.vue'),
|
||||
},
|
||||
apollo: {
|
||||
currentActor: CURRENT_ACTOR_CLIENT,
|
||||
group: {
|
||||
query: FETCH_GROUP,
|
||||
variables() {
|
||||
return {
|
||||
name: this.preferredUsername,
|
||||
};
|
||||
},
|
||||
skip() {
|
||||
return !this.preferredUsername;
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
export default class CreateConversation extends Vue {
|
||||
@Prop({ type: String, required: true }) preferredUsername!: string;
|
||||
group!: IGroup;
|
||||
currentActor!: IPerson;
|
||||
|
||||
conversation = { title: '', text: '' };
|
||||
|
||||
async createConversation() {
|
||||
try {
|
||||
const { data } = await this.$apollo.mutate({
|
||||
mutation: CREATE_CONVERSATION,
|
||||
variables: {
|
||||
title: this.conversation.title,
|
||||
text: this.conversation.text,
|
||||
actorId: this.group.id,
|
||||
creatorId: this.currentActor.id,
|
||||
},
|
||||
apollo: {
|
||||
currentActor: CURRENT_ACTOR_CLIENT,
|
||||
group: {
|
||||
query: FETCH_GROUP,
|
||||
variables() {
|
||||
return {
|
||||
name: this.preferredUsername,
|
||||
};
|
||||
},
|
||||
skip() {
|
||||
return !this.preferredUsername;
|
||||
}
|
||||
},
|
||||
},
|
||||
})
|
||||
export default class CreateConversation extends Vue {
|
||||
@Prop({type: String, required: true}) preferredUsername!: string;
|
||||
group!: IGroup;
|
||||
currentActor!: IPerson;
|
||||
// update: (store, { data: { createConversation } }) => {
|
||||
// // TODO: update group list cache
|
||||
// },
|
||||
});
|
||||
|
||||
conversation = {title: '', text: ''};
|
||||
|
||||
async createConversation() {
|
||||
try {
|
||||
const {data} = await this.$apollo.mutate({
|
||||
mutation: CREATE_CONVERSATION,
|
||||
variables: {
|
||||
title: this.conversation.title,
|
||||
text: this.conversation.text,
|
||||
actorId: this.group.id,
|
||||
creatorId: this.currentActor.id
|
||||
},
|
||||
// update: (store, { data: { createConversation } }) => {
|
||||
// // TODO: update group list cache
|
||||
// },
|
||||
});
|
||||
|
||||
await this.$router.push({
|
||||
name: RouteName.CONVERSATION,
|
||||
params: {id: data?.createConversation.id, slug: data?.createConversation.slug}
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
}
|
||||
await this.$router.push({
|
||||
name: RouteName.CONVERSATION,
|
||||
params: { id: data?.createConversation.id, slug: data?.createConversation.slug },
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
|
|
|
@ -311,7 +311,7 @@ import {
|
|||
IEvent, ParticipantRole,
|
||||
} from '@/types/event.model';
|
||||
import { CURRENT_ACTOR_CLIENT, LOGGED_USER_DRAFTS, LOGGED_USER_PARTICIPATIONS } from '@/graphql/actor';
|
||||
import {Group, IPerson, Person} from '@/types/actor';
|
||||
import { Group, IPerson, Person } from '@/types/actor';
|
||||
import PictureUpload from '@/components/PictureUpload.vue';
|
||||
import EditorComponent from '@/components/Editor.vue';
|
||||
import DateTimePicker from '@/components/Event/DateTimePicker.vue';
|
||||
|
@ -349,16 +349,16 @@ const DEFAULT_LIMIT_NUMBER_OF_PLACES = 10;
|
|||
query: FETCH_EVENT,
|
||||
variables() {
|
||||
return {
|
||||
uuid: this.eventId
|
||||
}
|
||||
uuid: this.eventId,
|
||||
};
|
||||
},
|
||||
update(data) {
|
||||
return new EventModel(data.event);
|
||||
},
|
||||
skip() {
|
||||
return !this.eventId;
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
metaInfo() {
|
||||
return {
|
||||
|
|
|
@ -395,467 +395,467 @@
|
|||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import {
|
||||
EVENT_PERSON_PARTICIPATION,
|
||||
EVENT_PERSON_PARTICIPATION_SUBSCRIPTION_CHANGED,
|
||||
FETCH_EVENT,
|
||||
JOIN_EVENT,
|
||||
} from '@/graphql/event';
|
||||
import {Component, Prop, Watch} from 'vue-property-decorator';
|
||||
import {CURRENT_ACTOR_CLIENT} from '@/graphql/actor';
|
||||
import {
|
||||
EventModel,
|
||||
EventStatus,
|
||||
EventVisibility,
|
||||
IEvent,
|
||||
IParticipant,
|
||||
ParticipantRole,
|
||||
EventJoinOptions
|
||||
} from '@/types/event.model';
|
||||
import {IPerson, Person} from '@/types/actor';
|
||||
import {GRAPHQL_API_ENDPOINT} from '@/api/_entrypoint';
|
||||
import DateCalendarIcon from '@/components/Event/DateCalendarIcon.vue';
|
||||
import BIcon from 'buefy/src/components/icon/Icon.vue';
|
||||
import EventCard from '@/components/Event/EventCard.vue';
|
||||
import EventFullDate from '@/components/Event/EventFullDate.vue';
|
||||
import ActorLink from '@/components/Account/ActorLink.vue';
|
||||
import ReportModal from '@/components/Report/ReportModal.vue';
|
||||
import {IReport} from '@/types/report.model';
|
||||
import {CREATE_REPORT} from '@/graphql/report';
|
||||
import EventMixin from '@/mixins/event';
|
||||
import IdentityPicker from '@/views/Account/IdentityPicker.vue';
|
||||
import ParticipationButton from '@/components/Event/ParticipationButton.vue';
|
||||
import {GraphQLError} from 'graphql';
|
||||
import {RouteName} from '@/router';
|
||||
import {Address} from '@/types/address.model';
|
||||
import CommentTree from '@/components/Comment/CommentTree.vue';
|
||||
import 'intersection-observer';
|
||||
import {CONFIG} from '@/graphql/config';
|
||||
import {
|
||||
AnonymousParticipationNotFoundError,
|
||||
getLeaveTokenForParticipation,
|
||||
isParticipatingInThisEvent,
|
||||
removeAnonymousParticipation,
|
||||
} from '@/services/AnonymousParticipationStorage';
|
||||
import {IConfig} from '@/types/config.model';
|
||||
import Subtitle from '@/components/Utils/Subtitle.vue';
|
||||
import Tag from '@/components/Tag.vue';
|
||||
import EventMetadataBlock from "@/components/Event/EventMetadataBlock.vue";
|
||||
import {
|
||||
EVENT_PERSON_PARTICIPATION,
|
||||
EVENT_PERSON_PARTICIPATION_SUBSCRIPTION_CHANGED,
|
||||
FETCH_EVENT,
|
||||
JOIN_EVENT,
|
||||
} from '@/graphql/event';
|
||||
import { Component, Prop, Watch } from 'vue-property-decorator';
|
||||
import { CURRENT_ACTOR_CLIENT } from '@/graphql/actor';
|
||||
import {
|
||||
EventModel,
|
||||
EventStatus,
|
||||
EventVisibility,
|
||||
IEvent,
|
||||
IParticipant,
|
||||
ParticipantRole,
|
||||
EventJoinOptions,
|
||||
} from '@/types/event.model';
|
||||
import { IPerson, Person } from '@/types/actor';
|
||||
import { GRAPHQL_API_ENDPOINT } from '@/api/_entrypoint';
|
||||
import DateCalendarIcon from '@/components/Event/DateCalendarIcon.vue';
|
||||
import BIcon from 'buefy/src/components/icon/Icon.vue';
|
||||
import EventCard from '@/components/Event/EventCard.vue';
|
||||
import EventFullDate from '@/components/Event/EventFullDate.vue';
|
||||
import ActorLink from '@/components/Account/ActorLink.vue';
|
||||
import ReportModal from '@/components/Report/ReportModal.vue';
|
||||
import { IReport } from '@/types/report.model';
|
||||
import { CREATE_REPORT } from '@/graphql/report';
|
||||
import EventMixin from '@/mixins/event';
|
||||
import IdentityPicker from '@/views/Account/IdentityPicker.vue';
|
||||
import ParticipationButton from '@/components/Event/ParticipationButton.vue';
|
||||
import { GraphQLError } from 'graphql';
|
||||
import { RouteName } from '@/router';
|
||||
import { Address } from '@/types/address.model';
|
||||
import CommentTree from '@/components/Comment/CommentTree.vue';
|
||||
import 'intersection-observer';
|
||||
import { CONFIG } from '@/graphql/config';
|
||||
import {
|
||||
AnonymousParticipationNotFoundError,
|
||||
getLeaveTokenForParticipation,
|
||||
isParticipatingInThisEvent,
|
||||
removeAnonymousParticipation,
|
||||
} from '@/services/AnonymousParticipationStorage';
|
||||
import { IConfig } from '@/types/config.model';
|
||||
import Subtitle from '@/components/Utils/Subtitle.vue';
|
||||
import Tag from '@/components/Tag.vue';
|
||||
import EventMetadataBlock from '@/components/Event/EventMetadataBlock.vue';
|
||||
|
||||
@Component({
|
||||
components: {
|
||||
EventMetadataBlock,
|
||||
Subtitle,
|
||||
ActorLink,
|
||||
EventFullDate,
|
||||
EventCard,
|
||||
BIcon,
|
||||
DateCalendarIcon,
|
||||
ReportModal,
|
||||
IdentityPicker,
|
||||
ParticipationButton,
|
||||
CommentTree,
|
||||
Tag,
|
||||
// tslint:disable:space-in-parens
|
||||
'map-leaflet': () => import(/* webpackChunkName: "map" */ '@/components/Map.vue'),
|
||||
// tslint:enable
|
||||
@Component({
|
||||
components: {
|
||||
EventMetadataBlock,
|
||||
Subtitle,
|
||||
ActorLink,
|
||||
EventFullDate,
|
||||
EventCard,
|
||||
BIcon,
|
||||
DateCalendarIcon,
|
||||
ReportModal,
|
||||
IdentityPicker,
|
||||
ParticipationButton,
|
||||
CommentTree,
|
||||
Tag,
|
||||
// tslint:disable:space-in-parens
|
||||
'map-leaflet': () => import(/* webpackChunkName: "map" */ '@/components/Map.vue'),
|
||||
// tslint:enable
|
||||
},
|
||||
apollo: {
|
||||
event: {
|
||||
query: FETCH_EVENT,
|
||||
variables() {
|
||||
return {
|
||||
uuid: this.uuid,
|
||||
};
|
||||
},
|
||||
error({ graphQLErrors }) {
|
||||
this.handleErrors(graphQLErrors);
|
||||
},
|
||||
},
|
||||
currentActor: {
|
||||
query: CURRENT_ACTOR_CLIENT,
|
||||
},
|
||||
participations: {
|
||||
query: EVENT_PERSON_PARTICIPATION,
|
||||
variables() {
|
||||
return {
|
||||
eventId: this.event.id,
|
||||
actorId: this.currentActor.id,
|
||||
};
|
||||
},
|
||||
subscribeToMore: {
|
||||
document: EVENT_PERSON_PARTICIPATION_SUBSCRIPTION_CHANGED,
|
||||
variables() {
|
||||
return {
|
||||
eventId: this.event.id,
|
||||
actorId: this.currentActor.id,
|
||||
};
|
||||
},
|
||||
apollo: {
|
||||
event: {
|
||||
query: FETCH_EVENT,
|
||||
variables() {
|
||||
return {
|
||||
uuid: this.uuid,
|
||||
};
|
||||
},
|
||||
error({graphQLErrors}) {
|
||||
this.handleErrors(graphQLErrors);
|
||||
},
|
||||
},
|
||||
currentActor: {
|
||||
query: CURRENT_ACTOR_CLIENT,
|
||||
},
|
||||
participations: {
|
||||
query: EVENT_PERSON_PARTICIPATION,
|
||||
variables() {
|
||||
return {
|
||||
eventId: this.event.id,
|
||||
actorId: this.currentActor.id,
|
||||
};
|
||||
},
|
||||
subscribeToMore: {
|
||||
document: EVENT_PERSON_PARTICIPATION_SUBSCRIPTION_CHANGED,
|
||||
variables() {
|
||||
return {
|
||||
eventId: this.event.id,
|
||||
actorId: this.currentActor.id,
|
||||
};
|
||||
},
|
||||
},
|
||||
update: (data) => {
|
||||
if (data && data.person) return data.person.participations;
|
||||
return [];
|
||||
},
|
||||
skip() {
|
||||
return !this.currentActor || !this.event || !this.event.id || !this.currentActor.id;
|
||||
},
|
||||
},
|
||||
config: CONFIG,
|
||||
},
|
||||
metaInfo() {
|
||||
return {
|
||||
// if no subcomponents specify a metaInfo.title, this title will be used
|
||||
},
|
||||
update: (data) => {
|
||||
if (data && data.person) return data.person.participations;
|
||||
return [];
|
||||
},
|
||||
skip() {
|
||||
return !this.currentActor || !this.event || !this.event.id || !this.currentActor.id;
|
||||
},
|
||||
},
|
||||
config: CONFIG,
|
||||
},
|
||||
metaInfo() {
|
||||
return {
|
||||
// if no subcomponents specify a metaInfo.title, this title will be used
|
||||
// @ts-ignore
|
||||
title: this.eventTitle,
|
||||
// all titles will be injected into this template
|
||||
titleTemplate: '%s | Mobilizon',
|
||||
meta: [
|
||||
// @ts-ignore
|
||||
title: this.eventTitle,
|
||||
// all titles will be injected into this template
|
||||
titleTemplate: '%s | Mobilizon',
|
||||
meta: [
|
||||
// @ts-ignore
|
||||
{name: 'description', content: this.eventDescription},
|
||||
],
|
||||
};
|
||||
},
|
||||
})
|
||||
export default class Event extends EventMixin {
|
||||
@Prop({type: String, required: true}) uuid!: string;
|
||||
{ name: 'description', content: this.eventDescription },
|
||||
],
|
||||
};
|
||||
},
|
||||
})
|
||||
export default class Event extends EventMixin {
|
||||
@Prop({ type: String, required: true }) uuid!: string;
|
||||
|
||||
event: IEvent = new EventModel();
|
||||
currentActor!: IPerson;
|
||||
identity: IPerson = new Person();
|
||||
config!: IConfig;
|
||||
participations: IParticipant[] = [];
|
||||
oldParticipationRole!: String;
|
||||
showMap: boolean = false;
|
||||
isReportModalActive: boolean = false;
|
||||
isJoinModalActive: boolean = false;
|
||||
isJoinConfirmationModalActive: boolean = false;
|
||||
EventVisibility = EventVisibility;
|
||||
EventStatus = EventStatus;
|
||||
EventJoinOptions = EventJoinOptions;
|
||||
RouteName = RouteName;
|
||||
observer!: IntersectionObserver;
|
||||
loadComments: boolean = false;
|
||||
anonymousParticipation: boolean | null = null;
|
||||
actorForConfirmation!: IPerson;
|
||||
messageForConfirmation: string = '';
|
||||
event: IEvent = new EventModel();
|
||||
currentActor!: IPerson;
|
||||
identity: IPerson = new Person();
|
||||
config!: IConfig;
|
||||
participations: IParticipant[] = [];
|
||||
oldParticipationRole!: String;
|
||||
showMap: boolean = false;
|
||||
isReportModalActive: boolean = false;
|
||||
isJoinModalActive: boolean = false;
|
||||
isJoinConfirmationModalActive: boolean = false;
|
||||
EventVisibility = EventVisibility;
|
||||
EventStatus = EventStatus;
|
||||
EventJoinOptions = EventJoinOptions;
|
||||
RouteName = RouteName;
|
||||
observer!: IntersectionObserver;
|
||||
loadComments: boolean = false;
|
||||
anonymousParticipation: boolean | null = null;
|
||||
actorForConfirmation!: IPerson;
|
||||
messageForConfirmation: string = '';
|
||||
|
||||
get eventTitle() {
|
||||
if (!this.event) return undefined;
|
||||
return this.event.title;
|
||||
}
|
||||
get eventTitle() {
|
||||
if (!this.event) return undefined;
|
||||
return this.event.title;
|
||||
}
|
||||
|
||||
get eventDescription() {
|
||||
if (!this.event) return undefined;
|
||||
return this.event.description;
|
||||
}
|
||||
get eventDescription() {
|
||||
if (!this.event) return undefined;
|
||||
return this.event.description;
|
||||
}
|
||||
|
||||
async mounted() {
|
||||
this.identity = this.currentActor;
|
||||
if (this.$route.hash.includes('#comment-')) {
|
||||
this.loadComments = true;
|
||||
}
|
||||
|
||||
try {
|
||||
this.anonymousParticipation = await this.anonymousParticipationConfirmed();
|
||||
} catch (e) {
|
||||
if (e instanceof AnonymousParticipationNotFoundError) {
|
||||
this.anonymousParticipation = null;
|
||||
} else {
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
|
||||
this.observer = new IntersectionObserver((entries) => {
|
||||
for (const entry of entries) {
|
||||
if (entry) {
|
||||
this.loadComments = entry.isIntersecting || this.loadComments;
|
||||
}
|
||||
}
|
||||
}, {
|
||||
rootMargin: '-50px 0px -50px',
|
||||
});
|
||||
this.observer.observe(this.$refs.commentsObserver as Element);
|
||||
|
||||
this.$watch('eventDescription', function (eventDescription) {
|
||||
if (!eventDescription) return;
|
||||
const eventDescriptionElement = this.$refs.eventDescriptionElement as HTMLElement;
|
||||
|
||||
eventDescriptionElement.addEventListener('click', ($event) => {
|
||||
// TODO: Find the right type for target
|
||||
let {target}: { target: any } = $event;
|
||||
while (target && target.tagName !== 'A') target = target.parentNode;
|
||||
// handle only links that occur inside the component and do not reference external resources
|
||||
if (target && target.matches('.hashtag') && target.href) {
|
||||
// some sanity checks taken from vue-router:
|
||||
// https://github.com/vuejs/vue-router/blob/dev/src/components/link.js#L106
|
||||
const {altKey, ctrlKey, metaKey, shiftKey, button, defaultPrevented} = $event;
|
||||
// don't handle with control keys
|
||||
if (metaKey || altKey || ctrlKey || shiftKey) return;
|
||||
// don't handle when preventDefault called
|
||||
if (defaultPrevented) return;
|
||||
// don't handle right clicks
|
||||
if (button !== undefined && button !== 0) return;
|
||||
// don't handle if `target="_blank"`
|
||||
if (target && target.getAttribute) {
|
||||
const linkTarget = target.getAttribute('target');
|
||||
if (/\b_blank\b/i.test(linkTarget)) return;
|
||||
}
|
||||
// don't handle same page links/anchors
|
||||
const url = new URL(target.href);
|
||||
const to = url.pathname;
|
||||
if (window.location.pathname !== to && $event.preventDefault) {
|
||||
$event.preventDefault();
|
||||
this.$router.push(to);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete the event, then redirect to home.
|
||||
*/
|
||||
async openDeleteEventModalWrapper() {
|
||||
await this.openDeleteEventModal(this.event, this.currentActor);
|
||||
}
|
||||
|
||||
async reportEvent(content: string, forward: boolean) {
|
||||
this.isReportModalActive = false;
|
||||
if (!this.event.organizerActor) return;
|
||||
const eventTitle = this.event.title;
|
||||
try {
|
||||
await this.$apollo.mutate<IReport>({
|
||||
mutation: CREATE_REPORT,
|
||||
variables: {
|
||||
eventId: this.event.id,
|
||||
reporterId: this.currentActor.id,
|
||||
reportedId: this.event.organizerActor.id,
|
||||
content,
|
||||
forward,
|
||||
},
|
||||
});
|
||||
this.$notifier.success(this.$t('Event {eventTitle} reported', {eventTitle}) as string);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
|
||||
joinEventWithConfirmation(actor: IPerson) {
|
||||
this.isJoinConfirmationModalActive = true;
|
||||
this.actorForConfirmation = actor;
|
||||
}
|
||||
|
||||
async joinEvent(identity: IPerson, message: string | null = null) {
|
||||
this.isJoinConfirmationModalActive = false;
|
||||
this.isJoinModalActive = false;
|
||||
try {
|
||||
const {data} = await this.$apollo.mutate<{ joinEvent: IParticipant }>({
|
||||
mutation: JOIN_EVENT,
|
||||
variables: {
|
||||
eventId: this.event.id,
|
||||
actorId: identity.id,
|
||||
message,
|
||||
},
|
||||
update: (store, {data}) => {
|
||||
if (data == null) return;
|
||||
|
||||
const participationCachedData = store.readQuery<{ person: IPerson }>({
|
||||
query: EVENT_PERSON_PARTICIPATION,
|
||||
variables: {eventId: this.event.id, actorId: identity.id},
|
||||
});
|
||||
if (participationCachedData == null) return;
|
||||
const {person} = participationCachedData;
|
||||
if (person === null) {
|
||||
console.error('Cannot update participation cache, because of null value.');
|
||||
return;
|
||||
}
|
||||
person.participations.push(data.joinEvent);
|
||||
store.writeQuery({
|
||||
query: EVENT_PERSON_PARTICIPATION,
|
||||
variables: {eventId: this.event.id, actorId: identity.id},
|
||||
data: {person},
|
||||
});
|
||||
|
||||
const cachedData = store.readQuery<{ event: IEvent }>({
|
||||
query: FETCH_EVENT,
|
||||
variables: {uuid: this.event.uuid}
|
||||
});
|
||||
if (cachedData == null) return;
|
||||
const {event} = cachedData;
|
||||
if (event === null) {
|
||||
console.error('Cannot update event participant cache, because of null value.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.joinEvent.role === ParticipantRole.NOT_APPROVED) {
|
||||
event.participantStats.notApproved = event.participantStats.notApproved + 1;
|
||||
} else {
|
||||
event.participantStats.going = event.participantStats.going + 1;
|
||||
event.participantStats.participant = event.participantStats.participant + 1;
|
||||
}
|
||||
|
||||
store.writeQuery({query: FETCH_EVENT, variables: {uuid: this.uuid}, data: {event}});
|
||||
},
|
||||
});
|
||||
if (data) {
|
||||
if (data.joinEvent.role === ParticipantRole.NOT_APPROVED) {
|
||||
this.participationRequestedMessage();
|
||||
} else {
|
||||
this.participationConfirmedMessage();
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
|
||||
confirmLeave() {
|
||||
this.$buefy.dialog.confirm({
|
||||
title: this.$t('Leaving event "{title}"', {title: this.event.title}) as string,
|
||||
message: this.$t('Are you sure you want to cancel your participation at event "{title}"?', {title: this.event.title}) as string,
|
||||
confirmText: this.$t('Leave event') as string,
|
||||
cancelText: this.$t('Cancel') as string,
|
||||
type: 'is-danger',
|
||||
hasIcon: true,
|
||||
onConfirm: () => {
|
||||
if (this.currentActor.id) {
|
||||
this.leaveEvent(this.event, this.currentActor.id);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@Watch('participations')
|
||||
watchParticipations() {
|
||||
if (this.participations.length > 0) {
|
||||
if (this.oldParticipationRole
|
||||
&& this.participations[0].role !== ParticipantRole.NOT_APPROVED
|
||||
&& this.oldParticipationRole !== this.participations[0].role) {
|
||||
switch (this.participations[0].role) {
|
||||
case ParticipantRole.PARTICIPANT:
|
||||
this.participationConfirmedMessage();
|
||||
break;
|
||||
case ParticipantRole.REJECTED:
|
||||
this.participationRejectedMessage();
|
||||
break;
|
||||
default:
|
||||
this.participationChangedMessage();
|
||||
break;
|
||||
}
|
||||
}
|
||||
this.oldParticipationRole = this.participations[0].role;
|
||||
}
|
||||
}
|
||||
|
||||
private participationConfirmedMessage() {
|
||||
this.$notifier.success(this.$t('Your participation has been confirmed') as string);
|
||||
}
|
||||
|
||||
private participationRequestedMessage() {
|
||||
this.$notifier.success(this.$t('Your participation has been requested') as string);
|
||||
}
|
||||
|
||||
private participationRejectedMessage() {
|
||||
this.$notifier.error(this.$t('Your participation has been rejected') as string);
|
||||
}
|
||||
|
||||
private participationChangedMessage() {
|
||||
this.$notifier.info(this.$t('Your participation status has been changed') as string);
|
||||
}
|
||||
|
||||
async downloadIcsEvent() {
|
||||
const data = await (await fetch(`${GRAPHQL_API_ENDPOINT}/events/${this.uuid}/export/ics`)).text();
|
||||
const blob = new Blob([data], {type: 'text/calendar'});
|
||||
const link = document.createElement('a');
|
||||
link.href = window.URL.createObjectURL(blob);
|
||||
link.download = `${this.event.title}.ics`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
}
|
||||
|
||||
async handleErrors(errors: GraphQLError) {
|
||||
if (errors[0].message.includes('not found') || errors[0].message.includes('has invalid value $uuid')) {
|
||||
await this.$router.push({name: RouteName.PAGE_NOT_FOUND});
|
||||
}
|
||||
}
|
||||
|
||||
get actorIsParticipant() {
|
||||
if (this.actorIsOrganizer) return true;
|
||||
|
||||
return this.participations.length > 0 && this.participations[0].role === ParticipantRole.PARTICIPANT;
|
||||
}
|
||||
|
||||
get actorIsOrganizer() {
|
||||
return this.participations.length > 0 && this.participations[0].role === ParticipantRole.CREATOR;
|
||||
}
|
||||
|
||||
get endDate() {
|
||||
return this.event.endsOn !== null && this.event.endsOn > this.event.beginsOn ? this.event.endsOn : this.event.beginsOn;
|
||||
}
|
||||
|
||||
get twitterShareUrl(): string {
|
||||
return `https://twitter.com/intent/tweet?url=${encodeURIComponent(this.event.url)}&text=${this.event.title}`;
|
||||
}
|
||||
|
||||
get facebookShareUrl(): string {
|
||||
return `https://www.facebook.com/sharer/sharer.php?u=${encodeURIComponent(this.event.url)}`;
|
||||
}
|
||||
|
||||
get linkedInShareUrl(): string {
|
||||
return `https://www.linkedin.com/shareArticle?mini=true&url=${encodeURIComponent(this.event.url)}&title=${this.event.title}`;
|
||||
}
|
||||
|
||||
get emailShareUrl(): string {
|
||||
return `mailto:?to=&body=${this.event.url}${encodeURIComponent('\n\n')}${this.textDescription}&subject=${this.event.title}`;
|
||||
}
|
||||
|
||||
get diasporaShareUrl(): string {
|
||||
return `https://share.diasporafoundation.org/?title=${encodeURIComponent(this.event.title)}&url=${encodeURIComponent(this.event.url)}`;
|
||||
}
|
||||
|
||||
get textDescription(): string {
|
||||
const meta = document.querySelector("meta[property='og:description']");
|
||||
if (!meta) return '';
|
||||
const desc = meta.getAttribute('content') || '';
|
||||
return desc.substring(0, 1000);
|
||||
}
|
||||
|
||||
get eventCapacityOK(): boolean {
|
||||
if (this.event.draft) return true;
|
||||
if (!this.event.options.maximumAttendeeCapacity) return true;
|
||||
return this.event.options.maximumAttendeeCapacity > this.event.participantStats.participant;
|
||||
}
|
||||
|
||||
get numberOfPlacesStillAvailable(): number {
|
||||
if (this.event.draft) return this.event.options.maximumAttendeeCapacity;
|
||||
return this.event.options.maximumAttendeeCapacity - this.event.participantStats.participant;
|
||||
}
|
||||
|
||||
urlToHostname(url: string): string | null {
|
||||
try {
|
||||
return (new URL(url)).hostname;
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
get physicalAddress(): Address | null {
|
||||
if (!this.event.physicalAddress) return null;
|
||||
return new Address(this.event.physicalAddress);
|
||||
}
|
||||
|
||||
async anonymousParticipationConfirmed(): Promise<boolean> {
|
||||
return await isParticipatingInThisEvent(this.uuid);
|
||||
}
|
||||
|
||||
async cancelAnonymousParticipation() {
|
||||
const token = await getLeaveTokenForParticipation(this.uuid) as String;
|
||||
await this.leaveEvent(this.event, this.config.anonymous.actorId, token);
|
||||
await removeAnonymousParticipation(this.uuid);
|
||||
this.anonymousParticipation = null;
|
||||
}
|
||||
async mounted() {
|
||||
this.identity = this.currentActor;
|
||||
if (this.$route.hash.includes('#comment-')) {
|
||||
this.loadComments = true;
|
||||
}
|
||||
|
||||
try {
|
||||
this.anonymousParticipation = await this.anonymousParticipationConfirmed();
|
||||
} catch (e) {
|
||||
if (e instanceof AnonymousParticipationNotFoundError) {
|
||||
this.anonymousParticipation = null;
|
||||
} else {
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
|
||||
this.observer = new IntersectionObserver((entries) => {
|
||||
for (const entry of entries) {
|
||||
if (entry) {
|
||||
this.loadComments = entry.isIntersecting || this.loadComments;
|
||||
}
|
||||
}
|
||||
}, {
|
||||
rootMargin: '-50px 0px -50px',
|
||||
});
|
||||
this.observer.observe(this.$refs.commentsObserver as Element);
|
||||
|
||||
this.$watch('eventDescription', function (eventDescription) {
|
||||
if (!eventDescription) return;
|
||||
const eventDescriptionElement = this.$refs.eventDescriptionElement as HTMLElement;
|
||||
|
||||
eventDescriptionElement.addEventListener('click', ($event) => {
|
||||
// TODO: Find the right type for target
|
||||
let { target }: { target: any } = $event;
|
||||
while (target && target.tagName !== 'A') target = target.parentNode;
|
||||
// handle only links that occur inside the component and do not reference external resources
|
||||
if (target && target.matches('.hashtag') && target.href) {
|
||||
// some sanity checks taken from vue-router:
|
||||
// https://github.com/vuejs/vue-router/blob/dev/src/components/link.js#L106
|
||||
const { altKey, ctrlKey, metaKey, shiftKey, button, defaultPrevented } = $event;
|
||||
// don't handle with control keys
|
||||
if (metaKey || altKey || ctrlKey || shiftKey) return;
|
||||
// don't handle when preventDefault called
|
||||
if (defaultPrevented) return;
|
||||
// don't handle right clicks
|
||||
if (button !== undefined && button !== 0) return;
|
||||
// don't handle if `target="_blank"`
|
||||
if (target && target.getAttribute) {
|
||||
const linkTarget = target.getAttribute('target');
|
||||
if (/\b_blank\b/i.test(linkTarget)) return;
|
||||
}
|
||||
// don't handle same page links/anchors
|
||||
const url = new URL(target.href);
|
||||
const to = url.pathname;
|
||||
if (window.location.pathname !== to && $event.preventDefault) {
|
||||
$event.preventDefault();
|
||||
this.$router.push(to);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete the event, then redirect to home.
|
||||
*/
|
||||
async openDeleteEventModalWrapper() {
|
||||
await this.openDeleteEventModal(this.event, this.currentActor);
|
||||
}
|
||||
|
||||
async reportEvent(content: string, forward: boolean) {
|
||||
this.isReportModalActive = false;
|
||||
if (!this.event.organizerActor) return;
|
||||
const eventTitle = this.event.title;
|
||||
try {
|
||||
await this.$apollo.mutate<IReport>({
|
||||
mutation: CREATE_REPORT,
|
||||
variables: {
|
||||
eventId: this.event.id,
|
||||
reporterId: this.currentActor.id,
|
||||
reportedId: this.event.organizerActor.id,
|
||||
content,
|
||||
forward,
|
||||
},
|
||||
});
|
||||
this.$notifier.success(this.$t('Event {eventTitle} reported', { eventTitle }) as string);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
|
||||
joinEventWithConfirmation(actor: IPerson) {
|
||||
this.isJoinConfirmationModalActive = true;
|
||||
this.actorForConfirmation = actor;
|
||||
}
|
||||
|
||||
async joinEvent(identity: IPerson, message: string | null = null) {
|
||||
this.isJoinConfirmationModalActive = false;
|
||||
this.isJoinModalActive = false;
|
||||
try {
|
||||
const { data } = await this.$apollo.mutate<{ joinEvent: IParticipant }>({
|
||||
mutation: JOIN_EVENT,
|
||||
variables: {
|
||||
eventId: this.event.id,
|
||||
actorId: identity.id,
|
||||
message,
|
||||
},
|
||||
update: (store, { data }) => {
|
||||
if (data == null) return;
|
||||
|
||||
const participationCachedData = store.readQuery<{ person: IPerson }>({
|
||||
query: EVENT_PERSON_PARTICIPATION,
|
||||
variables: { eventId: this.event.id, actorId: identity.id },
|
||||
});
|
||||
if (participationCachedData == null) return;
|
||||
const { person } = participationCachedData;
|
||||
if (person === null) {
|
||||
console.error('Cannot update participation cache, because of null value.');
|
||||
return;
|
||||
}
|
||||
person.participations.push(data.joinEvent);
|
||||
store.writeQuery({
|
||||
query: EVENT_PERSON_PARTICIPATION,
|
||||
variables: { eventId: this.event.id, actorId: identity.id },
|
||||
data: { person },
|
||||
});
|
||||
|
||||
const cachedData = store.readQuery<{ event: IEvent }>({
|
||||
query: FETCH_EVENT,
|
||||
variables: { uuid: this.event.uuid },
|
||||
});
|
||||
if (cachedData == null) return;
|
||||
const { event } = cachedData;
|
||||
if (event === null) {
|
||||
console.error('Cannot update event participant cache, because of null value.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.joinEvent.role === ParticipantRole.NOT_APPROVED) {
|
||||
event.participantStats.notApproved = event.participantStats.notApproved + 1;
|
||||
} else {
|
||||
event.participantStats.going = event.participantStats.going + 1;
|
||||
event.participantStats.participant = event.participantStats.participant + 1;
|
||||
}
|
||||
|
||||
store.writeQuery({ query: FETCH_EVENT, variables: { uuid: this.uuid }, data: { event } });
|
||||
},
|
||||
});
|
||||
if (data) {
|
||||
if (data.joinEvent.role === ParticipantRole.NOT_APPROVED) {
|
||||
this.participationRequestedMessage();
|
||||
} else {
|
||||
this.participationConfirmedMessage();
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
|
||||
confirmLeave() {
|
||||
this.$buefy.dialog.confirm({
|
||||
title: this.$t('Leaving event "{title}"', { title: this.event.title }) as string,
|
||||
message: this.$t('Are you sure you want to cancel your participation at event "{title}"?', { title: this.event.title }) as string,
|
||||
confirmText: this.$t('Leave event') as string,
|
||||
cancelText: this.$t('Cancel') as string,
|
||||
type: 'is-danger',
|
||||
hasIcon: true,
|
||||
onConfirm: () => {
|
||||
if (this.currentActor.id) {
|
||||
this.leaveEvent(this.event, this.currentActor.id);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@Watch('participations')
|
||||
watchParticipations() {
|
||||
if (this.participations.length > 0) {
|
||||
if (this.oldParticipationRole
|
||||
&& this.participations[0].role !== ParticipantRole.NOT_APPROVED
|
||||
&& this.oldParticipationRole !== this.participations[0].role) {
|
||||
switch (this.participations[0].role) {
|
||||
case ParticipantRole.PARTICIPANT:
|
||||
this.participationConfirmedMessage();
|
||||
break;
|
||||
case ParticipantRole.REJECTED:
|
||||
this.participationRejectedMessage();
|
||||
break;
|
||||
default:
|
||||
this.participationChangedMessage();
|
||||
break;
|
||||
}
|
||||
}
|
||||
this.oldParticipationRole = this.participations[0].role;
|
||||
}
|
||||
}
|
||||
|
||||
private participationConfirmedMessage() {
|
||||
this.$notifier.success(this.$t('Your participation has been confirmed') as string);
|
||||
}
|
||||
|
||||
private participationRequestedMessage() {
|
||||
this.$notifier.success(this.$t('Your participation has been requested') as string);
|
||||
}
|
||||
|
||||
private participationRejectedMessage() {
|
||||
this.$notifier.error(this.$t('Your participation has been rejected') as string);
|
||||
}
|
||||
|
||||
private participationChangedMessage() {
|
||||
this.$notifier.info(this.$t('Your participation status has been changed') as string);
|
||||
}
|
||||
|
||||
async downloadIcsEvent() {
|
||||
const data = await (await fetch(`${GRAPHQL_API_ENDPOINT}/events/${this.uuid}/export/ics`)).text();
|
||||
const blob = new Blob([data], { type: 'text/calendar' });
|
||||
const link = document.createElement('a');
|
||||
link.href = window.URL.createObjectURL(blob);
|
||||
link.download = `${this.event.title}.ics`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
}
|
||||
|
||||
async handleErrors(errors: GraphQLError) {
|
||||
if (errors[0].message.includes('not found') || errors[0].message.includes('has invalid value $uuid')) {
|
||||
await this.$router.push({ name: RouteName.PAGE_NOT_FOUND });
|
||||
}
|
||||
}
|
||||
|
||||
get actorIsParticipant() {
|
||||
if (this.actorIsOrganizer) return true;
|
||||
|
||||
return this.participations.length > 0 && this.participations[0].role === ParticipantRole.PARTICIPANT;
|
||||
}
|
||||
|
||||
get actorIsOrganizer() {
|
||||
return this.participations.length > 0 && this.participations[0].role === ParticipantRole.CREATOR;
|
||||
}
|
||||
|
||||
get endDate() {
|
||||
return this.event.endsOn !== null && this.event.endsOn > this.event.beginsOn ? this.event.endsOn : this.event.beginsOn;
|
||||
}
|
||||
|
||||
get twitterShareUrl(): string {
|
||||
return `https://twitter.com/intent/tweet?url=${encodeURIComponent(this.event.url)}&text=${this.event.title}`;
|
||||
}
|
||||
|
||||
get facebookShareUrl(): string {
|
||||
return `https://www.facebook.com/sharer/sharer.php?u=${encodeURIComponent(this.event.url)}`;
|
||||
}
|
||||
|
||||
get linkedInShareUrl(): string {
|
||||
return `https://www.linkedin.com/shareArticle?mini=true&url=${encodeURIComponent(this.event.url)}&title=${this.event.title}`;
|
||||
}
|
||||
|
||||
get emailShareUrl(): string {
|
||||
return `mailto:?to=&body=${this.event.url}${encodeURIComponent('\n\n')}${this.textDescription}&subject=${this.event.title}`;
|
||||
}
|
||||
|
||||
get diasporaShareUrl(): string {
|
||||
return `https://share.diasporafoundation.org/?title=${encodeURIComponent(this.event.title)}&url=${encodeURIComponent(this.event.url)}`;
|
||||
}
|
||||
|
||||
get textDescription(): string {
|
||||
const meta = document.querySelector("meta[property='og:description']");
|
||||
if (!meta) return '';
|
||||
const desc = meta.getAttribute('content') || '';
|
||||
return desc.substring(0, 1000);
|
||||
}
|
||||
|
||||
get eventCapacityOK(): boolean {
|
||||
if (this.event.draft) return true;
|
||||
if (!this.event.options.maximumAttendeeCapacity) return true;
|
||||
return this.event.options.maximumAttendeeCapacity > this.event.participantStats.participant;
|
||||
}
|
||||
|
||||
get numberOfPlacesStillAvailable(): number {
|
||||
if (this.event.draft) return this.event.options.maximumAttendeeCapacity;
|
||||
return this.event.options.maximumAttendeeCapacity - this.event.participantStats.participant;
|
||||
}
|
||||
|
||||
urlToHostname(url: string): string | null {
|
||||
try {
|
||||
return (new URL(url)).hostname;
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
get physicalAddress(): Address | null {
|
||||
if (!this.event.physicalAddress) return null;
|
||||
return new Address(this.event.physicalAddress);
|
||||
}
|
||||
|
||||
async anonymousParticipationConfirmed(): Promise<boolean> {
|
||||
return await isParticipatingInThisEvent(this.uuid);
|
||||
}
|
||||
|
||||
async cancelAnonymousParticipation() {
|
||||
const token = await getLeaveTokenForParticipation(this.uuid) as String;
|
||||
await this.leaveEvent(this.event, this.config.anonymous.actorId, token);
|
||||
await removeAnonymousParticipation(this.uuid);
|
||||
this.anonymousParticipation = null;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
@import "../../variables";
|
||||
|
|
|
@ -1,287 +1,308 @@
|
|||
<template>
|
||||
<div class="container is-widescreen">
|
||||
<div v-if="group && groupMemberships && groupMemberships.includes(group.id)" class="block-container">
|
||||
<div class="block-column">
|
||||
<nav class="breadcrumb" aria-label="breadcrumbs" v-if="group">
|
||||
<ul>
|
||||
<li>
|
||||
<router-link :to="{ name: RouteName.MY_GROUPS }">{{ $t('My groups') }}</router-link>
|
||||
</li>
|
||||
<li class="is-active">
|
||||
<router-link
|
||||
:to="{ name: RouteName.GROUP, params:{ preferredUsername: group.preferredUsername } }">
|
||||
{{ `@${group.preferredUsername}` }}
|
||||
</router-link>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
<!-- <section class="card-image" v-if="group.banner">-->
|
||||
<!-- <figure class="image">-->
|
||||
<!-- <img :src="group.banner.url"/>-->
|
||||
<!-- </figure>-->
|
||||
<!-- </section>-->
|
||||
<section class="presentation">
|
||||
<div class="media">
|
||||
<div class="media-left">
|
||||
<figure class="image is-128x128" v-if="group.avatar">
|
||||
<img :src="group.avatar.url"/>
|
||||
</figure>
|
||||
<b-icon v-else size="is-large" icon="account-group"/>
|
||||
</div>
|
||||
<div class="media-content">
|
||||
<h2>{{ group.name }}</h2>
|
||||
<small class="has-text-grey">@{{ group.preferredUsername }}</small>
|
||||
<span v-if="group.domain">{{ group.domain }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="members">
|
||||
<figure class="image is-48x48 is-rounded"
|
||||
:title="$t(`@{username} ({role})`, {username: member.actor.preferredUsername, role: member.role})"
|
||||
v-for="member in group.members.elements" :key="member.actor.id">
|
||||
<img :src="member.actor.avatar.url"/>
|
||||
</figure>
|
||||
</div>
|
||||
</section>
|
||||
<section>
|
||||
<subtitle>
|
||||
{{ $t('Upcoming events') }}
|
||||
</subtitle>
|
||||
<div class="organized-events-wrapper" v-if="group.organizedEvents.total > 0">
|
||||
<EventMinimalistCard
|
||||
v-for="event in group.organizedEvents.elements"
|
||||
:event="event"
|
||||
:key="event.uuid"
|
||||
class="organized-event"
|
||||
/>
|
||||
</div>
|
||||
<router-link :to="{}">{{ $t('View all upcoming events') }}</router-link>
|
||||
</section>
|
||||
<section>
|
||||
<subtitle>
|
||||
{{ $t('Collections') }}
|
||||
</subtitle>
|
||||
<b-button type="is-primary">{{ $t('Créer une collection') }}</b-button>
|
||||
<div class="columns" v-if="group.collections.elements.length > 0">
|
||||
<span
|
||||
v-for="collection in group.collections.elements"
|
||||
:key="collection.id"
|
||||
>{{ collection }}</span>
|
||||
</div>
|
||||
</section>
|
||||
<div class="container is-widescreen">
|
||||
<div
|
||||
v-if="group && groupMemberships && groupMemberships.includes(group.id)"
|
||||
class="block-container"
|
||||
>
|
||||
<div class="block-column">
|
||||
<nav class="breadcrumb" aria-label="breadcrumbs" v-if="group">
|
||||
<ul>
|
||||
<li>
|
||||
<router-link :to="{ name: RouteName.MY_GROUPS }">{{ $t('My groups') }}</router-link>
|
||||
</li>
|
||||
<li class="is-active">
|
||||
<router-link
|
||||
:to="{ name: RouteName.GROUP, params:{ preferredUsername: group.preferredUsername } }"
|
||||
>{{ `@${group.preferredUsername}` }}</router-link>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
<!-- <section class="card-image" v-if="group.banner">-->
|
||||
<!-- <figure class="image">-->
|
||||
<!-- <img :src="group.banner.url"/>-->
|
||||
<!-- </figure>-->
|
||||
<!-- </section>-->
|
||||
<section class="presentation">
|
||||
<div class="media">
|
||||
<div class="media-left">
|
||||
<figure class="image is-128x128" v-if="group.avatar">
|
||||
<img :src="group.avatar.url" />
|
||||
</figure>
|
||||
<b-icon v-else size="is-large" icon="account-group" />
|
||||
</div>
|
||||
<div class="block-column">
|
||||
<section>
|
||||
<p>{{ $t('Recent activity') }}</p>
|
||||
</section>
|
||||
<section>
|
||||
<subtitle>
|
||||
{{ $t('Public page') }}
|
||||
</subtitle>
|
||||
<p>{{ $t('Followed by {count} persons', { count: group.members.total }) }}</p>
|
||||
<b-button type="is-light">{{ $t('Edit biography') }}</b-button>
|
||||
<b-button type="is-primary">{{ $t('Post a public message') }}</b-button>
|
||||
</section>
|
||||
<section>
|
||||
<subtitle>
|
||||
{{ $t('Ongoing tasks') }}
|
||||
</subtitle>
|
||||
<b-button type="is-primary">{{ $t('Create a new task list') }}</b-button>
|
||||
<div v-if="group.todoLists.elements.length > 0" v-for="todoList in group.todoLists.elements"
|
||||
:key="todoList.id">
|
||||
<router-link
|
||||
:to="{ name: RouteName.TODO_LIST, params: { id: todoList.id } }"
|
||||
>
|
||||
<h3 class="is-size-3">{{ $tc('{title} ({count} todos)', todoList.todos.total, { count:
|
||||
todoList.todos.total, title: todoList.title }) }}</h3>
|
||||
</router-link>
|
||||
<compact-todo :todo="todo" v-for="todo in todoList.todos.elements.slice(0, 3)" :key="todo.id"/>
|
||||
</div>
|
||||
<router-link :to="{ name: RouteName.TODO_LISTS }">{{ $t('View all todos') }}</router-link>
|
||||
</section>
|
||||
<section>
|
||||
<subtitle>
|
||||
{{ $t('Discussions') }}
|
||||
</subtitle>
|
||||
<conversation-list-item v-if="group.conversations.total > 0"
|
||||
v-for="conversation in group.conversations.elements" :key="conversation.id"
|
||||
:conversation="conversation"/>
|
||||
<router-link :to="{ name: RouteName.CONVERSATION_LIST, params: { preferredUsername: group.preferredUsername }}">{{ $t('View all conversations') }}</router-link>
|
||||
</section>
|
||||
<div class="media-content">
|
||||
<h2>{{ group.name }}</h2>
|
||||
<small class="has-text-grey">@{{ group.preferredUsername }}</small>
|
||||
<span v-if="group.domain">{{ group.domain }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="group">
|
||||
<section class="presentation">
|
||||
<div class="media">
|
||||
<div class="media-left">
|
||||
<figure class="image is-128x128" v-if="group.avatar">
|
||||
<img :src="group.avatar.url" alt=""/>
|
||||
</figure>
|
||||
<b-icon v-else size="is-large" icon="account-group"/>
|
||||
</div>
|
||||
<div class="media-content">
|
||||
<h2>{{ group.name }}</h2>
|
||||
<small class="has-text-grey">@{{ group.preferredUsername }}</small>
|
||||
<span v-if="group.domain">{{ group.domain }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section>
|
||||
<subtitle>
|
||||
{{ $t('Upcoming events') }}
|
||||
</subtitle>
|
||||
<div class="organized-events-wrapper" v-if="group.organizedEvents.total > 0">
|
||||
<EventMinimalistCard
|
||||
v-for="event in group.organizedEvents.elements"
|
||||
:event="event"
|
||||
:key="event.uuid"
|
||||
class="organized-event"
|
||||
/>
|
||||
<router-link :to="{}">{{ $t('View all upcoming events') }}</router-link>
|
||||
</div>
|
||||
<span v-else>
|
||||
{{ $t('No public upcoming events')}}
|
||||
</span>
|
||||
</section>
|
||||
{{ group }}
|
||||
</div>
|
||||
<b-message v-else-if="!group && $apollo.loading === false" type="is-danger">
|
||||
{{ $t('No group found') }}
|
||||
</b-message>
|
||||
</div>
|
||||
<div class="members">
|
||||
<figure
|
||||
class="image is-48x48 is-rounded"
|
||||
:title="$t(`@{username} ({role})`, {username: member.actor.preferredUsername, role: member.role})"
|
||||
v-for="member in group.members.elements"
|
||||
:key="member.actor.id"
|
||||
>
|
||||
<img :src="member.actor.avatar.url" />
|
||||
</figure>
|
||||
</div>
|
||||
</section>
|
||||
<section>
|
||||
<subtitle>{{ $t('Upcoming events') }}</subtitle>
|
||||
<div class="organized-events-wrapper" v-if="group.organizedEvents.total > 0">
|
||||
<EventMinimalistCard
|
||||
v-for="event in group.organizedEvents.elements"
|
||||
:event="event"
|
||||
:key="event.uuid"
|
||||
class="organized-event"
|
||||
/>
|
||||
</div>
|
||||
<router-link :to="{}">{{ $t('View all upcoming events') }}</router-link>
|
||||
</section>
|
||||
<section>
|
||||
<subtitle>{{ $t('Resources') }}</subtitle>
|
||||
<div v-if="group.resources.elements.length > 0">
|
||||
<div v-for="resource in group.resources.elements" :key="resource.id">
|
||||
<resource-item :resource="resource" v-if="resource.type !== 'folder'" :inline="true" />
|
||||
<folder-item :resource="resource" :group="group" v-else :inline="true" />
|
||||
</div>
|
||||
</div>
|
||||
<router-link
|
||||
:to="{ name: RouteName.RESOURCE_FOLDER_ROOT, params: { preferredUsername: group.preferredUsername } }"
|
||||
>{{ $t('View all resources') }}</router-link>
|
||||
</section>
|
||||
</div>
|
||||
<div class="block-column">
|
||||
<section>
|
||||
<subtitle>{{ $t('Public page') }}</subtitle>
|
||||
<p>{{ $t('Followed by {count} persons', { count: group.members.total }) }}</p>
|
||||
<b-button type="is-light">{{ $t('Edit biography') }}</b-button>
|
||||
<b-button type="is-primary">{{ $t('Post a public message') }}</b-button>
|
||||
</section>
|
||||
<section>
|
||||
<subtitle>{{ $t('Ongoing tasks') }}</subtitle>
|
||||
<div
|
||||
v-if="group.todoLists.elements.length > 0"
|
||||
v-for="todoList in group.todoLists.elements"
|
||||
:key="todoList.id"
|
||||
>
|
||||
<router-link :to="{ name: RouteName.TODO_LIST, params: { id: todoList.id } }">
|
||||
<h3 class="is-size-3">
|
||||
{{ $tc('{title} ({count} todos)', todoList.todos.total, { count:
|
||||
todoList.todos.total, title: todoList.title }) }}
|
||||
</h3>
|
||||
</router-link>
|
||||
<compact-todo
|
||||
:todo="todo"
|
||||
v-for="todo in todoList.todos.elements.slice(0, 3)"
|
||||
:key="todo.id"
|
||||
/>
|
||||
</div>
|
||||
<router-link :to="{ name: RouteName.TODO_LISTS }">{{ $t('View all todos') }}</router-link>
|
||||
</section>
|
||||
<section>
|
||||
<subtitle>{{ $t('Discussions') }}</subtitle>
|
||||
<conversation-list-item
|
||||
v-if="group.conversations.total > 0"
|
||||
v-for="conversation in group.conversations.elements"
|
||||
:key="conversation.id"
|
||||
:conversation="conversation"
|
||||
/>
|
||||
<router-link
|
||||
:to="{ name: RouteName.CONVERSATION_LIST, params: { preferredUsername: group.preferredUsername }}"
|
||||
>{{ $t('View all conversations') }}</router-link>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="group">
|
||||
<section class="presentation">
|
||||
<div class="media">
|
||||
<div class="media-left">
|
||||
<figure class="image is-128x128" v-if="group.avatar">
|
||||
<img :src="group.avatar.url" alt />
|
||||
</figure>
|
||||
<b-icon v-else size="is-large" icon="account-group" />
|
||||
</div>
|
||||
<div class="media-content">
|
||||
<h2>{{ group.name }}</h2>
|
||||
<small class="has-text-grey">@{{ group.preferredUsername }}</small>
|
||||
<span v-if="group.domain">{{ group.domain }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section>
|
||||
<subtitle>{{ $t('Upcoming events') }}</subtitle>
|
||||
<div class="organized-events-wrapper" v-if="group.organizedEvents.total > 0">
|
||||
<EventMinimalistCard
|
||||
v-for="event in group.organizedEvents.elements"
|
||||
:event="event"
|
||||
:key="event.uuid"
|
||||
class="organized-event"
|
||||
/>
|
||||
<router-link :to="{}">{{ $t('View all upcoming events') }}</router-link>
|
||||
</div>
|
||||
<span v-else>{{ $t('No public upcoming events')}}</span>
|
||||
</section>
|
||||
<!-- {{ group }}-->
|
||||
<section>
|
||||
<subtitle>{{ $t('Latest posts') }}</subtitle>
|
||||
</section>
|
||||
<b-button>Join group</b-button>
|
||||
</div>
|
||||
<b-message
|
||||
v-else-if="!group && $apollo.loading === false"
|
||||
type="is-danger"
|
||||
>{{ $t('No group found') }}</b-message>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import {Component, Prop, Vue} from 'vue-property-decorator';
|
||||
import EventCard from '@/components/Event/EventCard.vue';
|
||||
import {FETCH_GROUP, CURRENT_ACTOR_CLIENT, PERSON_MEMBERSHIPS} from '@/graphql/actor';
|
||||
import {IActor, IGroup, IPerson} from '@/types/actor';
|
||||
import Subtitle from '@/components/Utils/Subtitle.vue';
|
||||
import {RouteName} from '@/router';
|
||||
import CompactTodo from '@/components/Todo/CompactTodo.vue';
|
||||
import EventMinimalistCard from "@/components/Event/EventMinimalistCard.vue";
|
||||
import ConversationListItem from "@/components/Conversation/ConversationListItem.vue";
|
||||
import { Component, Prop, Vue, Watch } from "vue-property-decorator";
|
||||
import EventCard from "@/components/Event/EventCard.vue";
|
||||
import {
|
||||
FETCH_GROUP,
|
||||
CURRENT_ACTOR_CLIENT,
|
||||
PERSON_MEMBERSHIPS
|
||||
} from "@/graphql/actor";
|
||||
import { IActor, IGroup, IPerson } from "@/types/actor";
|
||||
import Subtitle from "@/components/Utils/Subtitle.vue";
|
||||
import { RouteName } from "@/router";
|
||||
import CompactTodo from "@/components/Todo/CompactTodo.vue";
|
||||
import EventMinimalistCard from "@/components/Event/EventMinimalistCard.vue";
|
||||
import ConversationListItem from "@/components/Conversation/ConversationListItem.vue";
|
||||
import ResourceItem from "@/components/Resource/ResourceItem.vue";
|
||||
import FolderItem from "@/components/Resource/FolderItem.vue";
|
||||
|
||||
@Component({
|
||||
apollo: {
|
||||
group: {
|
||||
query: FETCH_GROUP,
|
||||
variables() {
|
||||
return {
|
||||
name: this.preferredUsername,
|
||||
};
|
||||
},
|
||||
},
|
||||
person: {
|
||||
query: PERSON_MEMBERSHIPS,
|
||||
variables() {
|
||||
return {
|
||||
id: this.currentActor.id
|
||||
}
|
||||
},
|
||||
skip() {
|
||||
return !this.currentActor || !this.currentActor.id;
|
||||
}
|
||||
},
|
||||
currentActor: CURRENT_ACTOR_CLIENT,
|
||||
},
|
||||
components: {
|
||||
ConversationListItem,
|
||||
EventMinimalistCard,
|
||||
CompactTodo,
|
||||
Subtitle,
|
||||
EventCard,
|
||||
},
|
||||
metaInfo() {
|
||||
return {
|
||||
// if no subcomponents specify a metaInfo.title, this title will be used
|
||||
// @ts-ignore
|
||||
title: this.groupTitle,
|
||||
// all titles will be injected into this template
|
||||
titleTemplate: '%s | Mobilizon',
|
||||
meta: [
|
||||
// @ts-ignore
|
||||
{name: 'description', content: this.groupSummary},
|
||||
],
|
||||
};
|
||||
},
|
||||
})
|
||||
export default class Group extends Vue {
|
||||
@Prop({type: String, required: true}) preferredUsername!: string;
|
||||
currentActor!: IActor;
|
||||
person!: IPerson;
|
||||
@Component({
|
||||
apollo: {
|
||||
group: {
|
||||
query: FETCH_GROUP,
|
||||
variables() {
|
||||
return {
|
||||
name: this.preferredUsername
|
||||
};
|
||||
}
|
||||
},
|
||||
person: {
|
||||
query: PERSON_MEMBERSHIPS,
|
||||
variables() {
|
||||
return {
|
||||
id: this.currentActor.id
|
||||
};
|
||||
},
|
||||
skip() {
|
||||
return !this.currentActor || !this.currentActor.id;
|
||||
}
|
||||
},
|
||||
currentActor: CURRENT_ACTOR_CLIENT
|
||||
},
|
||||
components: {
|
||||
ConversationListItem,
|
||||
EventMinimalistCard,
|
||||
CompactTodo,
|
||||
Subtitle,
|
||||
EventCard,
|
||||
FolderItem,
|
||||
ResourceItem
|
||||
},
|
||||
metaInfo() {
|
||||
return {
|
||||
// if no subcomponents specify a metaInfo.title, this title will be used
|
||||
// @ts-ignore
|
||||
title: this.groupTitle,
|
||||
// all titles will be injected into this template
|
||||
titleTemplate: "%s | Mobilizon",
|
||||
meta: [
|
||||
// @ts-ignore
|
||||
{ name: "description", content: this.groupSummary }
|
||||
]
|
||||
};
|
||||
}
|
||||
})
|
||||
export default class Group extends Vue {
|
||||
@Prop({ type: String, required: true }) preferredUsername!: string;
|
||||
currentActor!: IActor;
|
||||
person!: IPerson;
|
||||
|
||||
group!: IGroup;
|
||||
loading = true;
|
||||
RouteName = RouteName;
|
||||
group!: IGroup;
|
||||
loading = true;
|
||||
RouteName = RouteName;
|
||||
|
||||
get groupTitle() {
|
||||
if (!this.group) return undefined;
|
||||
return this.group.preferredUsername;
|
||||
}
|
||||
|
||||
get groupSummary() {
|
||||
if (!this.group) return undefined;
|
||||
return this.group.summary;
|
||||
}
|
||||
|
||||
get groupMemberships() {
|
||||
if (!this.person || !this.person.id) return undefined;
|
||||
return this.person.memberships.elements.map(({ parent: { id }}) => id);
|
||||
}
|
||||
@Watch("currentActor")
|
||||
watchCurrentActor(currentActor: IActor, oldActor: IActor) {
|
||||
if (currentActor.id && oldActor && currentActor.id !== oldActor.id) {
|
||||
this.$apollo.queries.group.refetch();
|
||||
}
|
||||
}
|
||||
|
||||
get groupTitle() {
|
||||
if (!this.group) return undefined;
|
||||
return this.group.preferredUsername;
|
||||
}
|
||||
|
||||
get groupSummary() {
|
||||
if (!this.group) return undefined;
|
||||
return this.group.summary;
|
||||
}
|
||||
|
||||
get groupMemberships() {
|
||||
if (!this.person || !this.person.id) return undefined;
|
||||
return this.person.memberships.elements.map(({ parent: { id } }) => id);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
div.container {
|
||||
background: white;
|
||||
margin-bottom: 3rem;
|
||||
padding: 2rem 0;
|
||||
div.container {
|
||||
background: white;
|
||||
margin-bottom: 3rem;
|
||||
padding: 2rem 0;
|
||||
|
||||
.block-container {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
|
||||
.block-container {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
.block-column {
|
||||
flex: 1;
|
||||
margin: 0 2rem;
|
||||
|
||||
.block-column {
|
||||
flex: 1;
|
||||
margin: 0 2rem;
|
||||
|
||||
section {
|
||||
/deep/ h3 span {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.organized-events-wrapper {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
|
||||
.organized-event {
|
||||
margin: 0.25rem 0;
|
||||
}
|
||||
}
|
||||
|
||||
&.presentation {
|
||||
.media-left {
|
||||
span.icon.is-large {
|
||||
height: 5rem;
|
||||
width: 5rem;
|
||||
|
||||
/deep/ i.mdi.mdi-account-group.mdi-48px:before {
|
||||
font-size: 100px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.media-content {
|
||||
h2 {
|
||||
color: #3C376E;
|
||||
font-family: "Liberation Sans", "Helvetica Neue", Roboto, Helvetica, Arial, serif;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
section {
|
||||
/deep/ h3 span {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.organized-events-wrapper {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
|
||||
.organized-event {
|
||||
margin: 0.25rem 0;
|
||||
}
|
||||
}
|
||||
|
||||
&.presentation {
|
||||
.media-left {
|
||||
span.icon.is-large {
|
||||
height: 5rem;
|
||||
width: 5rem;
|
||||
|
||||
/deep/ i.mdi.mdi-account-group.mdi-48px:before {
|
||||
font-size: 100px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.media-content {
|
||||
h2 {
|
||||
color: #3c376e;
|
||||
font-family: "Liberation Sans", "Helvetica Neue", Roboto,
|
||||
Helvetica, Arial, serif;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -0,0 +1,165 @@
|
|||
<template>
|
||||
<aside class="section container">
|
||||
<h1 class="title">{{ $t('Settings') }}</h1>
|
||||
<div class="columns">
|
||||
<SettingsMenu class="column is-one-quarter-desktop" :menu="menu" />
|
||||
<div class="column">
|
||||
<nav class="breadcrumb" aria-label="breadcrumbs">
|
||||
<ul>
|
||||
<li v-for="route in routes.get($route.name)" :class="{ 'is-active': route.to.name === $route.name }"><router-link :to="{ name: route.to.name }">{{ route.title }}</router-link></li>
|
||||
</ul>
|
||||
</nav>
|
||||
<router-view />
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</template>
|
||||
<script lang="ts">
|
||||
import { Component, Vue, Watch } from 'vue-property-decorator';
|
||||
import SettingsMenu from '@/components/Settings/SettingsMenu.vue';
|
||||
import { RouteName } from '@/router';
|
||||
import { ISettingMenuSection } from '@/types/setting-menu.model';
|
||||
import { Route } from 'vue-router';
|
||||
import { IGroup } from '@/types/actor';
|
||||
import { FETCH_GROUP } from '@/graphql/actor';
|
||||
|
||||
@Component({
|
||||
components: { SettingsMenu },
|
||||
apollo: {
|
||||
group: {
|
||||
query: FETCH_GROUP,
|
||||
},
|
||||
},
|
||||
})
|
||||
export default class Settings extends Vue {
|
||||
RouteName = RouteName;
|
||||
|
||||
menu: ISettingMenuSection[] = [];
|
||||
group!: IGroup[];
|
||||
newIdentity!: ISettingMenuSection;
|
||||
|
||||
mounted() {
|
||||
this.newIdentity = {
|
||||
title: this.$t('New profile') as string,
|
||||
to: { name: RouteName.CREATE_IDENTITY } as Route,
|
||||
};
|
||||
this.menu = [
|
||||
{
|
||||
title: this.$t('Account') as string,
|
||||
to: { name: RouteName.ACCOUNT_SETTINGS } as Route,
|
||||
items: [
|
||||
{
|
||||
title: this.$t('General') as string,
|
||||
to: { name: RouteName.ACCOUNT_SETTINGS_GENERAL } as Route,
|
||||
},
|
||||
{
|
||||
title: this.$t('Preferences') as string,
|
||||
to: { name: RouteName.PREFERENCES } as Route,
|
||||
},
|
||||
{
|
||||
title: this.$t('Notifications') as string,
|
||||
to: { name: RouteName.NOTIFICATIONS } as Route,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: this.$t('Profiles') as string,
|
||||
to: { name: RouteName.IDENTITIES } as Route,
|
||||
items: [this.newIdentity],
|
||||
},
|
||||
{
|
||||
title: this.$t('Moderation') as string,
|
||||
to: { name: RouteName.MODERATION } as Route,
|
||||
items: [
|
||||
{
|
||||
title: this.$t('Reports') as string,
|
||||
to: { name: RouteName.REPORTS } as Route,
|
||||
items: [
|
||||
{
|
||||
title: this.$t('Report') as string,
|
||||
to: { name: RouteName.REPORT } as Route,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: this.$t('Moderation log') as string,
|
||||
to: { name: RouteName.REPORT_LOGS } as Route,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: this.$t('Admin') as string,
|
||||
to: { name: RouteName.ADMIN } as Route,
|
||||
items: [
|
||||
{
|
||||
title: this.$t('Dashboard') as string,
|
||||
to: { name: RouteName.ADMIN_DASHBOARD } as Route,
|
||||
},
|
||||
{
|
||||
title: this.$t('Instance settings') as string,
|
||||
to: { name: RouteName.ADMIN_SETTINGS } as Route,
|
||||
},
|
||||
{
|
||||
title: this.$t('Federation') as string,
|
||||
to: { name: RouteName.RELAYS } as Route,
|
||||
items: [
|
||||
{
|
||||
title: this.$t('Followings') as string,
|
||||
to: { name: RouteName.RELAY_FOLLOWINGS } as Route,
|
||||
},
|
||||
{
|
||||
title: this.$t('Followers') as string,
|
||||
to: { name: RouteName.RELAY_FOLLOWERS } as Route,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
@Watch('identities')
|
||||
updateIdentities(identities) {
|
||||
if (!identities) return;
|
||||
if (!this.menu[1].items) return;
|
||||
this.menu[1].items = [];
|
||||
this.menu[1].items.push(...identities.map((identity: IPerson) => {
|
||||
return {
|
||||
to: { name: RouteName.UPDATE_IDENTITY, params: { identityName: identity.preferredUsername } } as unknown as Route,
|
||||
title: `@${identity.preferredUsername}`,
|
||||
};
|
||||
}));
|
||||
this.menu[1].items.push(this.newIdentity);
|
||||
}
|
||||
|
||||
get routes(): Map<string, Route[]> {
|
||||
return this.getPath(this.menu);
|
||||
}
|
||||
|
||||
getPath(object: ISettingMenuSection[]) {
|
||||
function iter(menu: ISettingMenuSection[]|ISettingMenuSection, acc: ISettingMenuSection[]) {
|
||||
if (Array.isArray(menu)) {
|
||||
return menu.forEach((item: ISettingMenuSection) => {
|
||||
iter(item, acc.concat(item));
|
||||
});
|
||||
}
|
||||
if (menu.items && menu.items.length > 0) {
|
||||
return menu.items.forEach((item: ISettingMenuSection) => {
|
||||
iter(item, acc.concat(item));
|
||||
});
|
||||
}
|
||||
result.set(menu.to.name, acc);
|
||||
}
|
||||
|
||||
const result = new Map();
|
||||
iter(object, []);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
aside.section {
|
||||
padding-top: 1rem;
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,515 @@
|
|||
<template>
|
||||
<div class="container section" v-if="resource">
|
||||
<nav class="breadcrumb" aria-label="breadcrumbs">
|
||||
<ul>
|
||||
<li>
|
||||
<router-link
|
||||
:to="{ name: RouteName.GROUP, params: { preferredUsername: resource.actor.preferredUsername } }"
|
||||
>
|
||||
{{
|
||||
resource.actor.preferredUsername }}
|
||||
</router-link>
|
||||
</li>
|
||||
<li>
|
||||
<router-link
|
||||
:to="{ name: RouteName.RESOURCE_FOLDER_ROOT, params: { preferredUsername: resource.actor.preferredUsername } }"
|
||||
>
|
||||
{{
|
||||
$t('Resources') }}
|
||||
</router-link>
|
||||
</li>
|
||||
<li
|
||||
v-if="resource.path !== '/'"
|
||||
:class="{ 'is-active': index + 1 === resourcePathArray(resource).length}"
|
||||
v-for="(pathFragment, index) in resourcePathArray(resource)"
|
||||
:key="pathFragment"
|
||||
>
|
||||
<router-link
|
||||
:to="{ name: RouteName.RESOURCE_FOLDER, params: { path: resourcePathArray(resource).slice(0, index + 1), preferredUsername: resource.actor.preferredUsername } }"
|
||||
>{{ pathFragment }}</router-link>
|
||||
</li>
|
||||
<li>
|
||||
<b-dropdown aria-role="list">
|
||||
<b-button class="button is-primary" slot="trigger">+</b-button>
|
||||
|
||||
<b-dropdown-item aria-role="listitem" @click="createFolderModal">
|
||||
<b-icon icon="folder" />
|
||||
{{ $t('New folder') }}
|
||||
</b-dropdown-item>
|
||||
<b-dropdown-item aria-role="listitem" @click="createLinkResourceModal = true">
|
||||
<b-icon icon="link" />
|
||||
{{ $t('New link') }}
|
||||
</b-dropdown-item>
|
||||
<hr class="dropdown-divider" />
|
||||
<b-dropdown-item
|
||||
aria-role="listitem"
|
||||
v-for="resourceProvider in config.resourceProviders"
|
||||
:key="resourceProvider.software"
|
||||
@click="createResourceFromProvider(resourceProvider)"
|
||||
>
|
||||
<b-icon :icon="mapServiceTypeToIcon[resourceProvider.software]" />
|
||||
{{ createSentenceForType(resourceProvider.software) }}
|
||||
</b-dropdown-item>
|
||||
</b-dropdown>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
<section>
|
||||
<div class="list-header">
|
||||
<div class="list-header-right">
|
||||
<b-checkbox v-model="checkedAll" />
|
||||
<div class="actions" v-if="validCheckedResources.length > 0">
|
||||
<small>
|
||||
{{ $tc('No resources selected', validCheckedResources.length, { count:
|
||||
validCheckedResources.length }) }}
|
||||
</small>
|
||||
<b-button
|
||||
type="is-danger"
|
||||
icon-right="delete"
|
||||
size="is-small"
|
||||
@click="deleteMultipleResources"
|
||||
>{{ $t('Delete') }}</b-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<draggable v-model="resource.children.elements" :sort="false" :group="groupObject">
|
||||
<transition-group>
|
||||
<div v-for="localResource in resource.children.elements" :key="localResource.id">
|
||||
<div class="resource-item">
|
||||
<div
|
||||
class="resource-checkbox"
|
||||
:class="{ checked: checkedResources[localResource.id] }"
|
||||
>
|
||||
<b-checkbox v-model="checkedResources[localResource.id]" />
|
||||
</div>
|
||||
<resource-item
|
||||
:resource="localResource"
|
||||
v-if="localResource.type !== 'folder'"
|
||||
@delete="deleteResource"
|
||||
@rename="handleRename"
|
||||
/>
|
||||
<folder-item
|
||||
:resource="localResource"
|
||||
:group="resource.actor"
|
||||
@delete="deleteResource"
|
||||
@rename="handleRename"
|
||||
v-else
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</transition-group>
|
||||
</draggable>
|
||||
</section>
|
||||
<b-modal :active.sync="renameModal" has-modal-card>
|
||||
<div class="modal-card">
|
||||
<section class="modal-card-body">
|
||||
<form @submit.prevent="renameResource">
|
||||
<b-field :label="$t('Title')">
|
||||
<b-input aria-required="true" v-model="updatedResource.title" />
|
||||
</b-field>
|
||||
|
||||
<b-button native-type="submit">{{ $t('Rename resource') }}</b-button>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
</b-modal>
|
||||
<b-modal :active.sync="createResourceModal" has-modal-card>
|
||||
<div class="modal-card">
|
||||
<section class="modal-card-body">
|
||||
<form @submit.prevent="createResource">
|
||||
<b-field :label="$t('Title')">
|
||||
<b-input aria-required="true" v-model="newResource.title" />
|
||||
</b-field>
|
||||
|
||||
<b-button native-type="submit">{{ createResourceButtonLabel }}</b-button>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
</b-modal>
|
||||
<b-modal :active.sync="createLinkResourceModal" has-modal-card>
|
||||
<div class="modal-card">
|
||||
<section class="modal-card-body">
|
||||
<form @submit.prevent="createResource">
|
||||
<b-field :label="$t('URL')">
|
||||
<b-input
|
||||
type="url"
|
||||
required
|
||||
v-model="newResource.resourceUrl"
|
||||
@blur="previewResource"
|
||||
/>
|
||||
</b-field>
|
||||
|
||||
<div class="new-resource-preview" v-if="newResource.title">
|
||||
<resource-item :resource="newResource" />
|
||||
</div>
|
||||
|
||||
<b-field :label="$t('Title')">
|
||||
<b-input aria-required="true" v-model="newResource.title" />
|
||||
</b-field>
|
||||
|
||||
<b-field :label="$t('Text')">
|
||||
<b-input type="textarea" v-model="newResource.summary" />
|
||||
</b-field>
|
||||
|
||||
<b-button native-type="submit">{{ $t('Create resource') }}</b-button>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
</b-modal>
|
||||
</div>
|
||||
</template>
|
||||
<script lang="ts">
|
||||
import { Component, Mixins, Prop, Watch } from "vue-property-decorator";
|
||||
import { CURRENT_ACTOR_CLIENT } from "@/graphql/actor";
|
||||
import { IActor } from "@/types/actor";
|
||||
import { RouteName } from "@/router";
|
||||
import { IResource, mapServiceTypeToIcon } from "@/types/resource";
|
||||
import {
|
||||
CREATE_RESOURCE,
|
||||
DELETE_RESOURCE,
|
||||
PREVIEW_RESOURCE_LINK,
|
||||
GET_RESOURCE,
|
||||
UPDATE_RESOURCE
|
||||
} from "@/graphql/resources";
|
||||
import { CONFIG } from "@/graphql/config";
|
||||
import { IConfig } from "@/types/config.model";
|
||||
import ResourceItem from "@/components/Resource/ResourceItem.vue";
|
||||
import FolderItem from "@/components/Resource/FolderItem.vue";
|
||||
import ResourceMixin from "@/mixins/resource";
|
||||
import Draggable from "vuedraggable";
|
||||
|
||||
@Component({
|
||||
components: { FolderItem, ResourceItem, Draggable },
|
||||
apollo: {
|
||||
resource: {
|
||||
query: GET_RESOURCE,
|
||||
variables() {
|
||||
let path = Array.isArray(this.$route.params.path)
|
||||
? this.$route.params.path.join("/")
|
||||
: this.$route.params.path || this.path;
|
||||
path = path[0] !== "/" ? `/${path}` : path;
|
||||
return {
|
||||
path,
|
||||
username: this.$route.params.preferredUsername
|
||||
};
|
||||
}
|
||||
},
|
||||
config: CONFIG,
|
||||
currentActor: CURRENT_ACTOR_CLIENT
|
||||
}
|
||||
})
|
||||
export default class Resources extends Mixins(ResourceMixin) {
|
||||
@Prop({ required: true }) path!: string;
|
||||
resource!: IResource;
|
||||
config!: IConfig;
|
||||
currentActor!: IActor;
|
||||
RouteName = RouteName;
|
||||
newResource: IResource = {
|
||||
title: "",
|
||||
summary: "",
|
||||
resourceUrl: "",
|
||||
children: { elements: [], total: 0 },
|
||||
metadata: {},
|
||||
type: "link"
|
||||
};
|
||||
updatedResource: IResource = {
|
||||
title: "",
|
||||
resourceUrl: '',
|
||||
metadata: {},
|
||||
children: { elements: [], total: 0 },
|
||||
path: undefined
|
||||
};
|
||||
checkedResources: object = {};
|
||||
validCheckedResources: string[] = [];
|
||||
checkedAll: boolean = false;
|
||||
|
||||
createResourceModal: boolean = false;
|
||||
createLinkResourceModal: boolean = false;
|
||||
renameModal: boolean = false;
|
||||
|
||||
groupObject: object = {
|
||||
name: "resources",
|
||||
pull: "clone",
|
||||
put: true
|
||||
};
|
||||
|
||||
mapServiceTypeToIcon = mapServiceTypeToIcon;
|
||||
|
||||
async createResource() {
|
||||
try {
|
||||
const { data } = await this.$apollo.mutate({
|
||||
mutation: CREATE_RESOURCE,
|
||||
variables: {
|
||||
title: this.newResource.title,
|
||||
summary: this.newResource.summary,
|
||||
actorId: this.resource.actor?.id,
|
||||
resourceUrl: this.newResource.resourceUrl,
|
||||
parentId:
|
||||
this.resource.id && this.resource.id.startsWith("root_")
|
||||
? null
|
||||
: this.resource.id,
|
||||
path: `${this.resource.path === "/" ? "" : this.resource.path}/${
|
||||
this.newResource.title
|
||||
}`,
|
||||
type: this.newResource.type
|
||||
},
|
||||
update: (store, { data: { createResource } }) => {
|
||||
if (createResource == null) return;
|
||||
const cachedData = store.readQuery<{ resource: IResource }>({
|
||||
query: GET_RESOURCE,
|
||||
variables: {
|
||||
path: this.resource.path,
|
||||
username: this.resource.actor?.preferredUsername
|
||||
}
|
||||
});
|
||||
if (cachedData == null) return;
|
||||
const { resource } = cachedData;
|
||||
if (resource == null) {
|
||||
console.error(
|
||||
"Cannot update resource cache, because of null value."
|
||||
);
|
||||
return;
|
||||
}
|
||||
const newResource: IResource = createResource;
|
||||
resource.children.elements = resource.children.elements.concat([
|
||||
newResource
|
||||
]);
|
||||
|
||||
store.writeQuery({
|
||||
query: GET_RESOURCE,
|
||||
variables: {
|
||||
path: this.resource.path,
|
||||
username: this.resource.actor?.preferredUsername
|
||||
},
|
||||
data: { resource }
|
||||
});
|
||||
}
|
||||
});
|
||||
this.createLinkResourceModal = false;
|
||||
this.createResourceModal = false;
|
||||
this.newResource.title = "";
|
||||
this.newResource.summary = "";
|
||||
this.newResource.resourceUrl = "";
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
}
|
||||
|
||||
async previewResource() {
|
||||
if (this.newResource.resourceUrl === "") return;
|
||||
const { data } = await this.$apollo.mutate({
|
||||
mutation: PREVIEW_RESOURCE_LINK,
|
||||
variables: {
|
||||
resourceUrl: this.newResource.resourceUrl
|
||||
}
|
||||
});
|
||||
this.newResource.title = data.previewResourceLink.title;
|
||||
this.newResource.summary = data.previewResourceLink.description;
|
||||
this.newResource.metadata = data.previewResourceLink;
|
||||
this.newResource.type = "link";
|
||||
}
|
||||
|
||||
createSentenceForType(type: string) {
|
||||
switch (type) {
|
||||
case "pad":
|
||||
return this.$t("Create a pad");
|
||||
case "calc":
|
||||
return this.$t("Create a calc");
|
||||
case "visio":
|
||||
return this.$t("Create a visioconference");
|
||||
}
|
||||
}
|
||||
|
||||
createFolderModal() {
|
||||
this.newResource.type = "folder";
|
||||
this.createResourceModal = true;
|
||||
}
|
||||
|
||||
createResourceFromProvider(provider) {
|
||||
console.log(provider);
|
||||
this.newResource.resourceUrl = this.generateFullResourceUrl(provider);
|
||||
this.newResource.type = provider.software;
|
||||
this.createResourceModal = true;
|
||||
}
|
||||
|
||||
generateFullResourceUrl(provider): string {
|
||||
const randomString = [...Array(10)]
|
||||
.map(() => Math.random().toString(36)[3])
|
||||
.join("")
|
||||
.replace(/(.|$)/g, c =>
|
||||
c[!Math.round(Math.random()) ? "toString" : "toLowerCase"]()
|
||||
);
|
||||
switch (provider.type) {
|
||||
case "ethercalc":
|
||||
case "etherpad":
|
||||
case "jitsi":
|
||||
default:
|
||||
return `${provider.endpoint}${randomString}`;
|
||||
}
|
||||
}
|
||||
|
||||
get createResourceButtonLabel() {
|
||||
switch (this.newResource.type) {
|
||||
case "folder":
|
||||
return this.$t("Create folder");
|
||||
case "pad":
|
||||
return this.$t("Create pad");
|
||||
case "calc":
|
||||
return this.$t("Create calc");
|
||||
case "visio":
|
||||
return this.$t("Create visio");
|
||||
}
|
||||
}
|
||||
|
||||
@Watch("checkedAll")
|
||||
watchCheckedAll() {
|
||||
this.resource.children.elements.forEach(({ id }) => {
|
||||
if (!id) return;
|
||||
this.checkedResources[id] = this.checkedAll;
|
||||
});
|
||||
}
|
||||
|
||||
@Watch("checkedResources", { deep: true })
|
||||
watchValidCheckedResources(): string[] {
|
||||
const validCheckedResources: string[] = [];
|
||||
for (const [key, value] of Object.entries(this.checkedResources)) {
|
||||
if (value) {
|
||||
validCheckedResources.push(key);
|
||||
}
|
||||
}
|
||||
return (this.validCheckedResources = validCheckedResources);
|
||||
}
|
||||
|
||||
async deleteMultipleResources() {
|
||||
for (const resourceID of this.validCheckedResources) {
|
||||
await this.deleteResource(resourceID);
|
||||
}
|
||||
}
|
||||
|
||||
async deleteResource(resourceID: string) {
|
||||
try {
|
||||
await this.$apollo.mutate({
|
||||
mutation: DELETE_RESOURCE,
|
||||
variables: {
|
||||
id: resourceID
|
||||
},
|
||||
update: (store, { data: { deleteResource } }) => {
|
||||
if (deleteResource == null) return;
|
||||
const cachedData = store.readQuery<{ resource: IResource }>({
|
||||
query: GET_RESOURCE,
|
||||
variables: {
|
||||
path: this.resource.path,
|
||||
username: this.resource.actor?.preferredUsername
|
||||
}
|
||||
});
|
||||
if (cachedData == null) return;
|
||||
const { resource } = cachedData;
|
||||
if (resource == null) {
|
||||
console.error(
|
||||
"Cannot update resource cache, because of null value."
|
||||
);
|
||||
return;
|
||||
}
|
||||
const oldResource: IResource = deleteResource;
|
||||
|
||||
resource.children.elements = resource.children.elements.filter(
|
||||
resource => resource.id !== oldResource.id
|
||||
);
|
||||
|
||||
store.writeQuery({
|
||||
query: GET_RESOURCE,
|
||||
variables: {
|
||||
path: this.resource.path,
|
||||
username: this.resource.actor?.preferredUsername
|
||||
},
|
||||
data: { resource }
|
||||
});
|
||||
}
|
||||
});
|
||||
delete this.validCheckedResources[resourceID];
|
||||
delete this.checkedResources[resourceID];
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
|
||||
handleRename(resource: IResource) {
|
||||
this.renameModal = true;
|
||||
this.updatedResource = resource;
|
||||
}
|
||||
|
||||
async renameResource() {
|
||||
await this.updateResource(this.updatedResource);
|
||||
}
|
||||
|
||||
async updateResource(resource: IResource) {
|
||||
try {
|
||||
await this.$apollo.mutate<{ updateResource: IResource }>({
|
||||
mutation: UPDATE_RESOURCE,
|
||||
variables: {
|
||||
id: resource.id,
|
||||
title: resource.title,
|
||||
parentId: resource.parent?.id,
|
||||
path: resource.path
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
nav.breadcrumb ul {
|
||||
align-items: center;
|
||||
|
||||
li:last-child .dropdown {
|
||||
margin-left: 5px;
|
||||
|
||||
a {
|
||||
justify-content: left;
|
||||
color: inherit;
|
||||
padding: 0.375rem 1rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.list-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
|
||||
.list-header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
.actions {
|
||||
margin-right: 5px;
|
||||
|
||||
& > * {
|
||||
margin-left: 5px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.resource-item,
|
||||
.new-resource-preview {
|
||||
display: flex;
|
||||
font-size: 14px;
|
||||
border: 1px solid #c0cdd9;
|
||||
border-radius: 4px;
|
||||
color: #444b5d;
|
||||
margin-top: 14px;
|
||||
|
||||
.resource-checkbox {
|
||||
align-self: center;
|
||||
padding: 0 3px 0 10px;
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
&:hover .resource-checkbox,
|
||||
.resource-checkbox.checked {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,348 @@
|
|||
<template>
|
||||
<div class="container section" v-if="group">
|
||||
<nav class="breadcrumb" aria-label="breadcrumbs">
|
||||
<ul>
|
||||
<li>
|
||||
<router-link
|
||||
:to="{ name: RouteName.GROUP, params: { preferredUsername: group.preferredUsername } }"
|
||||
>
|
||||
{{
|
||||
group.preferredUsername }}
|
||||
</router-link>
|
||||
</li>
|
||||
<li class="is-active">
|
||||
<router-link
|
||||
:to="{ name: RouteName.RESOURCES, params: { preferredUsername: group.preferredUsername } }"
|
||||
>
|
||||
{{
|
||||
$t('Resources') }}
|
||||
</router-link>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
<section>
|
||||
<div class="list-header">
|
||||
<b-dropdown aria-role="list">
|
||||
<b-button class="button is-primary" slot="trigger">+</b-button>
|
||||
|
||||
<b-dropdown-item aria-role="listitem" @click="createFolderModal = true">
|
||||
<b-icon icon="folder" />
|
||||
{{ $t('New folder') }}
|
||||
</b-dropdown-item>
|
||||
<b-dropdown-item aria-role="listitem" @click="createLinkResourceModal = true">
|
||||
<b-icon icon="link" />
|
||||
{{ $t('New link') }}
|
||||
</b-dropdown-item>
|
||||
<hr class="dropdown-divider" />
|
||||
<b-dropdown-item
|
||||
aria-role="listitem"
|
||||
v-for="resourceProvider in config.resourceProviders"
|
||||
:key="resourceProvider.software"
|
||||
>
|
||||
<b-icon :icon="mapServiceTypeToIcon[resourceProvider.software]" />
|
||||
{{ createSentenceForType(resourceProvider.software) }}
|
||||
</b-dropdown-item>
|
||||
</b-dropdown>
|
||||
<div class="list-header-right">
|
||||
<div class="actions" v-if="validCheckedResources.length > 0">
|
||||
<small>
|
||||
{{ $tc('No resources selected', validCheckedResources.length, { count:
|
||||
validCheckedResources.length }) }}
|
||||
</small>
|
||||
<b-button
|
||||
type="is-danger"
|
||||
icon-right="delete"
|
||||
size="is-small"
|
||||
@click="deleteMultipleResources"
|
||||
>{{ $t('Delete') }}</b-button>
|
||||
</div>
|
||||
<b-checkbox v-model="checkedAll" />
|
||||
</div>
|
||||
</div>
|
||||
<div v-for="resource in group.resources.elements" :key="resource.id">
|
||||
<div class="resource-item">
|
||||
<resource-item :resource="resource" v-if="resource.type !== 'folder'" @delete="deleteResource" />
|
||||
<folder-item :resource="resource" :group="group" v-else />
|
||||
<div class="resource-checkbox" :class="{ checked: checkedResources[resource.id] }">
|
||||
<b-checkbox v-model="checkedResources[resource.id]" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<b-modal :active.sync="createFolderModal" has-modal-card>
|
||||
<div class="modal-card">
|
||||
<section class="modal-card-body">
|
||||
<form @submit.prevent="createResource('folder')">
|
||||
<b-field :label="$t('Title')">
|
||||
<b-input aria-required="true" v-model="newResource.title" />
|
||||
</b-field>
|
||||
|
||||
<b-button native-type="submit">{{ $t('Create folder') }}</b-button>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
</b-modal>
|
||||
<b-modal :active.sync="createLinkResourceModal" has-modal-card>
|
||||
<div class="modal-card">
|
||||
<section class="modal-card-body">
|
||||
<form @submit.prevent="createResource()">
|
||||
<b-field :label="$t('URL')">
|
||||
<b-input
|
||||
type="url"
|
||||
required
|
||||
v-model="newResource.resourceUrl"
|
||||
@blur="previewResource"
|
||||
/>
|
||||
</b-field>
|
||||
|
||||
<div class="new-resource-preview" v-if="newResource.title">
|
||||
<resource-item :resource="newResource" />
|
||||
</div>
|
||||
|
||||
<b-field :label="$t('Title')">
|
||||
<b-input aria-required="true" v-model="newResource.title" />
|
||||
</b-field>
|
||||
|
||||
<b-field :label="$t('Text')">
|
||||
<b-input type="textarea" v-model="newResource.summary" />
|
||||
</b-field>
|
||||
|
||||
<b-button native-type="submit">{{ $t('Create resource') }}</b-button>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
</b-modal>
|
||||
</div>
|
||||
</template>
|
||||
<script lang="ts">
|
||||
import { Component, Prop, Vue, Watch } from 'vue-property-decorator';
|
||||
import { CURRENT_ACTOR_CLIENT, FETCH_GROUP } from '@/graphql/actor';
|
||||
import { IActor, IGroup } from '@/types/actor';
|
||||
import { RouteName } from '@/router';
|
||||
import { IResource } from '@/types/resource';
|
||||
import {
|
||||
CREATE_RESOURCE,
|
||||
DELETE_RESOURCE,
|
||||
PREVIEW_RESOURCE_LINK,
|
||||
} from '@/graphql/resources';
|
||||
import { CONFIG } from '@/graphql/config';
|
||||
import { IConfig } from '@/types/config.model';
|
||||
import ResourceItem from '@/components/Resource/ResourceItem.vue';
|
||||
import FolderItem from '@/components/Resource/FolderItem.vue';
|
||||
|
||||
@Component({
|
||||
components: { FolderItem, ResourceItem },
|
||||
apollo: {
|
||||
group: {
|
||||
query: FETCH_GROUP,
|
||||
variables() {
|
||||
return {
|
||||
name: this.$route.params.preferredUsername,
|
||||
};
|
||||
},
|
||||
},
|
||||
config: CONFIG,
|
||||
currentActor: CURRENT_ACTOR_CLIENT,
|
||||
},
|
||||
})
|
||||
export default class Resources extends Vue {
|
||||
@Prop({ type: String, required: true }) preferredUsername!: string;
|
||||
group!: IGroup;
|
||||
config!: IConfig;
|
||||
currentActor!: IActor;
|
||||
RouteName = RouteName;
|
||||
newResource: IResource = {
|
||||
title: '',
|
||||
summary: '',
|
||||
resourceUrl: '',
|
||||
children: { elements: [], total: 0 },
|
||||
metadata: {},
|
||||
};
|
||||
checkedResources: object = {};
|
||||
validCheckedResources: string[] = [];
|
||||
checkedAll: boolean = false;
|
||||
|
||||
createFolderModal: boolean = false;
|
||||
createLinkResourceModal: boolean = false;
|
||||
|
||||
mapServiceTypeToIcon: object = {
|
||||
pad: 'file-document-outline',
|
||||
calc: 'google-spreadsheet',
|
||||
visio: 'webcam',
|
||||
};
|
||||
|
||||
async createResource(type: string = 'link') {
|
||||
try {
|
||||
const { data } = await this.$apollo.mutate({
|
||||
mutation: CREATE_RESOURCE,
|
||||
variables: {
|
||||
title: this.newResource.title,
|
||||
summary: this.newResource.summary,
|
||||
actorId: this.group.id,
|
||||
resourceUrl: this.newResource.resourceUrl,
|
||||
path: `/${this.newResource.title}`,
|
||||
type,
|
||||
},
|
||||
update: (store, { data: { createResource } }) => {
|
||||
if (createResource == null) return;
|
||||
const cachedData = store.readQuery<{ group: IGroup }>({
|
||||
query: FETCH_GROUP,
|
||||
variables: { name: this.group.preferredUsername },
|
||||
});
|
||||
if (cachedData == null) return;
|
||||
const { group } = cachedData;
|
||||
if (group == null) {
|
||||
console.error('Cannot update group cache, because of null value.');
|
||||
return;
|
||||
}
|
||||
const newResource: IResource = createResource;
|
||||
group.resources.elements = group.resources.elements.concat([
|
||||
newResource,
|
||||
]);
|
||||
|
||||
store.writeQuery({
|
||||
query: FETCH_GROUP,
|
||||
variables: { name: this.group.preferredUsername },
|
||||
data: { group },
|
||||
});
|
||||
},
|
||||
});
|
||||
this.createLinkResourceModal = false;
|
||||
this.createFolderModal = false;
|
||||
this.newResource.title = '';
|
||||
this.newResource.summary = '';
|
||||
this.newResource.resourceUrl = '';
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
}
|
||||
|
||||
async previewResource() {
|
||||
if (this.newResource.resourceUrl === '') return;
|
||||
const { data } = await this.$apollo.mutate({
|
||||
mutation: PREVIEW_RESOURCE_LINK,
|
||||
variables: {
|
||||
resourceUrl: this.newResource.resourceUrl,
|
||||
},
|
||||
});
|
||||
this.newResource.title = data.previewResourceLink.title;
|
||||
this.newResource.summary = data.previewResourceLink.description;
|
||||
this.newResource.metadata = data.previewResourceLink;
|
||||
}
|
||||
|
||||
createSentenceForType(type: string) {
|
||||
switch (type) {
|
||||
case 'pad':
|
||||
return this.$t('Create a pad');
|
||||
case 'calc':
|
||||
return this.$t('Create a calc');
|
||||
case 'visio':
|
||||
return this.$t('Create a visioconference');
|
||||
}
|
||||
}
|
||||
|
||||
@Watch('checkedAll')
|
||||
watchCheckedAll() {
|
||||
this.group.resources.elements.forEach(({ id }) => {
|
||||
if (!id) return;
|
||||
this.checkedResources[id] = this.checkedAll;
|
||||
});
|
||||
}
|
||||
|
||||
@Watch('checkedResources', { deep: true })
|
||||
watchValidCheckedResources(): string[] {
|
||||
const validCheckedResources: string[] = [];
|
||||
for (const [key, value] of Object.entries(this.checkedResources)) {
|
||||
if (value) {
|
||||
validCheckedResources.push(key);
|
||||
}
|
||||
}
|
||||
return (this.validCheckedResources = validCheckedResources);
|
||||
}
|
||||
|
||||
async deleteMultipleResources() {
|
||||
for (const resourceID of this.validCheckedResources) {
|
||||
await this.deleteResource(resourceID);
|
||||
}
|
||||
}
|
||||
|
||||
async deleteResource(resourceID: string) {
|
||||
try {
|
||||
await this.$apollo.mutate({
|
||||
mutation: DELETE_RESOURCE,
|
||||
variables: {
|
||||
id: resourceID,
|
||||
},
|
||||
update: (store, { data: { deleteResource } }) => {
|
||||
if (deleteResource == null) return;
|
||||
const cachedData = store.readQuery<{ group: IGroup }>({
|
||||
query: FETCH_GROUP,
|
||||
variables: { name: this.group.preferredUsername },
|
||||
});
|
||||
if (cachedData == null) return;
|
||||
const { group } = cachedData;
|
||||
if (group == null) {
|
||||
console.error('Cannot update group cache, because of null value.');
|
||||
return;
|
||||
}
|
||||
const oldResource: IResource = deleteResource;
|
||||
|
||||
group.resources.elements = group.resources.elements.filter(
|
||||
resource => resource.id !== oldResource.id,
|
||||
);
|
||||
|
||||
store.writeQuery({
|
||||
query: FETCH_GROUP,
|
||||
variables: { name: this.group.preferredUsername },
|
||||
data: { group },
|
||||
});
|
||||
},
|
||||
});
|
||||
delete this.validCheckedResources[resourceID];
|
||||
delete this.checkedResources[resourceID];
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
.list-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
|
||||
.list-header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
.actions {
|
||||
margin-right: 5px;
|
||||
|
||||
& > * {
|
||||
margin-left: 5px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.resource-item,
|
||||
.new-resource-preview {
|
||||
display: flex;
|
||||
font-size: 14px;
|
||||
border: 1px solid #c0cdd9;
|
||||
border-radius: 4px;
|
||||
color: #444b5d;
|
||||
margin-top: 14px;
|
||||
|
||||
.resource-checkbox {
|
||||
align-self: center;
|
||||
padding: 0 3px 0 10px;
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
&:hover .resource-checkbox,
|
||||
.resource-checkbox.checked {
|
||||
visibility: visible;
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -3,6 +3,7 @@
|
|||
<nav class="breadcrumb" aria-label="breadcrumbs">
|
||||
<ul>
|
||||
<li><router-link :to="{ name: RouteName.GROUP, params: { preferredUsername: todoList.actor.preferredUsername } }">{{ todoList.actor.preferredUsername }}</router-link></li>
|
||||
<li><router-link :to="{ name: RouteName.TODO_LISTS, params: { preferredUsername: todoList.actor.preferredUsername } }">{{ $t('Task lists') }}</router-link></li>
|
||||
<li class="is-active"><router-link :to="{ name: RouteName.TODO_LIST, params: { id: todoList.id } }">{{ todoList.title }}</router-link></li>
|
||||
</ul>
|
||||
</nav>
|
||||
|
@ -79,4 +80,4 @@ export default class TodoList extends Vue {
|
|||
this.newTodo = { title: '', status: false };
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</script>
|
||||
|
|
|
@ -1,15 +1,27 @@
|
|||
<template>
|
||||
<section class="container section" v-if="group">
|
||||
<form class="form" @submit.prevent="createNewTodoList">
|
||||
<b-field :label="$t('List title')">
|
||||
<b-input v-model="newTodoList.title" />
|
||||
</b-field>
|
||||
<b-button native-type="submit">{{ $t('Add this list') }}</b-button>
|
||||
</form>
|
||||
<div v-for="todoList in todoLists" :key="todoList.id">
|
||||
<pre>{{ todoList }}</pre>
|
||||
</div>
|
||||
</section>
|
||||
<div class="container section" v-if="group">
|
||||
<nav class="breadcrumb" aria-label="breadcrumbs">
|
||||
<ul>
|
||||
<li><router-link :to="{ name: RouteName.GROUP, params: { preferredUsername: group.preferredUsername } }">{{ group.preferredUsername }}</router-link></li>
|
||||
<li class="is-active"><router-link :to="{ name: RouteName.TODO_LISTS, params: { preferredUsername: group.preferredUsername } }">{{ $t('Task lists') }}</router-link></li>
|
||||
</ul>
|
||||
</nav>
|
||||
<section>
|
||||
<form class="form" @submit.prevent="createNewTodoList">
|
||||
<b-field :label="$t('List title')">
|
||||
<b-input v-model="newTodoList.title"/>
|
||||
</b-field>
|
||||
<b-button native-type="submit">{{ $t('Create a new list') }}</b-button>
|
||||
</form>
|
||||
<div v-for="todoList in todoLists" :key="todoList.id">
|
||||
<router-link :to="{ name: RouteName.TODO_LIST, params: { id: todoList.id } }">
|
||||
<h3 class="is-size-3">{{ $tc('{title} ({count} todos)', todoList.todos.total, { count:
|
||||
todoList.todos.total, title: todoList.title }) }}</h3>
|
||||
</router-link>
|
||||
<compact-todo :todo="todo" v-for="todo in todoList.todos.elements" :key="todo.id"/>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
<script lang="ts">
|
||||
import { Component, Prop, Vue } from 'vue-property-decorator';
|
||||
|
@ -17,6 +29,8 @@ import { FETCH_GROUP } from '@/graphql/actor';
|
|||
import { IGroup } from '@/types/actor';
|
||||
import { ITodoList } from '@/types/todos';
|
||||
import { CREATE_TODO_LIST } from '@/graphql/todos';
|
||||
import CompactTodo from '@/components/Todo/CompactTodo.vue';
|
||||
import { RouteName } from '@/router';
|
||||
|
||||
@Component({
|
||||
apollo: {
|
||||
|
@ -29,11 +43,15 @@ import { CREATE_TODO_LIST } from '@/graphql/todos';
|
|||
},
|
||||
},
|
||||
},
|
||||
components: {
|
||||
CompactTodo,
|
||||
},
|
||||
})
|
||||
export default class TodoLists extends Vue {
|
||||
@Prop({ type: String, required: true }) preferredUsername!: string;
|
||||
group!: IGroup;
|
||||
newTodoList: ITodoList = { title: '', id: '', todos: { elements: [], total: 0 } };
|
||||
RouteName = RouteName;
|
||||
|
||||
get todoLists() {
|
||||
return this.group.todoLists.elements;
|
||||
|
@ -53,4 +71,4 @@ export default class TodoLists extends Vue {
|
|||
});
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</script>
|
||||
|
|
2575
js/yarn.lock
2575
js/yarn.lock
File diff suppressed because it is too large
Load Diff
|
@ -10,11 +10,23 @@ defmodule Mobilizon.Federation.ActivityPub do
|
|||
|
||||
import Mobilizon.Federation.ActivityPub.Utils
|
||||
|
||||
alias Mobilizon.{Actors, Config, Conversations, Events, Reports, Share, Todos, Users}
|
||||
alias Mobilizon.{
|
||||
Actors,
|
||||
Config,
|
||||
Conversations,
|
||||
Events,
|
||||
Reports,
|
||||
Resources,
|
||||
Share,
|
||||
Todos,
|
||||
Users
|
||||
}
|
||||
|
||||
alias Mobilizon.Actors.{Actor, Follower, Member}
|
||||
alias Mobilizon.Conversations.Comment
|
||||
alias Mobilizon.Events.{Event, Participant}
|
||||
alias Mobilizon.Reports.Report
|
||||
alias Mobilizon.Resources.Resource
|
||||
alias Mobilizon.Todos.{Todo, TodoList}
|
||||
alias Mobilizon.Tombstone
|
||||
|
||||
|
@ -33,6 +45,7 @@ defmodule Mobilizon.Federation.ActivityPub do
|
|||
alias Mobilizon.Federation.WebFinger
|
||||
|
||||
alias Mobilizon.GraphQL.API.Utils, as: APIUtils
|
||||
alias Mobilizon.Service.RichMedia.Parser
|
||||
|
||||
alias Mobilizon.Web.Endpoint
|
||||
alias Mobilizon.Web.Email.{Admin, Mailer}
|
||||
|
@ -42,7 +55,7 @@ defmodule Mobilizon.Federation.ActivityPub do
|
|||
@doc """
|
||||
Wraps an object into an activity
|
||||
"""
|
||||
@spec create_activity(map, boolean) :: {:ok, Activity.t()}
|
||||
@spec create_activity(map(), boolean()) :: {:ok, Activity.t()}
|
||||
def create_activity(map, local \\ true) when is_map(map) do
|
||||
with map <- lazy_put_activity_defaults(map) do
|
||||
{:ok,
|
||||
|
@ -77,7 +90,7 @@ defmodule Mobilizon.Federation.ActivityPub do
|
|||
{:existing_comment, nil} <- {:existing_comment, Conversations.get_comment_from_url(url)},
|
||||
{:existing_actor, {:error, _err}} <-
|
||||
{:existing_actor, get_or_fetch_actor_by_url(url)},
|
||||
{:ok, %{body: body, status_code: code}} when code in 200..299 <-
|
||||
{:ok, %HTTPoison.Response{body: body, status_code: code}} when code in 200..299 <-
|
||||
HTTPoison.get(
|
||||
url,
|
||||
headers,
|
||||
|
@ -180,6 +193,7 @@ defmodule Mobilizon.Federation.ActivityPub do
|
|||
:group -> create_group(args, additional)
|
||||
:todo_list -> create_todo_list(args, additional)
|
||||
:todo -> create_todo(args, additional)
|
||||
:resource -> create_resource(args, additional)
|
||||
end),
|
||||
{:ok, activity} <- create_activity(create_data, local),
|
||||
:ok <- maybe_federate(activity) do
|
||||
|
@ -212,6 +226,7 @@ defmodule Mobilizon.Federation.ActivityPub do
|
|||
:comment -> update_comment(old_entity, args, additional)
|
||||
:actor -> update_actor(old_entity, args, additional)
|
||||
:todo -> update_todo(old_entity, args, additional)
|
||||
:resource -> update_resource(old_entity, args, additional)
|
||||
end),
|
||||
{:ok, activity} <- create_activity(update_data, local),
|
||||
:ok <- maybe_federate(activity) do
|
||||
|
@ -406,6 +421,24 @@ defmodule Mobilizon.Federation.ActivityPub do
|
|||
end
|
||||
end
|
||||
|
||||
def delete(%Resource{url: url, actor: %Actor{url: actor_url}} = resource, local) do
|
||||
data = %{
|
||||
"type" => "Delete",
|
||||
"actor" => actor_url,
|
||||
"object" => url,
|
||||
"id" => url <> "/delete",
|
||||
"to" => [actor_url <> "/followers", "https://www.w3.org/ns/activitystreams#Public"]
|
||||
}
|
||||
|
||||
# TODO : When a resource is a folder, delete everything in it.
|
||||
with {:ok, _resource} <- Resources.delete_resource(resource),
|
||||
{:ok, true} <- Cachex.del(:activity_pub, "resource_#{resource.id}"),
|
||||
{:ok, activity} <- create_activity(data, local),
|
||||
:ok <- maybe_federate(activity) do
|
||||
{:ok, activity, resource}
|
||||
end
|
||||
end
|
||||
|
||||
def flag(args, local \\ false, _additional \\ %{}) do
|
||||
with {:build_args, args} <- {:build_args, prepare_args_for_report(args)},
|
||||
{:create_report, {:ok, %Report{} = report}} <-
|
||||
|
@ -827,6 +860,59 @@ defmodule Mobilizon.Federation.ActivityPub do
|
|||
end
|
||||
end
|
||||
|
||||
defp create_resource(%{type: "folder"} = args, additional) do
|
||||
with {:ok, %Resource{actor_id: group_id} = resource} <-
|
||||
Resources.create_resource(args),
|
||||
# %Resource{actor_id: group_id} = resource <-
|
||||
# Resources.get_resource(parent_id),
|
||||
{:ok, %Actor{} = group} <- Actors.get_group_by_actor_id(group_id),
|
||||
resource_as_data <-
|
||||
Convertible.model_to_as(%{resource | actor: group}),
|
||||
audience <- %{"to" => [group.url], "cc" => []},
|
||||
create_data <-
|
||||
make_create_data(resource_as_data, Map.merge(audience, additional)) do
|
||||
{:ok, resource, create_data}
|
||||
else
|
||||
err ->
|
||||
Logger.error(inspect(err))
|
||||
err
|
||||
end
|
||||
end
|
||||
|
||||
defp create_resource(%{resource_url: resource_url} = args, additional) do
|
||||
args =
|
||||
case Parser.parse(resource_url) do
|
||||
{:ok, metadata} -> Map.put(args, :metadata, metadata)
|
||||
_ -> args
|
||||
end
|
||||
|
||||
with {:ok, %Resource{actor_id: group_id} = resource} <-
|
||||
Resources.create_resource(args),
|
||||
# %Resource{actor_id: group_id} = resource <-
|
||||
# Resources.get_resource(parent_id),
|
||||
{:ok, %Actor{} = group} <- Actors.get_group_by_actor_id(group_id),
|
||||
resource_as_data <-
|
||||
Convertible.model_to_as(%{resource | actor: group}),
|
||||
audience <- %{"to" => [group.url], "cc" => []},
|
||||
create_data <-
|
||||
make_create_data(resource_as_data, Map.merge(audience, additional)) do
|
||||
{:ok, resource, create_data}
|
||||
else
|
||||
err ->
|
||||
Logger.error(inspect(err))
|
||||
err
|
||||
end
|
||||
end
|
||||
|
||||
defp string_or_default(args, key, default) do
|
||||
Map.update(args, key, "", fn value ->
|
||||
case String.strip(args[key]) do
|
||||
"" -> default
|
||||
_ -> args[key]
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
@spec check_for_tombstones(map()) :: Tombstone.t() | nil
|
||||
defp check_for_tombstones(%{url: url}), do: Tombstone.find_tombstone(url)
|
||||
defp check_for_tombstones(_), do: nil
|
||||
|
@ -888,9 +974,28 @@ defmodule Mobilizon.Federation.ActivityPub do
|
|||
todo_as_data <-
|
||||
Convertible.model_to_as(%{todo | todo_list: %{todo_list | actor: group}}),
|
||||
audience <- %{"to" => [group.url], "cc" => []},
|
||||
create_data <-
|
||||
update_data <-
|
||||
make_update_data(todo_as_data, Map.merge(audience, additional)) do
|
||||
{:ok, todo, create_data}
|
||||
{:ok, todo, update_data}
|
||||
end
|
||||
end
|
||||
|
||||
defp update_resource(%Resource{} = old_resource, args, additional) do
|
||||
with {:ok, %Resource{actor_id: group_id} = resource} <-
|
||||
Resources.update_resource(old_resource, args),
|
||||
# %Resource{actor_id: group_id} = resource <-
|
||||
# Resources.get_resource(parent_id),
|
||||
{:ok, %Actor{} = group} <- Actors.get_group_by_actor_id(group_id),
|
||||
resource_as_data <-
|
||||
Convertible.model_to_as(%{resource | actor: group}),
|
||||
audience <- %{"to" => [group.url], "cc" => []},
|
||||
update_data <-
|
||||
make_update_data(resource_as_data, Map.merge(audience, additional)) do
|
||||
{:ok, resource, update_data}
|
||||
else
|
||||
err ->
|
||||
Logger.error(inspect(err))
|
||||
err
|
||||
end
|
||||
end
|
||||
|
||||
|
|
|
@ -584,7 +584,8 @@ defmodule Mobilizon.Federation.ActivityPub.Transmogrifier do
|
|||
# else
|
||||
# {:join_event, {:ok, %Participant{role: :participant}}} ->
|
||||
# Logger.debug(
|
||||
# "Tried to handle an Accept activity on a Join activity with a event object but the participant is already validated"
|
||||
# "Tried to handle an Accept activity on a Join activity with a event object but the participant
|
||||
# is already validated"
|
||||
# )
|
||||
#
|
||||
# nil
|
||||
|
|
|
@ -0,0 +1,85 @@
|
|||
defmodule Mobilizon.Federation.ActivityStream.Converter.Resource do
|
||||
@moduledoc """
|
||||
Resource converter.
|
||||
|
||||
This module allows to convert resources from ActivityStream format to our own
|
||||
internal one, and back.
|
||||
"""
|
||||
alias Mobilizon.Actors.Actor
|
||||
alias Mobilizon.Federation.ActivityPub
|
||||
alias Mobilizon.Federation.ActivityStream.{Converter, Convertible}
|
||||
alias Mobilizon.Resources
|
||||
alias Mobilizon.Resources.Resource
|
||||
|
||||
@behaviour Converter
|
||||
|
||||
defimpl Convertible, for: Resource do
|
||||
alias Mobilizon.Federation.ActivityStream.Converter.Resource, as: ResourceConverter
|
||||
|
||||
defdelegate model_to_as(resource), to: ResourceConverter
|
||||
end
|
||||
|
||||
@doc """
|
||||
Convert an resource struct to an ActivityStream representation
|
||||
"""
|
||||
@impl Converter
|
||||
@spec model_to_as(Resource.t()) :: map
|
||||
def model_to_as(%Resource{actor: %Actor{url: actor_url}} = resource) do
|
||||
res = %{
|
||||
"type" => "Resource",
|
||||
"actor" => actor_url,
|
||||
"id" => resource.url,
|
||||
"name" => resource.title,
|
||||
"summary" => resource.summary
|
||||
# TODO add resource_url
|
||||
}
|
||||
|
||||
# if parent do
|
||||
# Map.put(res, "parent", parent.url)
|
||||
# else
|
||||
# res
|
||||
# end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Converts an AP object data to our internal data structure.
|
||||
"""
|
||||
@impl Converter
|
||||
@spec as_to_model_data(map) :: {:ok, map} | {:error, any()}
|
||||
def as_to_model_data(%{"type" => "Resource", "actor" => actor_url} = object) do
|
||||
with {:ok, %Actor{id: actor_id} = actor} <-
|
||||
ActivityPub.get_or_fetch_actor_by_url(actor_url) do
|
||||
data = %{
|
||||
title: object["name"],
|
||||
summary: object["summary"],
|
||||
url: object["id"],
|
||||
actor_id: actor_id
|
||||
# parent_id: get_eventual_parent(object)
|
||||
# TODO add resource_url
|
||||
}
|
||||
|
||||
{:ok, data}
|
||||
end
|
||||
end
|
||||
|
||||
defp get_eventual_parent_id(%{"parent" => parent_url})
|
||||
when not is_nil(parent_url) do
|
||||
with %Resource{id: resource_id} = _resource <-
|
||||
Resources.get_resource_by_url(parent_url) do
|
||||
resource_id
|
||||
else
|
||||
nil ->
|
||||
with {:ok, %Resource{id: resource_id} = _resource} <-
|
||||
ActivityPub.fetch_object_from_url(parent_url) do
|
||||
resource_id
|
||||
else
|
||||
_ -> nil
|
||||
end
|
||||
|
||||
_ ->
|
||||
nil
|
||||
end
|
||||
end
|
||||
|
||||
defp get_eventual_parent_id(_), do: nil
|
||||
end
|
|
@ -1,223 +0,0 @@
|
|||
defmodule Mobilizon.GraphQL.Resolvers.Collections do
|
||||
@moduledoc """
|
||||
Handles the collection-related GraphQL calls
|
||||
"""
|
||||
|
||||
alias Mobilizon.{Actors, Collections, Users}
|
||||
alias Mobilizon.Actors.Actor
|
||||
alias Mobilizon.Collections.{Collection, Resource}
|
||||
alias Mobilizon.Storage.Page
|
||||
alias Mobilizon.Users.User
|
||||
|
||||
require Logger
|
||||
|
||||
@doc """
|
||||
Find collections for group.
|
||||
|
||||
Returns only if actor requesting is a member of the group
|
||||
"""
|
||||
def find_collections_for_group(
|
||||
%Actor{id: group_id} = group,
|
||||
_args,
|
||||
%{
|
||||
context: %{
|
||||
current_user: %User{} = user
|
||||
}
|
||||
} = _resolution
|
||||
) do
|
||||
with %Actor{id: actor_id} <- Users.get_actor_for_user(user),
|
||||
{:member, true} <- {:member, Actors.is_member?(actor_id, group_id)},
|
||||
%Page{} = page <- Collections.get_collections_for_group(group) do
|
||||
{:ok, page}
|
||||
else
|
||||
{:member, _} ->
|
||||
with %Page{} = page <- Collections.get_collections_for_group(group) do
|
||||
{:ok, %Page{page | elements: []}}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def find_collections_for_group(
|
||||
_group,
|
||||
_args,
|
||||
_resolution
|
||||
) do
|
||||
{:ok, %Page{total: 0, elements: []}}
|
||||
end
|
||||
|
||||
def find_resources_for_collection(
|
||||
%Collection{actor_id: group_id} = collection,
|
||||
_args,
|
||||
%{
|
||||
context: %{
|
||||
current_user: %User{} = user
|
||||
}
|
||||
} = _resolution
|
||||
) do
|
||||
with %Actor{id: actor_id} <- Users.get_actor_for_user(user),
|
||||
{:member, true} <- {:member, Actors.is_member?(actor_id, group_id)},
|
||||
%Page{} = page <- Collections.get_resources_for_collection(collection) do
|
||||
{:ok, page}
|
||||
else
|
||||
{:is_owned, nil} ->
|
||||
{:error, "Actor id is not owned by authenticated user"}
|
||||
|
||||
{:member, _} ->
|
||||
{:error, "Actor id is not member of group"}
|
||||
end
|
||||
end
|
||||
|
||||
def create_collection(
|
||||
_parent,
|
||||
%{actor_id: actor_id, group_id: group_id},
|
||||
%{
|
||||
context: %{
|
||||
current_user: %User{} = user
|
||||
}
|
||||
} = _resolution
|
||||
) do
|
||||
with {:is_owned, %Actor{} = actor} <- User.owns_actor(user, actor_id),
|
||||
{:member, true} <- {:member, Actors.is_member?(actor_id, group_id)},
|
||||
{:ok, _, %Resource{} = resource} <- ActivityPub.create_collection(actor, true, %{}) do
|
||||
{:ok, resource}
|
||||
else
|
||||
{:member, _} ->
|
||||
{:error, "Actor id is not member of group"}
|
||||
end
|
||||
end
|
||||
|
||||
def update_collection(
|
||||
_parent,
|
||||
%{id: collection_id, actor_id: actor_id},
|
||||
%{
|
||||
context: %{
|
||||
current_user: %User{} = user
|
||||
}
|
||||
} = _resolution
|
||||
) do
|
||||
with {:is_owned, %Actor{} = actor} <- User.owns_actor(user, actor_id),
|
||||
{:collection, %Collection{actor_id: group_id} = collection} <-
|
||||
{:collection, Collections.get_collection(collection_id)},
|
||||
{:member, true} <- {:member, Actors.is_member?(actor_id, group_id)},
|
||||
{:ok, _, %Collection{} = resource} <-
|
||||
ActivityPub.update_collection(collection, actor, true, %{}) do
|
||||
{:ok, resource}
|
||||
else
|
||||
{:collection, _} ->
|
||||
{:error, "Collection doesn't exist"}
|
||||
|
||||
{:member, _} ->
|
||||
{:error, "Actor id is not member of group"}
|
||||
end
|
||||
end
|
||||
|
||||
def delete_collection(
|
||||
_parent,
|
||||
%{id: collection_id, actor_id: actor_id},
|
||||
%{
|
||||
context: %{
|
||||
current_user: %User{} = user
|
||||
}
|
||||
} = _resolution
|
||||
) do
|
||||
with {:is_owned, %Actor{} = actor} <- User.owns_actor(user, actor_id),
|
||||
{:collection, %Collection{actor_id: group_id} = collection} <-
|
||||
{:collection, Collections.get_collection(collection_id)},
|
||||
{:member, true} <- {:member, Actors.is_member?(actor_id, group_id)},
|
||||
{:ok, _, %Collection{} = resource} <-
|
||||
ActivityPub.delete_collection(collection, actor, true, %{}) do
|
||||
{:ok, resource}
|
||||
else
|
||||
{:collection, _} ->
|
||||
{:error, "Collection doesn't exist"}
|
||||
|
||||
{:member, _} ->
|
||||
{:error, "Actor id is not member of group"}
|
||||
end
|
||||
end
|
||||
|
||||
def create_resource(
|
||||
_parent,
|
||||
%{actor_id: actor_id, collection_id: collection_id},
|
||||
%{
|
||||
context: %{
|
||||
current_user: %User{} = user
|
||||
}
|
||||
} = _resolution
|
||||
) do
|
||||
with {:is_owned, %Actor{} = actor} <- User.owns_actor(user, actor_id),
|
||||
{:collection, %Collection{actor_id: group_id} = collection} <-
|
||||
{:collection, Collections.get_collection(collection_id)},
|
||||
{:member, true} <- {:member, Actors.is_member?(actor_id, group_id)},
|
||||
{:ok, _, %Resource{} = resource} <-
|
||||
ActivityPub.create_resource(actor, collection, true, %{}) do
|
||||
{:ok, resource}
|
||||
else
|
||||
{:collection, _} ->
|
||||
{:error, "Collection doesn't exist"}
|
||||
|
||||
{:member, _} ->
|
||||
{:error, "Actor id is not member of group"}
|
||||
end
|
||||
end
|
||||
|
||||
def update_resource(
|
||||
_parent,
|
||||
%{id: resource_id, actor_id: actor_id},
|
||||
%{
|
||||
context: %{
|
||||
current_user: %User{} = user
|
||||
}
|
||||
} = _resolution
|
||||
) do
|
||||
with {:is_owned, %Actor{} = actor} <- User.owns_actor(user, actor_id),
|
||||
{:resource, %Resource{collection_id: collection_id} = resource} <-
|
||||
{:resource, Collections.get_resource(resource_id)},
|
||||
{:collection, %Collection{actor_id: group_id}} <-
|
||||
{:collection, Collections.get_collection(collection_id)},
|
||||
{:member, true} <- {:member, Actors.is_member?(actor_id, group_id)},
|
||||
{:ok, _, %Resource{} = resource} <-
|
||||
ActivityPub.update_resource(resource, actor, true, %{}) do
|
||||
{:ok, resource}
|
||||
else
|
||||
{:collection, _} ->
|
||||
{:error, "Collection doesn't exist"}
|
||||
|
||||
{:resource, _} ->
|
||||
{:error, "Resource doesn't exist"}
|
||||
|
||||
{:member, _} ->
|
||||
{:error, "Actor id is not member of group"}
|
||||
end
|
||||
end
|
||||
|
||||
def delete_resource(
|
||||
_parent,
|
||||
%{id: resource_id, actor_id: actor_id},
|
||||
%{
|
||||
context: %{
|
||||
current_user: %User{} = user
|
||||
}
|
||||
} = _resolution
|
||||
) do
|
||||
with {:is_owned, %Actor{} = actor} <- User.owns_actor(user, actor_id),
|
||||
{:resource, %Resource{collection_id: collection_id} = resource} <-
|
||||
{:resource, Collections.get_resource(resource_id)},
|
||||
{:collection, %Collection{actor_id: group_id}} <-
|
||||
{:collection, Collections.get_collection(collection_id)},
|
||||
{:member, true} <- {:member, Actors.is_member?(actor_id, group_id)},
|
||||
{:ok, _, %Resource{} = resource} <-
|
||||
ActivityPub.delete_resource(resource, actor, true, %{}) do
|
||||
{:ok, resource}
|
||||
else
|
||||
{:collection, _} ->
|
||||
{:error, "Collection doesn't exist"}
|
||||
|
||||
{:resource, _} ->
|
||||
{:error, "Resource doesn't exist"}
|
||||
|
||||
{:member, _} ->
|
||||
{:error, "Actor id is not member of group"}
|
||||
end
|
||||
end
|
||||
end
|
|
@ -100,7 +100,8 @@ defmodule Mobilizon.GraphQL.Resolvers.Config do
|
|||
endpoint: Config.instance_maps_tiles_endpoint(),
|
||||
attribution: Config.instance_maps_tiles_attribution()
|
||||
}
|
||||
}
|
||||
},
|
||||
resource_providers: Config.instance_resource_providers()
|
||||
}
|
||||
end
|
||||
end
|
||||
|
|
|
@ -5,13 +5,11 @@ defmodule Mobilizon.GraphQL.Resolvers.Group do
|
|||
|
||||
alias Mobilizon.{Actors, Events, Users}
|
||||
alias Mobilizon.Actors.{Actor, Member}
|
||||
alias Mobilizon.Users.User
|
||||
|
||||
alias Mobilizon.Federation.ActivityPub
|
||||
alias Mobilizon.GraphQL.API
|
||||
alias Mobilizon.GraphQL.Resolvers.Person
|
||||
|
||||
alias Mobilizon.Federation.ActivityPub
|
||||
alias Mobilizon.Storage.Page
|
||||
alias Mobilizon.Users.User
|
||||
|
||||
require Logger
|
||||
|
||||
|
|
|
@ -0,0 +1,230 @@
|
|||
defmodule Mobilizon.GraphQL.Resolvers.Resource do
|
||||
@moduledoc """
|
||||
Handles the resources-related GraphQL calls
|
||||
"""
|
||||
|
||||
alias Mobilizon.{Actors, Resources, Users}
|
||||
alias Mobilizon.Actors.Actor
|
||||
alias Mobilizon.Resources.Resource
|
||||
alias Mobilizon.Resources.Resource.Metadata
|
||||
alias Mobilizon.Storage.Page
|
||||
alias Mobilizon.Users.User
|
||||
alias Mobilizon.Federation.ActivityPub
|
||||
alias Mobilizon.Service.RichMedia.Parser
|
||||
|
||||
require Logger
|
||||
|
||||
@doc """
|
||||
Find resources for group.
|
||||
|
||||
Returns only if actor requesting is a member of the group
|
||||
"""
|
||||
def find_resources_for_group(
|
||||
%Actor{id: group_id} = group,
|
||||
%{page: page, limit: limit},
|
||||
%{
|
||||
context: %{
|
||||
current_user: %User{} = user
|
||||
}
|
||||
} = _resolution
|
||||
) do
|
||||
with %Actor{id: actor_id} <- Users.get_actor_for_user(user),
|
||||
{:member, true} <- {:member, Actors.is_member?(actor_id, group_id)},
|
||||
%Page{} = page <- Resources.get_resources_for_group(group, page, limit) do
|
||||
{:ok, page}
|
||||
else
|
||||
{:member, _} ->
|
||||
find_resources_for_group(nil, nil, nil)
|
||||
end
|
||||
end
|
||||
|
||||
def find_resources_for_group(
|
||||
_group,
|
||||
_args,
|
||||
_resolution
|
||||
) do
|
||||
{:ok, %Page{total: 0, elements: []}}
|
||||
end
|
||||
|
||||
def find_resources_for_parent(
|
||||
%Resource{actor_id: group_id} = parent,
|
||||
_args,
|
||||
%{
|
||||
context: %{
|
||||
current_user: %User{} = user
|
||||
}
|
||||
} = _resolution
|
||||
) do
|
||||
with %Actor{id: actor_id} <- Users.get_actor_for_user(user),
|
||||
{:member, true} <- {:member, Actors.is_member?(actor_id, group_id)},
|
||||
%Page{} = page <- Resources.get_resources_for_folder(parent) do
|
||||
{:ok, page}
|
||||
end
|
||||
end
|
||||
|
||||
def find_resources_for_parent(_parent, _args, _resolution),
|
||||
do: {:ok, %Page{total: 0, elements: []}}
|
||||
|
||||
def get_resource(
|
||||
_parent,
|
||||
%{path: path, username: username},
|
||||
%{
|
||||
context: %{
|
||||
current_user: %User{} = user
|
||||
}
|
||||
} = _resolution
|
||||
) do
|
||||
with %Actor{id: actor_id} <- Users.get_actor_for_user(user),
|
||||
%Actor{id: group_id} <- Actors.get_actor_by_name(username, :Group),
|
||||
{:member, true} <- {:member, Actors.is_member?(actor_id, group_id)},
|
||||
{:resource, %Resource{} = resource} <-
|
||||
{:resource, Resources.get_resource_by_group_and_path_with_preloads(group_id, path)} do
|
||||
{:ok, resource}
|
||||
else
|
||||
{:member, false} -> {:error, "Actor is not member of group"}
|
||||
{:resource, _} -> {:error, "No such resource"}
|
||||
end
|
||||
end
|
||||
|
||||
def get_resource(_parent, _args, _resolution) do
|
||||
{:error, "You need to be logged-in to access resources"}
|
||||
end
|
||||
|
||||
def create_resource(
|
||||
_parent,
|
||||
%{actor_id: group_id} = args,
|
||||
%{
|
||||
context: %{
|
||||
current_user: %User{} = user
|
||||
}
|
||||
} = _resolution
|
||||
) do
|
||||
with %Actor{id: actor_id} <- Users.get_actor_for_user(user),
|
||||
{:member, true} <- {:member, Actors.is_member?(actor_id, group_id)},
|
||||
{:ok, %Actor{} = group} <- Actors.get_group_by_actor_id(group_id),
|
||||
parent <- get_eventual_parent(args),
|
||||
{:own_check, true} <- {:own_check, check_resource_owned_by_group(parent, group_id)},
|
||||
{:ok, _, %Resource{} = resource} <-
|
||||
ActivityPub.create(
|
||||
:resource,
|
||||
args
|
||||
|> Map.put(:actor_id, group_id)
|
||||
|> Map.put(:creator_id, actor_id),
|
||||
true,
|
||||
%{}
|
||||
) do
|
||||
{:ok, resource}
|
||||
else
|
||||
{:own_check, _} ->
|
||||
{:error, "Parent resource doesn't match this group"}
|
||||
|
||||
{:member, _} ->
|
||||
{:error, "Actor id is not member of group"}
|
||||
end
|
||||
end
|
||||
|
||||
def create_resource(_parent, _args, _resolution) do
|
||||
{:error, "You need to be logged-in to create resources"}
|
||||
end
|
||||
|
||||
def update_resource(
|
||||
_parent,
|
||||
%{id: resource_id} = args,
|
||||
%{
|
||||
context: %{
|
||||
current_user: %User{} = user
|
||||
}
|
||||
} = _resolution
|
||||
) do
|
||||
with %Actor{id: actor_id} <- Users.get_actor_for_user(user),
|
||||
{:resource, %Resource{actor_id: group_id} = resource} <-
|
||||
{:resource, Resources.get_resource_with_preloads(resource_id)},
|
||||
{:member, true} <- {:member, Actors.is_member?(actor_id, group_id)},
|
||||
{:ok, _, %Resource{} = resource} <-
|
||||
ActivityPub.update(:resource, resource, args, true, %{}) do
|
||||
{:ok, resource}
|
||||
else
|
||||
{:resource, _} ->
|
||||
{:error, "Resource doesn't exist"}
|
||||
|
||||
{:member, _} ->
|
||||
{:error, "Actor id is not member of group"}
|
||||
end
|
||||
end
|
||||
|
||||
def update_resource(_parent, _args, _resolution) do
|
||||
{:error, "You need to be logged-in to update resources"}
|
||||
end
|
||||
|
||||
def delete_resource(
|
||||
_parent,
|
||||
%{id: resource_id},
|
||||
%{
|
||||
context: %{
|
||||
current_user: %User{} = user
|
||||
}
|
||||
} = _resolution
|
||||
) do
|
||||
with %Actor{id: actor_id} <- Users.get_actor_for_user(user),
|
||||
{:resource, %Resource{parent_id: _parent_id, actor_id: group_id} = resource} <-
|
||||
{:resource, Resources.get_resource_with_preloads(resource_id)},
|
||||
{:member, true} <- {:member, Actors.is_member?(actor_id, group_id)},
|
||||
{:ok, _, %Resource{} = resource} <-
|
||||
ActivityPub.delete(resource) do
|
||||
{:ok, resource}
|
||||
else
|
||||
{:resource, _} ->
|
||||
{:error, "Resource doesn't exist"}
|
||||
|
||||
{:member, _} ->
|
||||
{:error, "Actor id is not member of group"}
|
||||
end
|
||||
end
|
||||
|
||||
def delete_resource(_parent, _args, _resolution) do
|
||||
{:error, "You need to be logged-in to delete resources"}
|
||||
end
|
||||
|
||||
def preview_resource_link(
|
||||
_parent,
|
||||
%{resource_url: resource_url},
|
||||
%{
|
||||
context: %{
|
||||
current_user: %User{} = _user
|
||||
}
|
||||
} = _resolution
|
||||
) do
|
||||
with {:ok, data} when is_map(data) <- Parser.parse(resource_url) do
|
||||
{:ok, struct(Metadata, data)}
|
||||
end
|
||||
end
|
||||
|
||||
def preview_resource_link(_parent, _args, _resolution) do
|
||||
{:error, "You need to be logged-in to view a resource preview"}
|
||||
end
|
||||
|
||||
@spec get_eventual_parent(map()) :: Resource.t() | nil
|
||||
defp get_eventual_parent(args) do
|
||||
parent = args |> Map.get(:parent_id) |> get_parent_resource()
|
||||
|
||||
case parent do
|
||||
%Resource{} -> parent
|
||||
_ -> nil
|
||||
end
|
||||
end
|
||||
|
||||
@spec get_parent_resource(integer | nil) :: nil | Resource.t()
|
||||
defp get_parent_resource(nil), do: nil
|
||||
defp get_parent_resource(parent_id), do: Resources.get_resource(parent_id)
|
||||
|
||||
@spec check_resource_owned_by_group(Resource.t() | nil, integer) :: boolean
|
||||
defp check_resource_owned_by_group(nil, _group_id), do: true
|
||||
|
||||
defp check_resource_owned_by_group(%Resource{actor_id: actor_id} = resource, group_id)
|
||||
when is_binary(group_id),
|
||||
do: actor_id == String.to_integer(group_id)
|
||||
|
||||
defp check_resource_owned_by_group(%Resource{actor_id: actor_id}, group_id)
|
||||
when is_number(group_id),
|
||||
do: actor_id == group_id
|
||||
end
|
|
@ -26,8 +26,7 @@ defmodule Mobilizon.GraphQL.Schema do
|
|||
import_types(Schema.Conversations.CommentType)
|
||||
import_types(Schema.Conversations.ConversationType)
|
||||
import_types(Schema.SearchType)
|
||||
import_types(Schema.Collections.CollectionType)
|
||||
import_types(Schema.Collections.ResourceType)
|
||||
import_types(Schema.ResourceType)
|
||||
import_types(Schema.Todos.TodoListType)
|
||||
import_types(Schema.Todos.TodoType)
|
||||
import_types(Schema.ConfigType)
|
||||
|
@ -137,6 +136,7 @@ defmodule Mobilizon.GraphQL.Schema do
|
|||
import_fields(:todo_list_queries)
|
||||
import_fields(:todo_queries)
|
||||
import_fields(:conversation_queries)
|
||||
import_fields(:resource_queries)
|
||||
end
|
||||
|
||||
@desc """
|
||||
|
@ -157,6 +157,7 @@ defmodule Mobilizon.GraphQL.Schema do
|
|||
import_fields(:todo_list_mutations)
|
||||
import_fields(:todo_mutations)
|
||||
import_fields(:conversation_mutations)
|
||||
import_fields(:resource_mutations)
|
||||
end
|
||||
|
||||
@desc """
|
||||
|
|
|
@ -5,7 +5,7 @@ defmodule Mobilizon.GraphQL.Schema.Actors.GroupType do
|
|||
|
||||
use Absinthe.Schema.Notation
|
||||
|
||||
alias Mobilizon.GraphQL.Resolvers.{Collections, Conversation, Group, Member, Todos}
|
||||
alias Mobilizon.GraphQL.Resolvers.{Conversation, Group, Member, Resource, Todos}
|
||||
alias Mobilizon.GraphQL.Schema
|
||||
|
||||
import_types(Schema.Actors.MemberType)
|
||||
|
@ -62,9 +62,11 @@ defmodule Mobilizon.GraphQL.Schema.Actors.GroupType do
|
|||
description("List of group members")
|
||||
end
|
||||
|
||||
field :collections, :paginated_collection_list do
|
||||
resolve(&Collections.find_collections_for_group/3)
|
||||
description("A paginated list of the collections this group has")
|
||||
field :resources, :paginated_resource_list do
|
||||
arg(:page, :integer, default_value: 1)
|
||||
arg(:limit, :integer, default_value: 10)
|
||||
resolve(&Resource.find_resources_for_group/3)
|
||||
description("A paginated list of the resources this group has")
|
||||
end
|
||||
|
||||
field :todo_lists, :paginated_todo_list_list do
|
||||
|
|
|
@ -1,23 +0,0 @@
|
|||
defmodule Mobilizon.GraphQL.Schema.Collections.CollectionType do
|
||||
@moduledoc """
|
||||
Schema representation for Collections
|
||||
"""
|
||||
use Absinthe.Schema.Notation
|
||||
alias Mobilizon.GraphQL.Resolvers.Collections
|
||||
|
||||
@desc "A collection"
|
||||
object :collection do
|
||||
field(:id, :id, description: "The collection's ID")
|
||||
field(:title, :string, description: "The collection's title")
|
||||
|
||||
field(:resources, :paginated_resource_list,
|
||||
resolve: &Collections.find_resources_for_collection/3,
|
||||
description: "The collection's resources"
|
||||
)
|
||||
end
|
||||
|
||||
object :paginated_collection_list do
|
||||
field(:elements, list_of(:collection), description: "A list of collections")
|
||||
field(:total, :integer, description: "The total number of collections in the list")
|
||||
end
|
||||
end
|
|
@ -1,50 +0,0 @@
|
|||
defmodule Mobilizon.GraphQL.Schema.Collections.ResourceType do
|
||||
@moduledoc """
|
||||
Schema representation for Resources
|
||||
"""
|
||||
use Absinthe.Schema.Notation
|
||||
alias Mobilizon.GraphQL.Resolvers.Collections
|
||||
|
||||
@desc "A resource"
|
||||
object :resource do
|
||||
field(:id, :id, description: "The resource's ID")
|
||||
field(:title, :string, description: "The resource's title")
|
||||
field(:summary, :string, description: "The resource's summary")
|
||||
field(:url, :string, description: "The resource's URL")
|
||||
end
|
||||
|
||||
object :paginated_resource_list do
|
||||
field(:elements, list_of(:resource), description: "A list of resources")
|
||||
field(:total, :integer, description: "The total number of resources in the list")
|
||||
end
|
||||
|
||||
object :resource_mutations do
|
||||
@desc "Create a resource"
|
||||
field :create_resource, :resource do
|
||||
arg(:collection_id, non_null(:id))
|
||||
arg(:actor_id, non_null(:id))
|
||||
arg(:title, non_null(:string))
|
||||
arg(:summary, non_null(:string))
|
||||
arg(:url, non_null(:string))
|
||||
|
||||
resolve(&Collections.create_resource/3)
|
||||
end
|
||||
|
||||
@desc "Update a resource"
|
||||
field :update_resource, :resource do
|
||||
arg(:id, non_null(:id))
|
||||
arg(:actor_id, non_null(:id))
|
||||
arg(:title, non_null(:string))
|
||||
arg(:summary, non_null(:string))
|
||||
arg(:url, non_null(:string))
|
||||
|
||||
resolve(&Collections.update_resource/3)
|
||||
end
|
||||
|
||||
@desc "Delete a resource"
|
||||
field :delete_resource, :deleted_object do
|
||||
arg(:id, non_null(:id))
|
||||
resolve(&Collections.delete_resource/3)
|
||||
end
|
||||
end
|
||||
end
|
|
@ -20,6 +20,7 @@ defmodule Mobilizon.GraphQL.Schema.ConfigType do
|
|||
field(:geocoding, :geocoding)
|
||||
field(:maps, :maps)
|
||||
field(:anonymous, :anonymous)
|
||||
field(:resource_providers, list_of(:resource_provider))
|
||||
|
||||
field(:terms, :terms, description: "The instance's terms") do
|
||||
arg(:locale, :string, default_value: "en")
|
||||
|
@ -97,6 +98,12 @@ defmodule Mobilizon.GraphQL.Schema.ConfigType do
|
|||
field(:enabled, :boolean)
|
||||
end
|
||||
|
||||
object :resource_provider do
|
||||
field(:type, :string)
|
||||
field(:endpoint, :string)
|
||||
field(:software, :string)
|
||||
end
|
||||
|
||||
object :config_queries do
|
||||
@desc "Get the instance config"
|
||||
field :config, :config do
|
||||
|
|
|
@ -0,0 +1,95 @@
|
|||
defmodule Mobilizon.GraphQL.Schema.ResourceType do
|
||||
@moduledoc """
|
||||
Schema representation for Resources
|
||||
"""
|
||||
use Absinthe.Schema.Notation
|
||||
alias Mobilizon.GraphQL.Resolvers.Resource
|
||||
|
||||
@desc "A resource"
|
||||
object :resource do
|
||||
field(:id, :id, description: "The resource's ID")
|
||||
field(:title, :string, description: "The resource's title")
|
||||
field(:summary, :string, description: "The resource's summary")
|
||||
field(:url, :string, description: "The resource's URL")
|
||||
field(:resource_url, :string, description: "The resource's URL")
|
||||
field(:metadata, :resource_metadata, description: "The resource's metadata")
|
||||
field(:creator, :actor, description: "The resource's creator")
|
||||
field(:actor, :actor, description: "The resource's owner")
|
||||
field(:inserted_at, :naive_datetime, description: "The resource's creation date")
|
||||
field(:updated_at, :naive_datetime, description: "The resource's last update date")
|
||||
field(:type, :string, description: "The resource's type (if it's a folder)")
|
||||
field(:path, :string, description: "The resource's path")
|
||||
field(:parent, :resource, description: "The resource's parent")
|
||||
|
||||
field :children, :paginated_resource_list do
|
||||
description("Children resources in folder")
|
||||
resolve(&Resource.find_resources_for_parent/3)
|
||||
end
|
||||
end
|
||||
|
||||
object :paginated_resource_list do
|
||||
field(:elements, list_of(:resource), description: "A list of resources")
|
||||
field(:total, :integer, description: "The total number of resources in the list")
|
||||
end
|
||||
|
||||
object :resource_metadata do
|
||||
field(:type, :string, description: "The type of the resource")
|
||||
field(:title, :string, description: "The resource's metadata title")
|
||||
field(:description, :string, description: "The resource's metadata description")
|
||||
field(:image_remote_url, :string, description: "The resource's metadata image")
|
||||
field(:width, :integer)
|
||||
field(:height, :integer)
|
||||
field(:author_name, :string)
|
||||
field(:author_url, :string)
|
||||
field(:provider_name, :string)
|
||||
field(:provider_url, :string)
|
||||
field(:html, :string)
|
||||
field(:favicon_url, :string)
|
||||
end
|
||||
|
||||
object :resource_queries do
|
||||
@desc "Get a resource"
|
||||
field :resource, :resource do
|
||||
arg(:path, non_null(:string))
|
||||
arg(:username, non_null(:string))
|
||||
resolve(&Resource.get_resource/3)
|
||||
end
|
||||
end
|
||||
|
||||
object :resource_mutations do
|
||||
@desc "Create a resource"
|
||||
field :create_resource, :resource do
|
||||
arg(:parent_id, :id)
|
||||
arg(:actor_id, non_null(:id))
|
||||
arg(:title, non_null(:string))
|
||||
arg(:summary, :string)
|
||||
arg(:resource_url, :string)
|
||||
arg(:type, :string, default_value: "link")
|
||||
|
||||
resolve(&Resource.create_resource/3)
|
||||
end
|
||||
|
||||
@desc "Update a resource"
|
||||
field :update_resource, :resource do
|
||||
arg(:id, non_null(:id))
|
||||
arg(:title, :string)
|
||||
arg(:summary, :string)
|
||||
arg(:parent_id, :id)
|
||||
arg(:resource_url, :string)
|
||||
|
||||
resolve(&Resource.update_resource/3)
|
||||
end
|
||||
|
||||
@desc "Delete a resource"
|
||||
field :delete_resource, :deleted_object do
|
||||
arg(:id, non_null(:id))
|
||||
resolve(&Resource.delete_resource/3)
|
||||
end
|
||||
|
||||
@desc "Get a preview for a resource link"
|
||||
field :preview_resource_link, :resource_metadata do
|
||||
arg(:resource_url, non_null(:string))
|
||||
resolve(&Resource.preview_resource_link/3)
|
||||
end
|
||||
end
|
||||
end
|
|
@ -19,7 +19,7 @@ defmodule Mobilizon.GraphQL.Schema.Todos.TodoListType do
|
|||
|
||||
field(:todos, :paginated_todo_list,
|
||||
resolve: &Todos.find_todos_for_todo_list/3,
|
||||
description: "The collection's todos"
|
||||
description: "The todo-list's todos"
|
||||
)
|
||||
end
|
||||
|
||||
|
|
|
@ -55,6 +55,7 @@ defmodule Mobilizon do
|
|||
),
|
||||
cachex_spec(:statistics, 10, 60, 60),
|
||||
cachex_spec(:config, 10, 60, 60),
|
||||
cachex_spec(:rich_media_cache, 10, 60, 60),
|
||||
cachex_spec(:activity_pub, 2500, 3, 15)
|
||||
] ++
|
||||
task_children(@env)
|
||||
|
|
|
@ -285,18 +285,13 @@ defmodule Mobilizon.Actors.Actor do
|
|||
def group_creation_changeset(%__MODULE__{} = actor, params) do
|
||||
actor
|
||||
|> cast(params, @group_creation_attrs)
|
||||
|> cast_embed(:avatar)
|
||||
|> cast_embed(:banner)
|
||||
|> build_urls(:Group)
|
||||
|> common_changeset()
|
||||
|> put_change(:domain, nil)
|
||||
|> put_change(:keys, Crypto.generate_rsa_2048_private_key())
|
||||
|> put_change(:type, :Group)
|
||||
|> unique_username_validator()
|
||||
|> validate_required(@group_creation_required_attrs)
|
||||
|> unique_constraint(:preferred_username,
|
||||
name: :actors_preferred_username_domain_type_index
|
||||
)
|
||||
|> unique_constraint(:url, name: :actors_url_index)
|
||||
|> validate_length(:summary, max: 5000)
|
||||
|> validate_length(:preferred_username, max: 100)
|
||||
end
|
||||
|
|
|
@ -1,37 +0,0 @@
|
|||
defmodule Mobilizon.Collections.Collection do
|
||||
@moduledoc """
|
||||
Represents a collection of resources
|
||||
"""
|
||||
use Ecto.Schema
|
||||
import Ecto.Changeset
|
||||
alias Mobilizon.Actors.Actor
|
||||
|
||||
@type t :: %__MODULE__{
|
||||
title: String.t(),
|
||||
summary: String.t(),
|
||||
actor: Actor.t(),
|
||||
public: boolean()
|
||||
}
|
||||
|
||||
@primary_key {:id, :binary_id, autogenerate: true}
|
||||
schema "collections" do
|
||||
field(:public, :boolean, default: false)
|
||||
field(:summary, :string)
|
||||
field(:title, :string)
|
||||
field(:url, :string)
|
||||
belongs_to(:actor, Actor)
|
||||
|
||||
timestamps()
|
||||
end
|
||||
|
||||
@required_attrs [:title, :actor_id, :url]
|
||||
@optional_attrs [:summary, :public]
|
||||
@attrs @required_attrs ++ @optional_attrs
|
||||
|
||||
@doc false
|
||||
def changeset(collection, attrs) do
|
||||
collection
|
||||
|> cast(attrs, @attrs)
|
||||
|> validate_required(@required_attrs)
|
||||
end
|
||||
end
|
|
@ -1,100 +0,0 @@
|
|||
defmodule Mobilizon.Collections do
|
||||
@moduledoc """
|
||||
The Collections context.
|
||||
"""
|
||||
alias Mobilizon.Actors.Actor
|
||||
alias Mobilizon.Collections.{Collection, Resource}
|
||||
alias Mobilizon.Storage.{Page, Repo}
|
||||
|
||||
import Ecto.Query
|
||||
import EctoEnum
|
||||
|
||||
defenum(ResourceType, :resource_type, [
|
||||
:url
|
||||
])
|
||||
|
||||
@doc """
|
||||
Get a collection by it's ID
|
||||
"""
|
||||
@spec get_collection(integer | String.t()) :: Collection.t() | nil
|
||||
def get_collection(id), do: Repo.get(Collection, id)
|
||||
|
||||
@doc """
|
||||
Returns the list of collections for a group.
|
||||
"""
|
||||
@spec get_collections_for_group(Actor.t(), integer | nil, integer | nil) :: Page.t()
|
||||
def get_collections_for_group(%Actor{id: group_id, type: :Group}, page \\ nil, limit \\ nil) do
|
||||
Collection
|
||||
|> where(actor_id: ^group_id)
|
||||
|> Page.build_page(page, limit)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns the list of collections for a group.
|
||||
"""
|
||||
@spec get_resources_for_collection(Collection.t(), integer | nil, integer | nil) :: Page.t()
|
||||
def get_resources_for_collection(%Collection{id: collection_id}, page \\ nil, limit \\ nil) do
|
||||
Resource
|
||||
|> where(collection_id: ^collection_id)
|
||||
|> Page.build_page(page, limit)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Creates a collection.
|
||||
"""
|
||||
@spec create_collection(map) :: {:ok, Collection.t()} | {:error, Ecto.Changeset.t()}
|
||||
def create_collection(attrs \\ %{}) do
|
||||
%Collection{}
|
||||
|> Collection.changeset(attrs)
|
||||
|> Repo.insert()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Updates a collection.
|
||||
"""
|
||||
@spec update_collection(Collection.t(), map) ::
|
||||
{:ok, Collection.t()} | {:error, Ecto.Changeset.t()}
|
||||
def update_collection(%Collection{} = collection, attrs) do
|
||||
collection
|
||||
|> Collection.changeset(attrs)
|
||||
|> Repo.update()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Deletes a collection
|
||||
"""
|
||||
@spec delete_collection(Collection.t()) :: {:ok, Collection.t()} | {:error, Ecto.Changeset.t()}
|
||||
def delete_collection(%Collection{} = collection), do: Repo.delete(collection)
|
||||
|
||||
@doc """
|
||||
Get a resource by it's ID
|
||||
"""
|
||||
@spec get_resource(integer | String.t()) :: Resource.t() | nil
|
||||
def get_resource(id), do: Repo.get(Resource, id)
|
||||
|
||||
@doc """
|
||||
Creates a resource.
|
||||
"""
|
||||
@spec create_resource(map) :: {:ok, Resource.t()} | {:error, Ecto.Changeset.t()}
|
||||
def create_resource(attrs \\ %{}) do
|
||||
%Resource{}
|
||||
|> Resource.changeset(attrs)
|
||||
|> Repo.insert()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Updates a resource.
|
||||
"""
|
||||
@spec update_resource(Resource.t(), map) :: {:ok, Resource.t()} | {:error, Ecto.Changeset.t()}
|
||||
def update_resource(%Resource{} = resource, attrs) do
|
||||
resource
|
||||
|> Resource.changeset(attrs)
|
||||
|> Repo.update()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Deletes a resource
|
||||
"""
|
||||
@spec delete_resource(Resource.t()) :: {:ok, Resource.t()} | {:error, Ecto.Changeset.t()}
|
||||
def delete_resource(%Resource{} = resource), do: Repo.delete(resource)
|
||||
end
|
|
@ -1,44 +0,0 @@
|
|||
defmodule Mobilizon.Collections.Resource do
|
||||
@moduledoc """
|
||||
Represents a web resource
|
||||
"""
|
||||
use Ecto.Schema
|
||||
import Ecto.Changeset
|
||||
import Mobilizon.Storage.Ecto, only: [ensure_url: 2]
|
||||
alias Mobilizon.Collections.{Collection, ResourceType}
|
||||
|
||||
@type t :: %__MODULE__{
|
||||
title: String.t(),
|
||||
summary: String.t(),
|
||||
collection: Collection.t(),
|
||||
url: String.t()
|
||||
}
|
||||
|
||||
@primary_key {:id, :binary_id, autogenerate: true}
|
||||
schema "resource" do
|
||||
field(:summary, :string)
|
||||
field(:title, :string)
|
||||
field(:url, :string)
|
||||
field(:resource_url, :string)
|
||||
|
||||
embeds_one :metadata, Metadata, on_replace: :delete do
|
||||
field(:type, ResourceType)
|
||||
end
|
||||
|
||||
belongs_to(:collection, Collection, type: :binary_id)
|
||||
|
||||
timestamps()
|
||||
end
|
||||
|
||||
@required_attrs [:title, :url, :collection_id, :resource_url]
|
||||
@optional_attrs [:summary]
|
||||
@attrs @required_attrs ++ @optional_attrs
|
||||
|
||||
@doc false
|
||||
def changeset(resource, attrs) do
|
||||
resource
|
||||
|> cast(attrs, @attrs)
|
||||
|> ensure_url(:resource)
|
||||
|> validate_required(@required_attrs)
|
||||
end
|
||||
end
|
|
@ -139,6 +139,23 @@ defmodule Mobilizon.Config do
|
|||
:enabled
|
||||
]
|
||||
|
||||
def instance_resource_providers() do
|
||||
types = get_in(Application.get_env(:mobilizon, Mobilizon.Service.ResourceProviders), [:types])
|
||||
|
||||
providers =
|
||||
get_in(Application.get_env(:mobilizon, Mobilizon.Service.ResourceProviders), [:providers])
|
||||
|
||||
providers_map = :maps.filter(fn key, _value -> key in Keyword.values(types) end, providers)
|
||||
|
||||
Enum.map(providers_map, fn {key, value} ->
|
||||
%{
|
||||
type: key,
|
||||
software: types |> Enum.find(fn {_key, val} -> val == key end) |> elem(0),
|
||||
endpoint: value
|
||||
}
|
||||
end)
|
||||
end
|
||||
|
||||
def anonymous_actor_id, do: get_cached_value(:anonymous_actor_id)
|
||||
def relay_actor_id, do: get_cached_value(:relay_actor_id)
|
||||
|
||||
|
|
|
@ -1,4 +1,7 @@
|
|||
defmodule Mobilizon.Conversations.Conversation.TitleSlug do
|
||||
@moduledoc """
|
||||
Module to generate the slug for conversations
|
||||
"""
|
||||
use EctoAutoslugField.Slug, from: :title, to: :slug
|
||||
end
|
||||
|
||||
|
|
|
@ -0,0 +1,101 @@
|
|||
defmodule Mobilizon.Resources.Resource do
|
||||
@moduledoc """
|
||||
Represents a web resource
|
||||
"""
|
||||
use Ecto.Schema
|
||||
import Ecto.Changeset
|
||||
alias Ecto.Changeset
|
||||
import Mobilizon.Storage.Ecto, only: [ensure_url: 2]
|
||||
|
||||
import EctoEnum
|
||||
defenum(TypeEnum, folder: 0, link: 1, picture: 20, pad: 30, calc: 40, visio: 50)
|
||||
alias Mobilizon.Actors.Actor
|
||||
|
||||
@type t :: %__MODULE__{
|
||||
title: String.t(),
|
||||
summary: String.t(),
|
||||
url: String.t(),
|
||||
resource_url: String.t(),
|
||||
type: atom(),
|
||||
metadata: Mobilizon.Resources.Resource.Metadata.t(),
|
||||
children: list(__MODULE__),
|
||||
parent: __MODULE__,
|
||||
actor: Actor.t(),
|
||||
creator: Actor.t()
|
||||
}
|
||||
|
||||
@primary_key {:id, :binary_id, autogenerate: true}
|
||||
schema "resource" do
|
||||
field(:summary, :string)
|
||||
field(:title, :string)
|
||||
field(:url, :string)
|
||||
field(:resource_url, :string)
|
||||
field(:type, TypeEnum)
|
||||
field(:path, :string)
|
||||
|
||||
embeds_one :metadata, Metadata, on_replace: :delete do
|
||||
field(:type, :string)
|
||||
field(:title, :string)
|
||||
field(:description, :string)
|
||||
field(:image_remote_url, :string)
|
||||
field(:width, :integer)
|
||||
field(:height, :integer)
|
||||
field(:author_name, :string)
|
||||
field(:author_url, :string)
|
||||
field(:provider_name, :string)
|
||||
field(:provider_url, :string)
|
||||
field(:html, :string)
|
||||
field(:favicon_url, :string)
|
||||
end
|
||||
|
||||
has_many(:children, __MODULE__, foreign_key: :parent_id)
|
||||
belongs_to(:parent, __MODULE__, type: :binary_id)
|
||||
belongs_to(:actor, Actor)
|
||||
belongs_to(:creator, Actor)
|
||||
|
||||
timestamps()
|
||||
end
|
||||
|
||||
@required_attrs [:title, :url, :actor_id, :creator_id, :type, :path]
|
||||
@optional_attrs [:summary, :parent_id, :resource_url]
|
||||
@attrs @required_attrs ++ @optional_attrs
|
||||
@metadata_attrs [
|
||||
:type,
|
||||
:title,
|
||||
:description,
|
||||
:image_remote_url,
|
||||
:width,
|
||||
:height,
|
||||
:author_name,
|
||||
:author_url,
|
||||
:provider_name,
|
||||
:provider_url,
|
||||
:html,
|
||||
:favicon_url
|
||||
]
|
||||
|
||||
@doc false
|
||||
def changeset(resource, attrs) do
|
||||
resource
|
||||
|> cast(attrs, @attrs)
|
||||
|> cast_embed(:metadata, with: &metadata_changeset/2)
|
||||
|> ensure_url(:resource)
|
||||
|> validate_resource_or_folder()
|
||||
|> validate_required(@required_attrs)
|
||||
end
|
||||
|
||||
defp metadata_changeset(schema, params) do
|
||||
schema
|
||||
|> cast(params, @metadata_attrs)
|
||||
end
|
||||
|
||||
@spec validate_resource_or_folder(Changeset.t()) :: Changeset.t()
|
||||
defp validate_resource_or_folder(%Changeset{} = changeset) do
|
||||
with {status, type} when status in [:changes, :data] <- fetch_field(changeset, :type),
|
||||
true <- type != :folder do
|
||||
validate_required(changeset, [:resource_url])
|
||||
else
|
||||
_ -> changeset
|
||||
end
|
||||
end
|
||||
end
|
|
@ -0,0 +1,192 @@
|
|||
defmodule Mobilizon.Resources do
|
||||
@moduledoc """
|
||||
The Resources context.
|
||||
"""
|
||||
alias Mobilizon.Actors
|
||||
alias Mobilizon.Actors.Actor
|
||||
alias Mobilizon.Resources.Resource
|
||||
alias Mobilizon.Storage.{Page, Repo}
|
||||
alias Ecto.Multi
|
||||
|
||||
import Ecto.Query
|
||||
require Logger
|
||||
|
||||
@resource_preloads [:actor, :creator, :children]
|
||||
|
||||
@doc """
|
||||
Returns the list of top-level resources for a group
|
||||
"""
|
||||
@spec get_resources_for_group(Actor.t(), integer | nil, integer | nil) :: Page.t()
|
||||
def get_resources_for_group(%Actor{id: group_id}, page \\ nil, limit \\ nil) do
|
||||
Resource
|
||||
|> where(actor_id: ^group_id)
|
||||
|> order_by(desc: :updated_at)
|
||||
|> Page.build_page(page, limit)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns the list of resources for a resource folder.
|
||||
"""
|
||||
@spec get_resources_for_folder(Resource.t(), integer | nil, integer | nil) :: Page.t()
|
||||
def get_resources_for_folder(resource, page \\ nil, limit \\ nil)
|
||||
|
||||
def get_resources_for_folder(
|
||||
%Resource{id: "root_" <> _group_id, actor_id: group_id},
|
||||
page,
|
||||
limit
|
||||
) do
|
||||
Resource
|
||||
|> where([r], r.actor_id == ^group_id and is_nil(r.parent_id))
|
||||
|> order_by(asc: :type)
|
||||
|> Page.build_page(page, limit)
|
||||
end
|
||||
|
||||
def get_resources_for_folder(%Resource{id: resource_id}, page, limit) do
|
||||
Resource
|
||||
|> where([r], r.parent_id == ^resource_id)
|
||||
|> order_by(asc: :type)
|
||||
|> Page.build_page(page, limit)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Get a resource by it's ID
|
||||
"""
|
||||
@spec get_resource(integer | String.t()) :: Resource.t() | nil
|
||||
def get_resource(nil), do: nil
|
||||
def get_resource(id), do: Repo.get(Resource, id)
|
||||
|
||||
@spec get_resource_with_preloads(integer | String.t()) :: Resource.t() | nil
|
||||
def get_resource_with_preloads(id) do
|
||||
Resource
|
||||
|> Repo.get(id)
|
||||
|> Repo.preload(@resource_preloads)
|
||||
end
|
||||
|
||||
@spec get_resource_by_group_and_path_with_preloads(String.t() | integer, String.t()) ::
|
||||
Resource.t() | nil
|
||||
def get_resource_by_group_and_path_with_preloads(group_id, "/") do
|
||||
with {:ok, %Actor{} = group} <- Actors.get_group_by_actor_id(group_id) do
|
||||
%Resource{
|
||||
actor_id: group_id,
|
||||
id: "root_#{group_id}",
|
||||
actor: group,
|
||||
path: "/",
|
||||
title: "Root"
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
def get_resource_by_group_and_path_with_preloads(group_id, path) do
|
||||
Resource
|
||||
|> Repo.get_by(actor_id: group_id, path: path)
|
||||
|> Repo.preload(@resource_preloads)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Get a resource by it's URL
|
||||
"""
|
||||
@spec get_resource_by_url(String.t()) :: Resource.t() | nil
|
||||
def get_resource_by_url(url), do: Repo.get_by(Resource, url: url)
|
||||
|
||||
@doc """
|
||||
Creates a resource.
|
||||
"""
|
||||
@spec create_resource(map) :: {:ok, Resource.t()} | {:error, Ecto.Changeset.t()}
|
||||
def create_resource(attrs \\ %{}) do
|
||||
Multi.new()
|
||||
|> do_find_parent_path(Map.get(attrs, :parent_id))
|
||||
|> Multi.insert(:insert, fn %{find_parent_path: path} ->
|
||||
Resource.changeset(%Resource{}, Map.put(attrs, :path, "#{path}/#{attrs.title}"))
|
||||
end)
|
||||
|> Repo.transaction()
|
||||
|> case do
|
||||
{:ok, %{insert: %Resource{} = resource}} ->
|
||||
{:ok, resource}
|
||||
|
||||
{:error, operation, reason, _changes} ->
|
||||
{:error, "Error while inserting resource when #{operation} because of #{inspect(reason)}"}
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Updates a resource.
|
||||
"""
|
||||
@spec update_resource(Resource.t(), map) :: {:ok, Resource.t()} | {:error, Ecto.Changeset.t()}
|
||||
def update_resource(%Resource{} = resource, attrs) do
|
||||
Multi.new()
|
||||
|> find_parent_path(resource, attrs)
|
||||
|> update_children(resource, attrs)
|
||||
|> Multi.update(:update, fn %{find_parent_path: path} ->
|
||||
Resource.changeset(resource, Map.put(attrs, :path, "#{path}/#{attrs.title}"))
|
||||
end)
|
||||
|> Repo.transaction()
|
||||
|> case do
|
||||
{:ok, %{update: %Resource{} = resource}} ->
|
||||
{:ok, resource}
|
||||
|
||||
# collect errors into record changesets
|
||||
{:error, operation, reason, _changes} ->
|
||||
{:error, "Error while updating resource when #{operation} because of #{inspect(reason)}"}
|
||||
end
|
||||
end
|
||||
|
||||
defp find_parent_path(
|
||||
multi,
|
||||
%Resource{parent_id: old_parent_id} = _resource,
|
||||
attrs
|
||||
) do
|
||||
updated_parent_id = Map.get(attrs, :parent_id, old_parent_id)
|
||||
Logger.debug("Finding parent path for updated_parent_id #{inspect updated_parent_id}")
|
||||
do_find_parent_path(multi, updated_parent_id)
|
||||
end
|
||||
|
||||
defp do_find_parent_path(multi, nil),
|
||||
do: Multi.run(multi, :find_parent_path, fn _, _ -> {:ok, ""} end)
|
||||
|
||||
defp do_find_parent_path(multi, parent_id) do
|
||||
Multi.run(multi, :find_parent_path, fn repo, _changes ->
|
||||
case get_resource(parent_id) do
|
||||
%Resource{path: path} = _resource -> {:ok, path}
|
||||
_ -> {:error, :not_found}
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
defp update_children(
|
||||
multi,
|
||||
%Resource{type: :folder, title: old_title, actor_id: actor_id, parent_id: old_parent_id} =
|
||||
resource,
|
||||
attrs
|
||||
) do
|
||||
title = Map.get(attrs, :title, old_title)
|
||||
|
||||
Multi.run(multi, :update_children, fn repo, %{find_parent_path: path} ->
|
||||
{query, params} =
|
||||
case old_parent_id do
|
||||
nil ->
|
||||
{"UPDATE resource SET path = CONCAT($1, title) WHERE actor_id = $2 AND parent_id IS NULL",
|
||||
["#{path}/#{title}/", actor_id]}
|
||||
|
||||
old_parent_id ->
|
||||
{"UPDATE resource SET path = CONCAT($1, title) WHERE actor_id = $2 AND parent_id = $3",
|
||||
["#{path}/#{title}/", actor_id, old_parent_id]}
|
||||
end
|
||||
|
||||
Ecto.Adapters.SQL.query!(
|
||||
repo,
|
||||
query,
|
||||
params
|
||||
)
|
||||
|
||||
repo.update_all(resource.children, set: [path: "#{path}/#{title}"])
|
||||
end)
|
||||
end
|
||||
|
||||
defp update_children(multi, _, _), do: multi
|
||||
|
||||
@doc """
|
||||
Deletes a resource
|
||||
"""
|
||||
@spec delete_resource(Resource.t()) :: {:ok, Resource.t()} | {:error, Ecto.Changeset.t()}
|
||||
def delete_resource(%Resource{} = resource), do: Repo.delete(resource)
|
||||
end
|
|
@ -26,16 +26,19 @@ defmodule Mobilizon.Todos do
|
|||
def get_todo_lists_for_group(%Actor{id: group_id, type: :Group}, page \\ nil, limit \\ nil) do
|
||||
TodoList
|
||||
|> where(actor_id: ^group_id)
|
||||
|> order_by(desc: :updated_at)
|
||||
|> Page.build_page(page, limit)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns the list of collections for a group.
|
||||
Returns the list of todos for a group.
|
||||
"""
|
||||
@spec get_todos_for_todo_list(TodoList.t(), integer | nil, integer | nil) :: Page.t()
|
||||
def get_todos_for_todo_list(%TodoList{id: todo_list_id}, page \\ nil, limit \\ nil) do
|
||||
Todo
|
||||
|> where(todo_list_id: ^todo_list_id)
|
||||
|> order_by(asc: :status)
|
||||
# |> order_by(desc: :updated_at)
|
||||
|> Page.build_page(page, limit)
|
||||
end
|
||||
|
||||
|
|
|
@ -8,19 +8,19 @@ defmodule Mobilizon.Service.Formatter.DefaultScrubbler do
|
|||
Custom strategy to filter HTML content.
|
||||
"""
|
||||
|
||||
alias HtmlSanitizeEx.Scrubber.Meta
|
||||
|
||||
require HtmlSanitizeEx.Scrubber.Meta
|
||||
require FastSanitize.Sanitizer.Meta
|
||||
alias FastSanitize.Sanitizer.Meta
|
||||
|
||||
# credo:disable-for-previous-line
|
||||
# No idea how to fix this one…
|
||||
|
||||
Meta.remove_cdata_sections_before_scrub()
|
||||
@valid_schemes ~w(https http)
|
||||
|
||||
Meta.strip_comments()
|
||||
|
||||
Meta.allow_tag_with_uri_attributes("a", ["href", "data-user", "data-tag"], ["https", "http"])
|
||||
Meta.allow_tag_with_uri_attributes(:a, ["href", "data-user", "data-tag"], @valid_schemes)
|
||||
|
||||
Meta.allow_tag_with_this_attribute_values("a", "class", [
|
||||
Meta.allow_tag_with_this_attribute_values(:a, "class", [
|
||||
"hashtag",
|
||||
"u-url",
|
||||
"mention",
|
||||
|
@ -28,7 +28,7 @@ defmodule Mobilizon.Service.Formatter.DefaultScrubbler do
|
|||
"mention u-url"
|
||||
])
|
||||
|
||||
Meta.allow_tag_with_this_attribute_values("a", "rel", [
|
||||
Meta.allow_tag_with_this_attribute_values(:a, "rel", [
|
||||
"tag",
|
||||
"nofollow",
|
||||
"noopener",
|
||||
|
@ -36,34 +36,42 @@ defmodule Mobilizon.Service.Formatter.DefaultScrubbler do
|
|||
"ugc"
|
||||
])
|
||||
|
||||
Meta.allow_tag_with_these_attributes("a", ["name", "title"])
|
||||
Meta.allow_tag_with_these_attributes(:a, ["name", "title"])
|
||||
|
||||
Meta.allow_tag_with_these_attributes("abbr", ["title"])
|
||||
Meta.allow_tag_with_these_attributes(:abbr, ["title"])
|
||||
|
||||
Meta.allow_tag_with_these_attributes("b", [])
|
||||
Meta.allow_tag_with_these_attributes("blockquote", [])
|
||||
Meta.allow_tag_with_these_attributes("br", [])
|
||||
Meta.allow_tag_with_these_attributes("code", [])
|
||||
Meta.allow_tag_with_these_attributes("del", [])
|
||||
Meta.allow_tag_with_these_attributes("em", [])
|
||||
Meta.allow_tag_with_these_attributes("i", [])
|
||||
Meta.allow_tag_with_these_attributes("li", [])
|
||||
Meta.allow_tag_with_these_attributes("ol", [])
|
||||
Meta.allow_tag_with_these_attributes("p", [])
|
||||
Meta.allow_tag_with_these_attributes("pre", [])
|
||||
Meta.allow_tag_with_these_attributes("strong", [])
|
||||
Meta.allow_tag_with_these_attributes("u", [])
|
||||
Meta.allow_tag_with_these_attributes("ul", [])
|
||||
Meta.allow_tag_with_these_attributes("img", ["src", "alt"])
|
||||
Meta.allow_tag_with_these_attributes(:b, [])
|
||||
Meta.allow_tag_with_these_attributes(:blockquote, [])
|
||||
Meta.allow_tag_with_these_attributes(:br, [])
|
||||
Meta.allow_tag_with_these_attributes(:code, [])
|
||||
Meta.allow_tag_with_these_attributes(:del, [])
|
||||
Meta.allow_tag_with_these_attributes(:em, [])
|
||||
Meta.allow_tag_with_these_attributes(:i, [])
|
||||
Meta.allow_tag_with_these_attributes(:li, [])
|
||||
Meta.allow_tag_with_these_attributes(:ol, [])
|
||||
Meta.allow_tag_with_these_attributes(:p, [])
|
||||
Meta.allow_tag_with_these_attributes(:pre, [])
|
||||
Meta.allow_tag_with_these_attributes(:strong, [])
|
||||
Meta.allow_tag_with_these_attributes(:u, [])
|
||||
Meta.allow_tag_with_these_attributes(:ul, [])
|
||||
Meta.allow_tag_with_uri_attributes(:img, ["src"], @valid_schemes)
|
||||
|
||||
Meta.allow_tag_with_this_attribute_values("span", "class", ["h-card", "mention"])
|
||||
Meta.allow_tag_with_these_attributes("span", ["data-user"])
|
||||
Meta.allow_tag_with_these_attributes(:img, [
|
||||
"width",
|
||||
"height",
|
||||
"class",
|
||||
"title",
|
||||
"alt"
|
||||
])
|
||||
|
||||
Meta.allow_tag_with_these_attributes("h1", [])
|
||||
Meta.allow_tag_with_these_attributes("h2", [])
|
||||
Meta.allow_tag_with_these_attributes("h3", [])
|
||||
Meta.allow_tag_with_these_attributes("h4", [])
|
||||
Meta.allow_tag_with_these_attributes("h5", [])
|
||||
Meta.allow_tag_with_this_attribute_values(:span, "class", ["h-card", "mention"])
|
||||
Meta.allow_tag_with_these_attributes(:span, ["data-user"])
|
||||
|
||||
Meta.allow_tag_with_these_attributes(:h1, [])
|
||||
Meta.allow_tag_with_these_attributes(:h2, [])
|
||||
Meta.allow_tag_with_these_attributes(:h3, [])
|
||||
Meta.allow_tag_with_these_attributes(:h4, [])
|
||||
Meta.allow_tag_with_these_attributes(:h5, [])
|
||||
|
||||
Meta.strip_everything_not_covered()
|
||||
end
|
||||
|
|
|
@ -95,7 +95,9 @@ defmodule Mobilizon.Service.Formatter do
|
|||
end
|
||||
|
||||
def html_escape(text, "text/html") do
|
||||
HTML.filter_tags(text)
|
||||
with {:ok, content} <- HTML.filter_tags(text) do
|
||||
content
|
||||
end
|
||||
end
|
||||
|
||||
def html_escape(text, "text/plain") do
|
||||
|
|
|
@ -8,9 +8,11 @@ defmodule Mobilizon.Service.Formatter.HTML do
|
|||
Service to filter tags out of HTML content.
|
||||
"""
|
||||
|
||||
alias HtmlSanitizeEx.Scrubber
|
||||
alias FastSanitize.Sanitizer
|
||||
|
||||
alias Mobilizon.Service.Formatter.DefaultScrubbler
|
||||
alias Mobilizon.Service.Formatter.{DefaultScrubbler, OEmbed}
|
||||
|
||||
def filter_tags(html), do: Scrubber.scrub(html, DefaultScrubbler)
|
||||
def filter_tags(html), do: Sanitizer.scrub(html, DefaultScrubbler)
|
||||
|
||||
def filter_tags_for_oembed(html), do: Sanitizer.scrub(html, OEmbed)
|
||||
end
|
||||
|
|
|
@ -0,0 +1,34 @@
|
|||
defmodule Mobilizon.Service.Formatter.OEmbed do
|
||||
@moduledoc """
|
||||
Custom strategy to filter HTML content in OEmbed html
|
||||
"""
|
||||
|
||||
require FastSanitize.Sanitizer.Meta
|
||||
alias FastSanitize.Sanitizer.Meta
|
||||
|
||||
@valid_schemes ~w(https http)
|
||||
|
||||
Meta.strip_comments()
|
||||
|
||||
Meta.allow_tag_with_uri_attributes(:a, ["href"], @valid_schemes)
|
||||
Meta.allow_tag_with_uri_attributes(:img, ["src"], @valid_schemes)
|
||||
|
||||
Meta.allow_tag_with_these_attributes(:audio, ["controls"])
|
||||
|
||||
Meta.allow_tag_with_uri_attributes(:embed, ["src"], @valid_schemes)
|
||||
Meta.allow_tag_with_these_attributes(:embed, ["height type width"])
|
||||
|
||||
Meta.allow_tag_with_uri_attributes(:iframe, ["src"], @valid_schemes)
|
||||
|
||||
Meta.allow_tag_with_these_attributes(
|
||||
:iframe,
|
||||
["allowfullscreen frameborder allow height scrolling width"]
|
||||
)
|
||||
|
||||
Meta.allow_tag_with_uri_attributes(:source, ["src"], @valid_schemes)
|
||||
Meta.allow_tag_with_these_attributes(:source, ["type"])
|
||||
|
||||
Meta.allow_tag_with_these_attributes(:video, ["controls height loop width"])
|
||||
|
||||
Meta.strip_everything_not_covered()
|
||||
end
|
|
@ -0,0 +1,104 @@
|
|||
defmodule Mobilizon.Service.RichMedia.Favicon do
|
||||
@moduledoc """
|
||||
Module to fetch favicon information from a website
|
||||
|
||||
Taken and adapted from https://github.com/ricn/favicon
|
||||
"""
|
||||
|
||||
require Logger
|
||||
alias Mobilizon.Config
|
||||
|
||||
@options [
|
||||
max_body: 2_000_000,
|
||||
timeout: 10_000,
|
||||
recv_timeout: 20_000,
|
||||
follow_redirect: true
|
||||
]
|
||||
|
||||
@spec fetch(String.t(), List.t()) :: {:ok, String.t()} | {:error, any()}
|
||||
def fetch(url, options \\ []) do
|
||||
user_agent = Keyword.get(options, :user_agent, Config.instance_user_agent())
|
||||
headers = [{"User-Agent", user_agent}]
|
||||
|
||||
case HTTPoison.get(url, headers, @options) do
|
||||
{:ok, %HTTPoison.Response{status_code: code, body: body}} when code in 200..299 ->
|
||||
find_favicon_url(url, body, headers)
|
||||
|
||||
{:ok, %HTTPoison.Response{}} ->
|
||||
{:error, "Error while fetching the page"}
|
||||
|
||||
{:error, %HTTPoison.Error{reason: reason}} ->
|
||||
{:error, reason}
|
||||
end
|
||||
end
|
||||
|
||||
@spec find_favicon_url(String.t(), String.t(), List.t()) :: {:ok, String.t()} | {:error, any()}
|
||||
defp find_favicon_url(url, body, headers) do
|
||||
Logger.debug("finding favicon URL for #{url}")
|
||||
|
||||
with {:ok, tag} <- find_favicon_link_tag(body) do
|
||||
Logger.debug("Found link #{inspect(tag)}")
|
||||
{"link", attrs, _} = tag
|
||||
|
||||
{"href", path} =
|
||||
Enum.find(attrs, fn {name, _} ->
|
||||
name == "href"
|
||||
end)
|
||||
|
||||
{:ok, format_url(url, path)}
|
||||
else
|
||||
_ ->
|
||||
find_favicon_in_root(url, headers)
|
||||
end
|
||||
end
|
||||
|
||||
@spec format_url(String.t(), String.t()) :: String.t()
|
||||
defp format_url(url, path) do
|
||||
image_uri = URI.parse(path)
|
||||
uri = URI.parse(url)
|
||||
|
||||
cond do
|
||||
is_nil(image_uri.host) -> "#{uri.scheme}://#{uri.host}#{path}"
|
||||
is_nil(image_uri.scheme) -> "#{uri.scheme}:#{path}"
|
||||
true -> path
|
||||
end
|
||||
end
|
||||
|
||||
@spec find_favicon_link_tag(String.t()) :: {:ok, tuple()} | {:error, any()}
|
||||
defp find_favicon_link_tag(html) do
|
||||
with {:ok, html} <- Floki.parse_document(html),
|
||||
:ok <- Logger.debug(inspect(html)),
|
||||
links <- Floki.find(html, "link"),
|
||||
:ok <- Logger.debug(inspect(links)),
|
||||
{:link, link} when not is_nil(link) <-
|
||||
{:link,
|
||||
Enum.find(links, fn {"link", attrs, _} ->
|
||||
Enum.any?(attrs, fn {name, value} ->
|
||||
name == "rel" && String.contains?(value, "icon") &&
|
||||
!String.contains?(value, "-icon-")
|
||||
end)
|
||||
end)} do
|
||||
{:ok, link}
|
||||
else
|
||||
{:link, nil} -> {:error, "No link found"}
|
||||
err -> err
|
||||
end
|
||||
end
|
||||
|
||||
@spec find_favicon_in_root(String.t(), List.t()) :: {:ok, String.t()} | {:error, any()}
|
||||
defp find_favicon_in_root(url, headers) do
|
||||
uri = URI.parse(url)
|
||||
favicon_url = "#{uri.scheme}://#{uri.host}/favicon.ico"
|
||||
|
||||
case HTTPoison.head(favicon_url, headers, @options) do
|
||||
{:ok, %HTTPoison.Response{status_code: code}} when code in 200..299 ->
|
||||
{:ok, favicon_url}
|
||||
|
||||
{:ok, %HTTPoison.Response{}} ->
|
||||
{:error, "Error while doing a HEAD request on the favicon"}
|
||||
|
||||
{:error, %HTTPoison.Error{reason: reason}} ->
|
||||
{:error, reason}
|
||||
end
|
||||
end
|
||||
end
|
|
@ -0,0 +1,280 @@
|
|||
# Portions of this file are derived from Pleroma:
|
||||
# Pleroma: A lightweight social networking server
|
||||
# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
defmodule Mobilizon.Service.RichMedia.Parser do
|
||||
@options [
|
||||
max_body: 2_000_000,
|
||||
timeout: 10_000,
|
||||
recv_timeout: 20_000,
|
||||
follow_redirect: true
|
||||
]
|
||||
|
||||
alias Mobilizon.Config
|
||||
alias Mobilizon.Service.RichMedia.Favicon
|
||||
require Logger
|
||||
|
||||
defp parsers do
|
||||
Mobilizon.Config.get([:rich_media, :parsers])
|
||||
end
|
||||
|
||||
def parse(nil), do: {:error, "No URL provided"}
|
||||
|
||||
@spec parse(String.t()) :: {:ok, map()} | {:error, any()}
|
||||
def parse(url) do
|
||||
try do
|
||||
case Cachex.fetch(:rich_media_cache, url, fn _ ->
|
||||
case parse_url(url) do
|
||||
{:ok, data} -> {:commit, data}
|
||||
{:error, err} -> {:ignore, err}
|
||||
end
|
||||
end) do
|
||||
{status, value} when status in [:ok, :commit] ->
|
||||
{:ok, value}
|
||||
|
||||
{_, err} ->
|
||||
{:error, err}
|
||||
end
|
||||
rescue
|
||||
e ->
|
||||
{:error, "Cachex error: #{inspect(e)}"}
|
||||
end
|
||||
end
|
||||
|
||||
@spec parse_url(String.t(), List.t()) :: {:ok, map()} | {:error, any()}
|
||||
defp parse_url(url, options \\ []) do
|
||||
user_agent = Keyword.get(options, :user_agent, Config.instance_user_agent())
|
||||
headers = [{"User-Agent", user_agent}]
|
||||
|
||||
try do
|
||||
with {:ok, _} <- prevent_local_address(url),
|
||||
{:ok, %HTTPoison.Response{body: body, status_code: code, headers: response_headers}}
|
||||
when code in 200..299 <-
|
||||
HTTPoison.get(
|
||||
url,
|
||||
headers,
|
||||
@options
|
||||
),
|
||||
{:is_html, response_headers, true} <-
|
||||
{:is_html, response_headers, is_html(response_headers)} do
|
||||
body
|
||||
|> parse_html()
|
||||
|> maybe_parse()
|
||||
|> Map.put(:url, url)
|
||||
|> maybe_add_favicon()
|
||||
|> clean_parsed_data()
|
||||
|> check_parsed_data()
|
||||
|> check_remote_picture_path()
|
||||
else
|
||||
{:is_html, response_headers, false} ->
|
||||
data = get_data_for_media(response_headers, url)
|
||||
|
||||
{:ok, data}
|
||||
|
||||
{:error, err} ->
|
||||
{:error, "HTTP error: #{inspect(err)}"}
|
||||
end
|
||||
rescue
|
||||
e ->
|
||||
{:error, "Parsing error: #{inspect(e)} #{inspect(__STACKTRACE__)}"}
|
||||
end
|
||||
end
|
||||
|
||||
@spec get_data_for_media(List.t(), String.t()) :: map()
|
||||
defp get_data_for_media(response_headers, url) do
|
||||
data = %{title: get_filename_from_headers(response_headers) || get_filename_from_url(url)}
|
||||
|
||||
if is_image(response_headers) do
|
||||
Map.put(data, :image_remote_url, url)
|
||||
else
|
||||
data
|
||||
end
|
||||
end
|
||||
|
||||
@spec is_html(List.t()) :: boolean
|
||||
defp is_html(headers) do
|
||||
headers
|
||||
|> get_header("Content-Type")
|
||||
|> content_type_header_matches(["text/html", "application/xhtml"])
|
||||
end
|
||||
|
||||
@spec is_image(List.t()) :: boolean
|
||||
defp is_image(headers) do
|
||||
headers
|
||||
|> get_header("Content-Type")
|
||||
|> content_type_header_matches(["image/"])
|
||||
end
|
||||
|
||||
@spec is_media(List.t()) :: boolean
|
||||
defp is_media(headers) do
|
||||
headers
|
||||
|> get_header("Content-Type")
|
||||
|> content_type_header_matches(["image/", "video/"])
|
||||
end
|
||||
|
||||
@spec content_type_header_matches(String.t() | nil, List.t()) :: boolean
|
||||
defp content_type_header_matches(header, content_types \\ [])
|
||||
defp content_type_header_matches(nil, content_types), do: false
|
||||
|
||||
defp content_type_header_matches(header, content_types) when is_binary(header) do
|
||||
Enum.any?(content_types, fn content_type -> String.starts_with?(header, content_type) end)
|
||||
end
|
||||
|
||||
@spec get_header(List.t(), String.t()) :: String.t() | nil
|
||||
defp get_header(headers, key) do
|
||||
case List.keyfind(headers, key, 0) do
|
||||
{^key, value} -> String.downcase(value)
|
||||
nil -> nil
|
||||
end
|
||||
end
|
||||
|
||||
@spec get_filename_from_headers(List.t()) :: String.t() | nil
|
||||
defp get_filename_from_headers(headers) do
|
||||
case get_header(headers, "Content-Disposition") do
|
||||
nil -> nil
|
||||
content_disposition -> parse_content_disposition(content_disposition)
|
||||
end
|
||||
end
|
||||
|
||||
@spec get_filename_from_url(String.t()) :: String.t()
|
||||
defp get_filename_from_url(url) do
|
||||
%URI{path: path} = URI.parse(url)
|
||||
|
||||
path
|
||||
|> String.split("/", trim: true)
|
||||
|> Enum.at(-1)
|
||||
|> URI.decode()
|
||||
end
|
||||
|
||||
# The following is taken from https://github.com/elixir-plug/plug/blob/65986ad32f9aaae3be50dc80cbdd19b326578da7/lib/plug/parsers/multipart.ex#L207
|
||||
@spec parse_content_disposition(String.t()) :: String.t() | nil
|
||||
defp parse_content_disposition(disposition) do
|
||||
with [_, params] <- :binary.split(disposition, ";"),
|
||||
%{"name" => _name} = params <- Plug.Conn.Utils.params(params) do
|
||||
handle_disposition(params)
|
||||
else
|
||||
_ -> nil
|
||||
end
|
||||
end
|
||||
|
||||
@spec handle_disposition(map()) :: String.t() | nil
|
||||
defp handle_disposition(params) do
|
||||
case params do
|
||||
%{"filename" => ""} ->
|
||||
nil
|
||||
|
||||
%{"filename" => filename} ->
|
||||
filename
|
||||
|
||||
%{"filename*" => ""} ->
|
||||
nil
|
||||
|
||||
%{"filename*" => "utf-8''" <> filename} ->
|
||||
URI.decode(filename)
|
||||
|
||||
_ ->
|
||||
nil
|
||||
end
|
||||
end
|
||||
|
||||
defp parse_html(html), do: Floki.parse_document!(html)
|
||||
|
||||
defp maybe_parse(html) do
|
||||
Enum.reduce_while(parsers(), %{}, fn parser, acc ->
|
||||
require Logger
|
||||
Logger.error(inspect(parser))
|
||||
Logger.error(inspect(parser.parse(html, acc)))
|
||||
|
||||
case parser.parse(html, acc) do
|
||||
{:ok, data} -> {:halt, data}
|
||||
{:error, _msg} -> {:cont, acc}
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
defp check_parsed_data(%{title: title} = data)
|
||||
when is_binary(title) and byte_size(title) > 0 do
|
||||
{:ok, data}
|
||||
end
|
||||
|
||||
defp check_parsed_data(data) do
|
||||
{:error, "Found metadata was invalid or incomplete: #{inspect(data)}"}
|
||||
end
|
||||
|
||||
defp clean_parsed_data(data) do
|
||||
data
|
||||
|> Enum.reject(fn {key, val} ->
|
||||
with {:ok, _} <- Jason.encode(%{key => val}) do
|
||||
false
|
||||
else
|
||||
_ -> true
|
||||
end
|
||||
end)
|
||||
|> Map.new()
|
||||
end
|
||||
|
||||
defp prevent_local_address(url) do
|
||||
with %URI{host: host} when not is_nil(host) <- URI.parse(url) do
|
||||
host = String.downcase(host)
|
||||
|
||||
if validate_hostname_not_localhost(host) && validate_hostname_only(host) &&
|
||||
validate_ip(host) do
|
||||
{:ok, url}
|
||||
else
|
||||
{:error, "Host violates local access rules"}
|
||||
end
|
||||
else
|
||||
_ ->
|
||||
{:error, "Could not detect any host"}
|
||||
end
|
||||
end
|
||||
|
||||
defp validate_hostname_not_localhost(hostname),
|
||||
do:
|
||||
hostname != "localhost" && !String.ends_with?(hostname, ".local") &&
|
||||
!String.ends_with?(hostname, ".localhost")
|
||||
|
||||
defp validate_hostname_only(hostname),
|
||||
do: hostname |> String.graphemes() |> Enum.count(&(&1 == "o")) > 0
|
||||
|
||||
defp validate_ip(hostname) do
|
||||
with {:ok, address} <- hostname |> String.to_charlist() |> :inet.parse_address() do
|
||||
!IpReserved.is_reserved?(address)
|
||||
else
|
||||
# Not a valid IP
|
||||
{:error, _} -> true
|
||||
end
|
||||
end
|
||||
|
||||
@spec maybe_add_favicon(map()) :: map()
|
||||
defp maybe_add_favicon(%{url: url} = data) do
|
||||
with {:ok, favicon_url} <- Favicon.fetch(url) do
|
||||
Logger.debug("Adding favicon #{favicon_url} to metadata")
|
||||
Map.put(data, :favicon_url, favicon_url)
|
||||
else
|
||||
err ->
|
||||
Logger.debug("Failed to add favicon to metadata")
|
||||
Logger.debug(inspect(err))
|
||||
data
|
||||
end
|
||||
end
|
||||
|
||||
@spec check_remote_picture_path(map()) :: map()
|
||||
defp check_remote_picture_path(%{image_remote_url: image_remote_url, url: url} = data) do
|
||||
Logger.debug("Checking image_remote_url #{image_remote_url}")
|
||||
image_uri = URI.parse(image_remote_url)
|
||||
uri = URI.parse(url)
|
||||
|
||||
image_remote_url =
|
||||
cond do
|
||||
is_nil(image_uri.host) -> "#{uri.scheme}://#{uri.host}#{image_remote_url}"
|
||||
is_nil(image_uri.scheme) -> "#{uri.scheme}:#{image_remote_url}"
|
||||
true -> image_remote_url
|
||||
end
|
||||
|
||||
Map.put(data, :image_remote_url, image_remote_url)
|
||||
end
|
||||
|
||||
defp check_remote_picture_path(data), do: data
|
||||
end
|
|
@ -0,0 +1,38 @@
|
|||
# Portions of this file are derived from Pleroma:
|
||||
# Pleroma: A lightweight social networking server
|
||||
# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
defmodule Mobilizon.Service.RichMedia.Parsers.Fallback do
|
||||
@spec parse(String.t(), map()) :: {:ok, map()} | {:error, String.t()}
|
||||
def parse(html, data) do
|
||||
data =
|
||||
data
|
||||
|> maybe_put(html, :title)
|
||||
|> maybe_put(html, :description)
|
||||
|
||||
if Enum.empty?(data) do
|
||||
{:error, "Not even a title"}
|
||||
else
|
||||
{:ok, data}
|
||||
end
|
||||
end
|
||||
|
||||
defp maybe_put(meta, html, attr) do
|
||||
case get_page(html, attr) do
|
||||
"" -> meta
|
||||
content -> Map.put_new(meta, attr, content)
|
||||
end
|
||||
end
|
||||
|
||||
defp get_page(html, :title) do
|
||||
Floki.find(html, "html head title") |> List.first() |> Floki.text() |> String.trim()
|
||||
end
|
||||
|
||||
defp get_page(html, :description) do
|
||||
case Floki.find(html, "html head meta[name='description']") |> List.first() do
|
||||
nil -> ""
|
||||
elem -> Floki.attribute(elem, "content") |> List.first() |> String.trim()
|
||||
end
|
||||
end
|
||||
end
|
|
@ -0,0 +1,73 @@
|
|||
# Portions of this file are derived from Pleroma:
|
||||
# Pleroma: A lightweight social networking server
|
||||
# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
defmodule Mobilizon.Service.RichMedia.Parsers.MetaTagsParser do
|
||||
def parse(html, data, prefix, error_message, key_name, value_name \\ "content") do
|
||||
meta_data =
|
||||
html
|
||||
|> get_elements(key_name, prefix)
|
||||
|> Enum.reduce(data, fn el, acc ->
|
||||
attributes = normalize_attributes(el, prefix, key_name, value_name)
|
||||
|
||||
Map.merge(acc, attributes)
|
||||
end)
|
||||
|> maybe_put_title(html)
|
||||
|> maybe_put_description(html)
|
||||
|
||||
if Enum.empty?(meta_data) do
|
||||
{:error, error_message}
|
||||
else
|
||||
{:ok, meta_data}
|
||||
end
|
||||
end
|
||||
|
||||
defp get_elements(html, key_name, prefix) do
|
||||
html |> Floki.find("meta[#{key_name}^='#{prefix}:']")
|
||||
end
|
||||
|
||||
defp normalize_attributes(html_node, prefix, key_name, value_name) do
|
||||
{_tag, attributes, _children} = html_node
|
||||
|
||||
data =
|
||||
Enum.into(attributes, %{}, fn {name, value} ->
|
||||
{name, String.trim_leading(value, "#{prefix}:")}
|
||||
end)
|
||||
|
||||
%{String.to_atom(data[key_name]) => data[value_name]}
|
||||
end
|
||||
|
||||
defp maybe_put_title(%{title: _} = meta, _), do: meta
|
||||
|
||||
defp maybe_put_title(meta, html) when meta != %{} do
|
||||
case get_page_title(html) do
|
||||
"" -> meta
|
||||
title -> Map.put_new(meta, :title, title)
|
||||
end
|
||||
end
|
||||
|
||||
defp maybe_put_title(meta, _), do: meta
|
||||
|
||||
defp maybe_put_description(%{description: _} = meta, _), do: meta
|
||||
|
||||
defp maybe_put_description(meta, html) when meta != %{} do
|
||||
case get_page_description(html) do
|
||||
"" -> meta
|
||||
description -> Map.put_new(meta, :description, description)
|
||||
end
|
||||
end
|
||||
|
||||
defp maybe_put_description(meta, _), do: meta
|
||||
|
||||
defp get_page_title(html) do
|
||||
Floki.find(html, "html head title") |> List.first() |> Floki.text()
|
||||
end
|
||||
|
||||
defp get_page_description(html) do
|
||||
case Floki.find(html, "html head meta[name='description']") |> List.first() do
|
||||
nil -> ""
|
||||
elem -> Floki.attribute(elem, "content")
|
||||
end
|
||||
end
|
||||
end
|
|
@ -0,0 +1,75 @@
|
|||
# Portions of this file are derived from Pleroma:
|
||||
# Pleroma: A lightweight social networking server
|
||||
# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
defmodule Mobilizon.Service.RichMedia.Parsers.OEmbed do
|
||||
alias Mobilizon.Service.Formatter.HTML
|
||||
require Logger
|
||||
|
||||
def parse(html, _data) do
|
||||
Logger.debug("Using OEmbed parser")
|
||||
|
||||
with elements = [_ | _] <- get_discovery_data(html),
|
||||
{:ok, oembed_url} <- get_oembed_url(elements),
|
||||
{:ok, oembed_data} <- get_oembed_data(oembed_url),
|
||||
oembed_data <- filter_oembed_data(oembed_data) do
|
||||
Logger.debug("Data found with OEmbed parser")
|
||||
Logger.debug(inspect(oembed_data))
|
||||
{:ok, oembed_data}
|
||||
else
|
||||
_e ->
|
||||
{:error, "No OEmbed data found"}
|
||||
end
|
||||
end
|
||||
|
||||
defp get_discovery_data(html) do
|
||||
html |> Floki.find("link[type='application/json+oembed']")
|
||||
end
|
||||
|
||||
defp get_oembed_url(nodes) do
|
||||
{"link", attributes, _children} = nodes |> hd()
|
||||
|
||||
{:ok, Enum.into(attributes, %{})["href"]}
|
||||
end
|
||||
|
||||
defp get_oembed_data(url) do
|
||||
with {:ok, %HTTPoison.Response{body: json}} <- HTTPoison.get(url),
|
||||
{:ok, data} <- Jason.decode(json),
|
||||
data <- data |> Map.new(fn {k, v} -> {String.to_atom(k), v} end) do
|
||||
{:ok, data}
|
||||
end
|
||||
end
|
||||
|
||||
defp filter_oembed_data(data) do
|
||||
case Map.get(data, :type) do
|
||||
nil ->
|
||||
{:error, "No type declared for OEmbed data"}
|
||||
|
||||
"link" ->
|
||||
Map.put(data, :image_remote_url, Map.get(data, :thumbnail_url))
|
||||
|
||||
"photo" ->
|
||||
if Map.get(data, :url, "") == "" do
|
||||
{:error, "No URL for photo OEmbed data"}
|
||||
else
|
||||
data
|
||||
|> Map.put(:image_remote_url, Map.get(data, :url))
|
||||
|> Map.put(:width, Map.get(data, :width, 0))
|
||||
|> Map.put(:height, Map.get(data, :height, 0))
|
||||
end
|
||||
|
||||
"video" ->
|
||||
{:ok, html} = data |> Map.get(:html, "") |> HTML.filter_tags_for_oembed()
|
||||
|
||||
data
|
||||
|> Map.put(:html, html)
|
||||
|> Map.put(:width, Map.get(data, :width, 0))
|
||||
|> Map.put(:height, Map.get(data, :height, 0))
|
||||
|> Map.put(:image_remote_url, Map.get(data, :thumbnail_url))
|
||||
|
||||
"rich" ->
|
||||
{:error, "OEmbed data has rich type, which we don't support"}
|
||||
end
|
||||
end
|
||||
end
|
|
@ -0,0 +1,33 @@
|
|||
# Portions of this file are derived from Pleroma:
|
||||
# Pleroma: A lightweight social networking server
|
||||
# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
defmodule Mobilizon.Service.RichMedia.Parsers.OGP do
|
||||
require Logger
|
||||
|
||||
def parse(html, data) do
|
||||
Logger.debug("Using OpenGraph card parser")
|
||||
|
||||
with {:ok, data} <-
|
||||
Mobilizon.Service.RichMedia.Parsers.MetaTagsParser.parse(
|
||||
html,
|
||||
data,
|
||||
"og",
|
||||
"No OGP metadata found",
|
||||
"property"
|
||||
) do
|
||||
data = transform_tags(data)
|
||||
Logger.debug("Data found with OpenGraph card parser")
|
||||
Logger.debug(inspect(data))
|
||||
{:ok, data}
|
||||
end
|
||||
end
|
||||
|
||||
defp transform_tags(data) do
|
||||
data
|
||||
|> Map.put(:image_remote_url, Map.get(data, :image))
|
||||
|> Map.put(:width, Map.get(data, :"image:width"))
|
||||
|> Map.put(:height, Map.get(data, :"image:height"))
|
||||
end
|
||||
end
|
|
@ -0,0 +1,31 @@
|
|||
# Portions of this file are derived from Pleroma:
|
||||
# Pleroma: A lightweight social networking server
|
||||
# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
defmodule Mobilizon.Service.RichMedia.Parsers.TwitterCard do
|
||||
alias Mobilizon.Service.RichMedia.Parsers.MetaTagsParser
|
||||
require Logger
|
||||
|
||||
@spec parse(String.t(), map()) :: {:ok, map()} | {:error, String.t()}
|
||||
def parse(html, data) do
|
||||
Logger.debug("Using Twitter card parser")
|
||||
|
||||
res =
|
||||
data
|
||||
|> parse_name_attrs(html)
|
||||
|> parse_property_attrs(html)
|
||||
|
||||
Logger.debug("Data found with Twitter card parser")
|
||||
Logger.debug(inspect(res))
|
||||
res
|
||||
end
|
||||
|
||||
defp parse_name_attrs(data, html) do
|
||||
MetaTagsParser.parse(html, data, "twitter", %{}, "name")
|
||||
end
|
||||
|
||||
defp parse_property_attrs({_, data}, html) do
|
||||
MetaTagsParser.parse(html, data, "twitter", "No twitter card metadata found", "property")
|
||||
end
|
||||
end
|
|
@ -3,12 +3,12 @@ defmodule Mobilizon.Web.Cache.ActivityPub do
|
|||
ActivityPub related cache.
|
||||
"""
|
||||
|
||||
alias Mobilizon.{Actors, Collections, Conversations, Events, Todos, Tombstone}
|
||||
alias Mobilizon.{Actors, Conversations, Events, Resources, Todos, Tombstone}
|
||||
alias Mobilizon.Actors.Actor
|
||||
alias Mobilizon.Collections.{Collection, Resource}
|
||||
alias Mobilizon.Conversations.Comment
|
||||
alias Mobilizon.Events.Event
|
||||
alias Mobilizon.Federation.ActivityPub.Relay
|
||||
alias Mobilizon.Resources.Resource
|
||||
alias Mobilizon.Todos.{Todo, TodoList}
|
||||
alias Mobilizon.Web.Endpoint
|
||||
alias Mobilizon.Web.Router.Helpers, as: Routes
|
||||
|
@ -71,23 +71,6 @@ defmodule Mobilizon.Web.Cache.ActivityPub do
|
|||
end)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets a collection by its UUID, with all associations loaded.
|
||||
"""
|
||||
@spec get_collection_by_uuid_with_preload(String.t()) ::
|
||||
{:commit, Collection.t()} | {:ignore, nil}
|
||||
def get_collection_by_uuid_with_preload(uuid) do
|
||||
Cachex.fetch(@cache, "collection_" <> uuid, fn "collection_" <> uuid ->
|
||||
case Collections.get_collection(uuid) do
|
||||
%Collection{} = collection ->
|
||||
{:commit, collection}
|
||||
|
||||
nil ->
|
||||
{:ignore, nil}
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets a resource by its UUID, with all associations loaded.
|
||||
"""
|
||||
|
@ -95,7 +78,7 @@ defmodule Mobilizon.Web.Cache.ActivityPub do
|
|||
{:commit, Resource.t()} | {:ignore, nil}
|
||||
def get_resource_by_uuid_with_preload(uuid) do
|
||||
Cachex.fetch(@cache, "resource_" <> uuid, fn "resource_" <> uuid ->
|
||||
case Collections.get_resource(uuid) do
|
||||
case Resources.get_resource(uuid) do
|
||||
%Resource{} = resource ->
|
||||
{:commit, resource}
|
||||
|
||||
|
|
|
@ -20,7 +20,6 @@ defmodule Mobilizon.Web.Cache do
|
|||
defdelegate get_local_actor_by_name(name), to: ActivityPub
|
||||
defdelegate get_public_event_by_uuid_with_preload(uuid), to: ActivityPub
|
||||
defdelegate get_comment_by_uuid_with_preload(uuid), to: ActivityPub
|
||||
defdelegate get_collection_by_uuid_with_preload(uuid), to: ActivityPub
|
||||
defdelegate get_resource_by_uuid_with_preload(uuid), to: ActivityPub
|
||||
defdelegate get_todo_list_by_uuid_with_preload(uuid), to: ActivityPub
|
||||
defdelegate get_todo_by_uuid_with_preload(uuid), to: ActivityPub
|
||||
|
|
|
@ -13,6 +13,7 @@ defmodule Mobilizon.Web.PageController do
|
|||
plug(:put_layout, false)
|
||||
action_fallback(Mobilizon.Web.FallbackController)
|
||||
|
||||
@spec index(Plug.Conn.t(), any) :: Plug.Conn.t()
|
||||
def index(conn, _params), do: render(conn, :index)
|
||||
|
||||
def actor(conn, %{"name" => name}) do
|
||||
|
@ -30,26 +31,23 @@ defmodule Mobilizon.Web.PageController do
|
|||
render_or_error(conn, &checks?/3, status, :comment, comment)
|
||||
end
|
||||
|
||||
def collection(conn, %{"uuid" => uuid}) do
|
||||
{status, comment} = Cache.get_collection_by_uuid_with_preload(uuid)
|
||||
render_or_error(conn, &checks?/3, status, :collection, comment)
|
||||
end
|
||||
|
||||
@spec resource(Plug.Conn.t(), map()) :: Plug.Conn.t() | {:error, :not_found}
|
||||
def resource(conn, %{"uuid" => uuid}) do
|
||||
{status, comment} = Cache.get_resource_by_uuid_with_preload(uuid)
|
||||
render_or_error(conn, &checks?/3, status, :resource, comment)
|
||||
{status, resource} = Cache.get_resource_by_uuid_with_preload(uuid)
|
||||
render_or_error(conn, &checks?/3, status, :resource, resource)
|
||||
end
|
||||
|
||||
def todo_list(conn, %{"uuid" => uuid}) do
|
||||
{status, comment} = Cache.get_todo_list_by_uuid_with_preload(uuid)
|
||||
render_or_error(conn, &checks?/3, status, :todo_list, comment)
|
||||
{status, todo_list} = Cache.get_todo_list_by_uuid_with_preload(uuid)
|
||||
render_or_error(conn, &checks?/3, status, :todo_list, todo_list)
|
||||
end
|
||||
|
||||
def todo(conn, %{"uuid" => uuid}) do
|
||||
{status, comment} = Cache.get_todo_by_uuid_with_preload(uuid)
|
||||
render_or_error(conn, &checks?/3, status, :todo, comment)
|
||||
{status, todo} = Cache.get_todo_by_uuid_with_preload(uuid)
|
||||
render_or_error(conn, &checks?/3, status, :todo, todo)
|
||||
end
|
||||
|
||||
@spec interact(Plug.Conn.t(), map()) :: Plug.Conn.t() | {:error, :not_found}
|
||||
def interact(conn, %{"uri" => uri}) do
|
||||
case ActivityPub.fetch_object_from_url(uri) do
|
||||
{:ok, %Event{uuid: uuid}} -> redirect(conn, to: "/events/#{uuid}")
|
||||
|
|
|
@ -75,7 +75,6 @@ defmodule Mobilizon.Web.Router do
|
|||
get("/events/:uuid", PageController, :event)
|
||||
get("/comments/:uuid", PageController, :comment)
|
||||
get("/resource/:uuid", PageController, :resource, as: "resource")
|
||||
get("/collection/:uuid", PageController, :collection, as: "collection")
|
||||
get("/todo-list/:uuid", PageController, :todo_list, as: "todo_list")
|
||||
get("/todo/:uuid", PageController, :todo, as: "todo")
|
||||
end
|
||||
|
|
3
mix.exs
3
mix.exs
|
@ -102,6 +102,9 @@ defmodule Mobilizon.Mixfile do
|
|||
{:ex_optimizer, "~> 0.1"},
|
||||
{:progress_bar, "~> 2.0"},
|
||||
{:oban, "~> 1.2.0"},
|
||||
{:floki, "~> 0.26.0"},
|
||||
{:ip_reserved, "~> 0.1.0"},
|
||||
{:fast_sanitize, "~> 0.1"},
|
||||
# Dev and test dependencies
|
||||
{:phoenix_live_reload, "~> 1.2", only: [:dev, :e2e]},
|
||||
{:ex_machina, "~> 2.3", only: [:dev, :test]},
|
||||
|
|
224
mix.lock
224
mix.lock
|
@ -1,115 +1,121 @@
|
|||
%{
|
||||
"absinthe": {:hex, :absinthe, "1.4.16", "0933e4d9f12652b12115d5709c0293a1bf78a22578032e9ad0dad4efee6b9eb1", [:mix], [{:dataloader, "~> 1.0.0", [hex: :dataloader, repo: "hexpm", optional: true]}, {:decimal, "~> 1.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm"},
|
||||
"absinthe_ecto": {:hex, :absinthe_ecto, "0.1.3", "420b68129e79fe4571a4838904ba03e282330d335da47729ad52ffd7b8c5fcb1", [:mix], [{:absinthe, "~> 1.3.0 or ~> 1.4.0", [hex: :absinthe, repo: "hexpm", optional: false]}, {:ecto, ">= 0.0.0", [hex: :ecto, repo: "hexpm", optional: false]}], "hexpm"},
|
||||
"absinthe_phoenix": {:hex, :absinthe_phoenix, "1.4.4", "af3b7b44483121f756ea0ec75a536b74f67cdd62ec6a34b9e58df1fb4662389e", [:mix], [{:absinthe, "~> 1.4.0", [hex: :absinthe, repo: "hexpm", optional: false]}, {:absinthe_plug, "~> 1.4.0", [hex: :absinthe_plug, repo: "hexpm", optional: false]}, {:decimal, "~> 1.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.4", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.13", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 1.0", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}], "hexpm"},
|
||||
"absinthe_plug": {:hex, :absinthe_plug, "1.4.7", "939b6b9e1c7abc6b399a5b49faa690a1fbb55b195c670aa35783b14b08ccec7a", [:mix], [{:absinthe, "~> 1.4.11", [hex: :absinthe, repo: "hexpm", optional: false]}, {:plug, "~> 1.3.2 or ~> 1.4", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm"},
|
||||
"argon2_elixir": {:hex, :argon2_elixir, "2.3.0", "e251bdafd69308e8c1263e111600e6d68bd44f23d2cccbe43fcb1a417a76bc8e", [:make, :mix], [{:comeonin, "~> 5.3", [hex: :comeonin, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm"},
|
||||
"atomex": {:hex, :atomex, "0.3.0", "19b5d1a2aef8706dbd307385f7d5d9f6f273869226d317492c396c7bacf26402", [:mix], [{:xml_builder, "~> 2.0.0", [hex: :xml_builder, repo: "hexpm", optional: false]}], "hexpm"},
|
||||
"absinthe": {:hex, :absinthe, "1.4.16", "0933e4d9f12652b12115d5709c0293a1bf78a22578032e9ad0dad4efee6b9eb1", [:mix], [{:dataloader, "~> 1.0.0", [hex: :dataloader, repo: "hexpm", optional: true]}, {:decimal, "~> 1.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "076b8bd9552f4966ba1242f412f6c439b731169a36a0ddaaffcd3893828f5bf6"},
|
||||
"absinthe_ecto": {:hex, :absinthe_ecto, "0.1.3", "420b68129e79fe4571a4838904ba03e282330d335da47729ad52ffd7b8c5fcb1", [:mix], [{:absinthe, "~> 1.3.0 or ~> 1.4.0", [hex: :absinthe, repo: "hexpm", optional: false]}, {:ecto, ">= 0.0.0", [hex: :ecto, repo: "hexpm", optional: false]}], "hexpm", "355b9db34abfab96ae1e025434b66e11002babcf4fe6b7144d26ff7548985f52"},
|
||||
"absinthe_phoenix": {:hex, :absinthe_phoenix, "1.4.4", "af3b7b44483121f756ea0ec75a536b74f67cdd62ec6a34b9e58df1fb4662389e", [:mix], [{:absinthe, "~> 1.4.0", [hex: :absinthe, repo: "hexpm", optional: false]}, {:absinthe_plug, "~> 1.4.0", [hex: :absinthe_plug, repo: "hexpm", optional: false]}, {:decimal, "~> 1.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.4", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.13", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 1.0", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}], "hexpm", "54118c32ca00257b3cd3e616b3f9cee99e493d2399528334cbb5457e470400d3"},
|
||||
"absinthe_plug": {:hex, :absinthe_plug, "1.4.7", "939b6b9e1c7abc6b399a5b49faa690a1fbb55b195c670aa35783b14b08ccec7a", [:mix], [{:absinthe, "~> 1.4.11", [hex: :absinthe, repo: "hexpm", optional: false]}, {:plug, "~> 1.3.2 or ~> 1.4", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "c6ecb0e56a963287ac252d0563e5b33b84b300ce8203d3d1410dddb5dc6d08e9"},
|
||||
"argon2_elixir": {:hex, :argon2_elixir, "2.3.0", "e251bdafd69308e8c1263e111600e6d68bd44f23d2cccbe43fcb1a417a76bc8e", [:make, :mix], [{:comeonin, "~> 5.3", [hex: :comeonin, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "28ccb63bff213aecec1f7f3dde9648418b031f822499973281d8f494b9d5a3b3"},
|
||||
"atomex": {:hex, :atomex, "0.3.0", "19b5d1a2aef8706dbd307385f7d5d9f6f273869226d317492c396c7bacf26402", [:mix], [{:xml_builder, "~> 2.0.0", [hex: :xml_builder, repo: "hexpm", optional: false]}], "hexpm", "025dbc3a3e99380894791a093019f535d0ef6cf1916f6ec1b778ac107fcfc3e4"},
|
||||
"auto_linker": {:git, "https://git.pleroma.social/pleroma/auto_linker.git", "95e8188490e97505c56636c1379ffdf036c1fdde", [ref: "95e8188490e97505c56636c1379ffdf036c1fdde"]},
|
||||
"bamboo": {:hex, :bamboo, "1.4.0", "7b9201c49a843e4802061cf45692405b2c00efcf1cebf8b7b64f015ead072392", [:mix], [{:hackney, ">= 1.13.0", [hex: :hackney, repo: "hexpm", optional: false]}, {:jason, "~> 1.1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm"},
|
||||
"bamboo_smtp": {:hex, :bamboo_smtp, "2.1.0", "4be58f3c51d9f7875dc169ae58a1d2f08e5b718bf3895f70d130548c0598f422", [:mix], [{:bamboo, "~> 1.2", [hex: :bamboo, repo: "hexpm", optional: false]}, {:gen_smtp, "~> 0.15.0", [hex: :gen_smtp, repo: "hexpm", optional: false]}], "hexpm"},
|
||||
"bamboo": {:hex, :bamboo, "1.4.0", "7b9201c49a843e4802061cf45692405b2c00efcf1cebf8b7b64f015ead072392", [:mix], [{:hackney, ">= 1.13.0", [hex: :hackney, repo: "hexpm", optional: false]}, {:jason, "~> 1.1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "b9cad03bf38c7f37b6308876039355665b6ce09fefb46dc529cef4def912cffa"},
|
||||
"bamboo_smtp": {:hex, :bamboo_smtp, "2.1.0", "4be58f3c51d9f7875dc169ae58a1d2f08e5b718bf3895f70d130548c0598f422", [:mix], [{:bamboo, "~> 1.2", [hex: :bamboo, repo: "hexpm", optional: false]}, {:gen_smtp, "~> 0.15.0", [hex: :gen_smtp, repo: "hexpm", optional: false]}], "hexpm", "0aad00ef93d0e0c83a0e1ca6998fea070c8a720a990fbda13ce834136215ee49"},
|
||||
"base64url": {:hex, :base64url, "0.0.1", "36a90125f5948e3afd7be97662a1504b934dd5dac78451ca6e9abf85a10286be", [:rebar], [], "hexpm"},
|
||||
"bunt": {:hex, :bunt, "0.2.0", "951c6e801e8b1d2cbe58ebbd3e616a869061ddadcc4863d0a2182541acae9a38", [:mix], [], "hexpm"},
|
||||
"cachex": {:hex, :cachex, "3.2.0", "a596476c781b0646e6cb5cd9751af2e2974c3e0d5498a8cab71807618b74fe2f", [:mix], [{:eternal, "~> 1.2", [hex: :eternal, repo: "hexpm", optional: false]}, {:jumper, "~> 1.0", [hex: :jumper, repo: "hexpm", optional: false]}, {:sleeplocks, "~> 1.1", [hex: :sleeplocks, repo: "hexpm", optional: false]}, {:unsafe, "~> 1.0", [hex: :unsafe, repo: "hexpm", optional: false]}], "hexpm"},
|
||||
"certifi": {:hex, :certifi, "2.5.1", "867ce347f7c7d78563450a18a6a28a8090331e77fa02380b4a21962a65d36ee5", [:rebar3], [{:parse_trans, "~>3.3", [hex: :parse_trans, repo: "hexpm", optional: false]}], "hexpm"},
|
||||
"cldr_utils": {:hex, :cldr_utils, "2.8.0", "a1355a658fdf7118a678002a5333562d464e1bbdc3c89a5e0c3d8088038e57f5", [:mix], [{:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}], "hexpm"},
|
||||
"combine": {:hex, :combine, "0.10.0", "eff8224eeb56498a2af13011d142c5e7997a80c8f5b97c499f84c841032e429f", [:mix], [], "hexpm"},
|
||||
"comeonin": {:hex, :comeonin, "5.3.1", "7fe612b739c78c9c1a75186ef2d322ce4d25032d119823269d0aa1e2f1e20025", [:mix], [], "hexpm"},
|
||||
"connection": {:hex, :connection, "1.0.4", "a1cae72211f0eef17705aaededacac3eb30e6625b04a6117c1b2db6ace7d5976", [:mix], [], "hexpm"},
|
||||
"cors_plug": {:hex, :cors_plug, "2.0.2", "2b46083af45e4bc79632bd951550509395935d3e7973275b2b743bd63cc942ce", [:mix], [{:plug, "~> 1.8", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm"},
|
||||
"cowboy": {:hex, :cowboy, "2.7.0", "91ed100138a764355f43316b1d23d7ff6bdb0de4ea618cb5d8677c93a7a2f115", [:rebar3], [{:cowlib, "~> 2.8.0", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, "~> 1.7.1", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm"},
|
||||
"cowlib": {:hex, :cowlib, "2.8.0", "fd0ff1787db84ac415b8211573e9a30a3ebe71b5cbff7f720089972b2319c8a4", [:rebar3], [], "hexpm"},
|
||||
"credo": {:hex, :credo, "1.3.0", "37699fefdbe1b0480a5a6b73f259207e9cd7ad5e492277e22c2179bcb226a67b", [:mix], [{:bunt, "~> 0.2.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm"},
|
||||
"dataloader": {:hex, :dataloader, "1.0.7", "58351b335673cf40601429bfed6c11fece6ce7ad169b2ac0f0fe83e716587391", [:mix], [{:ecto, ">= 0.0.0", [hex: :ecto, repo: "hexpm", optional: true]}], "hexpm"},
|
||||
"db_connection": {:hex, :db_connection, "2.2.1", "caee17725495f5129cb7faebde001dc4406796f12a62b8949f4ac69315080566", [:mix], [{:connection, "~> 1.0.2", [hex: :connection, repo: "hexpm", optional: false]}], "hexpm"},
|
||||
"decimal": {:hex, :decimal, "1.8.1", "a4ef3f5f3428bdbc0d35374029ffcf4ede8533536fa79896dd450168d9acdf3c", [:mix], [], "hexpm"},
|
||||
"dialyxir": {:hex, :dialyxir, "1.0.0-rc.7", "6287f8f2cb45df8584317a4be1075b8c9b8a69de8eeb82b4d9e6c761cf2664cd", [:mix], [{:erlex, ">= 0.2.5", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm"},
|
||||
"earmark": {:hex, :earmark, "1.4.3", "364ca2e9710f6bff494117dbbd53880d84bebb692dafc3a78eb50aa3183f2bfd", [:mix], [], "hexpm"},
|
||||
"ecto": {:hex, :ecto, "3.3.4", "95b05c82ae91361475e5491c9f3ac47632f940b3f92ae3988ac1aad04989c5bb", [:mix], [{:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm"},
|
||||
"ecto_autoslug_field": {:hex, :ecto_autoslug_field, "2.0.1", "2177c1c253f6dd3efd4b56d1cb76104d0a6ef044c6b9a7a0ad6d32665c4111e5", [:mix], [{:ecto, ">= 2.1.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:slugger, ">= 0.2.0", [hex: :slugger, repo: "hexpm", optional: false]}], "hexpm"},
|
||||
"ecto_enum": {:hex, :ecto_enum, "1.4.0", "d14b00e04b974afc69c251632d1e49594d899067ee2b376277efd8233027aec8", [:mix], [{:ecto, ">= 3.0.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "> 3.0.0", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:mariaex, ">= 0.0.0", [hex: :mariaex, repo: "hexpm", optional: true]}, {:postgrex, ">= 0.0.0", [hex: :postgrex, repo: "hexpm", optional: true]}], "hexpm"},
|
||||
"ecto_sql": {:hex, :ecto_sql, "3.3.4", "aa18af12eb875fbcda2f75e608b3bd534ebf020fc4f6448e4672fcdcbb081244", [:mix], [{:db_connection, "~> 2.2", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.4 or ~> 3.3.3", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.3.0", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.15.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm"},
|
||||
"elixir_feed_parser": {:hex, :elixir_feed_parser, "2.1.0", "bb96fb6422158dc7ad59de62ef211cc69d264acbbe63941a64a5dce97bbbc2e6", [:mix], [{:timex, "~> 3.4", [hex: :timex, repo: "hexpm", optional: false]}], "hexpm"},
|
||||
"elixir_make": {:hex, :elixir_make, "0.6.0", "38349f3e29aff4864352084fc736fa7fa0f2995a819a737554f7ebd28b85aaab", [:mix], [], "hexpm"},
|
||||
"erlex": {:hex, :erlex, "0.2.6", "c7987d15e899c7a2f34f5420d2a2ea0d659682c06ac607572df55a43753aa12e", [:mix], [], "hexpm"},
|
||||
"eternal": {:hex, :eternal, "1.2.1", "d5b6b2499ba876c57be2581b5b999ee9bdf861c647401066d3eeed111d096bc4", [:mix], [], "hexpm"},
|
||||
"ex_cldr": {:hex, :ex_cldr, "2.13.0", "742f14a4afcfea61a190d603d8e555d2c91d71e4e8fc2520d5dc35616969e225", [:mix], [{:cldr_utils, "~> 2.3", [hex: :cldr_utils, repo: "hexpm", optional: false]}, {:decimal, "~> 1.6", [hex: :decimal, repo: "hexpm", optional: false]}, {:gettext, "~> 0.13", [hex: :gettext, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:plug, "~> 1.4", [hex: :plug, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0", [hex: :telemetry, repo: "hexpm", optional: true]}], "hexpm"},
|
||||
"ex_cldr_calendars": {:hex, :ex_cldr_calendars, "1.7.1", "d2490358148c09b8915a20d959390ce10ea13cf6e7cfb1df823afbbc2ec71699", [:mix], [{:ex_cldr, "~> 2.12", [hex: :ex_cldr, repo: "hexpm", optional: false]}, {:ex_cldr_lists, "~> 2.4", [hex: :ex_cldr_lists, repo: "hexpm", optional: true]}, {:ex_cldr_units, "~> 2.0", [hex: :ex_cldr_units, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm"},
|
||||
"ex_cldr_currencies": {:hex, :ex_cldr_currencies, "2.4.1", "a8e8330a6d0712b8bb34c5e3759311da1d53fa8bebef163d72615f0ea60c0738", [:mix], [{:ex_cldr, "~> 2.6", [hex: :ex_cldr, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm"},
|
||||
"ex_cldr_dates_times": {:hex, :ex_cldr_dates_times, "2.3.0", "5c9fe5b695ac3adf30e3e5640a1f9b0e316f5773409d30e605a7edfc86c55921", [:mix], [{:ex_cldr, "~> 2.8", [hex: :ex_cldr, repo: "hexpm", optional: false]}, {:ex_cldr_calendars, "~> 1.2", [hex: :ex_cldr_calendars, repo: "hexpm", optional: false]}, {:ex_cldr_numbers, "~> 2.6", [hex: :ex_cldr_numbers, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm"},
|
||||
"ex_cldr_numbers": {:hex, :ex_cldr_numbers, "2.12.1", "2548f2a3ef6812c6600962795fb27f8725fec1afb790486f07dacc7eda9bb4b8", [:mix], [{:cldr_utils, "~> 2.6", [hex: :cldr_utils, repo: "hexpm", optional: false]}, {:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:ex_cldr, "~> 2.13", [hex: :ex_cldr, repo: "hexpm", optional: false]}, {:ex_cldr_currencies, "~> 2.3", [hex: :ex_cldr_currencies, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm"},
|
||||
"ex_crypto": {:hex, :ex_crypto, "0.10.0", "af600a89b784b36613a989da6e998c1b200ff1214c3cfbaf8deca4aa2f0a1739", [:mix], [{:poison, ">= 2.0.0", [hex: :poison, repo: "hexpm", optional: false]}], "hexpm"},
|
||||
"ex_doc": {:hex, :ex_doc, "0.21.3", "857ec876b35a587c5d9148a2512e952e24c24345552259464b98bfbb883c7b42", [:mix], [{:earmark, "~> 1.4", [hex: :earmark, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.14", [hex: :makeup_elixir, repo: "hexpm", optional: false]}], "hexpm"},
|
||||
"ex_ical": {:hex, :ex_ical, "0.2.0", "4b928b554614704016cc0c9ee226eb854da9327a1cc460457621ceacb1ac29a6", [:mix], [{:timex, "~> 3.1", [hex: :timex, repo: "hexpm", optional: false]}], "hexpm"},
|
||||
"ex_machina": {:hex, :ex_machina, "2.4.0", "09a34c5d371bfb5f78399029194a8ff67aff340ebe8ba19040181af35315eabb", [:mix], [{:ecto, "~> 2.2 or ~> 3.0", [hex: :ecto, repo: "hexpm", optional: true]}, {:ecto_sql, "~> 3.0", [hex: :ecto_sql, repo: "hexpm", optional: true]}], "hexpm"},
|
||||
"ex_optimizer": {:hex, :ex_optimizer, "0.1.0", "1d12f7ea289092a38a794b84bd2f42c1e0621cb307c0f3e6a7df620839af2937", [:mix], [{:file_info, "~> 0.0.4", [hex: :file_info, repo: "hexpm", optional: false]}], "hexpm"},
|
||||
"ex_unit_notifier": {:hex, :ex_unit_notifier, "0.1.4", "36a2dcab829f506e01bf17816590680dd1474407926d43e64c1263e627c364b8", [:mix], [], "hexpm"},
|
||||
"exactor": {:hex, :exactor, "2.2.4", "5efb4ddeb2c48d9a1d7c9b465a6fffdd82300eb9618ece5d34c3334d5d7245b1", [:mix], [], "hexpm"},
|
||||
"excoveralls": {:hex, :excoveralls, "0.12.2", "a513defac45c59e310ac42fcf2b8ae96f1f85746410f30b1ff2b710a4b6cd44b", [:mix], [{:hackney, "~> 1.0", [hex: :hackney, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm"},
|
||||
"exgravatar": {:hex, :exgravatar, "2.0.2", "638412896170409da114f98947d3f8d4f38e851b0e329c1cc4cd324d5e2ea081", [:mix], [], "hexpm"},
|
||||
"exjsx": {:hex, :exjsx, "4.0.0", "60548841e0212df401e38e63c0078ec57b33e7ea49b032c796ccad8cde794b5c", [:mix], [{:jsx, "~> 2.8.0", [hex: :jsx, repo: "hexpm", optional: false]}], "hexpm"},
|
||||
"exvcr": {:hex, :exvcr, "0.11.1", "a5e5f57a67538e032e16cfea6cfb1232314fb146e3ceedf1cde4a11f12fb7a58", [:mix], [{:exactor, "~> 2.2", [hex: :exactor, repo: "hexpm", optional: false]}, {:exjsx, "~> 4.0", [hex: :exjsx, repo: "hexpm", optional: false]}, {:httpoison, "~> 1.0", [hex: :httpoison, repo: "hexpm", optional: true]}, {:httpotion, "~> 3.1", [hex: :httpotion, repo: "hexpm", optional: true]}, {:ibrowse, "~> 4.4", [hex: :ibrowse, repo: "hexpm", optional: true]}, {:meck, "~> 0.8", [hex: :meck, repo: "hexpm", optional: false]}], "hexpm"},
|
||||
"file_info": {:hex, :file_info, "0.0.4", "2e0e77f211e833f38ead22cb29ce53761d457d80b3ffe0ffe0eb93880b0963b2", [:mix], [{:mimetype_parser, "~> 0.1.2", [hex: :mimetype_parser, repo: "hexpm", optional: false]}], "hexpm"},
|
||||
"file_system": {:hex, :file_system, "0.2.8", "f632bd287927a1eed2b718f22af727c5aeaccc9a98d8c2bd7bff709e851dc986", [:mix], [], "hexpm"},
|
||||
"gen_smtp": {:hex, :gen_smtp, "0.15.0", "9f51960c17769b26833b50df0b96123605a8024738b62db747fece14eb2fbfcc", [:rebar3], [], "hexpm"},
|
||||
"geo": {:hex, :geo, "3.3.3", "1119302b20d21515fbcec0a180b82653524067873ed333e7fa1f55e39959d702", [:mix], [], "hexpm"},
|
||||
"geo_postgis": {:hex, :geo_postgis, "3.3.1", "45bc96b9121d0647341685dc9d44956d61338707482d655c803500676b0413a1", [:mix], [{:geo, "~> 3.3", [hex: :geo, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:poison, "~> 2.2 or ~> 3.0 or ~> 4.0", [hex: :poison, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.14", [hex: :postgrex, repo: "hexpm", optional: false]}], "hexpm"},
|
||||
"geohax": {:hex, :geohax, "0.3.0", "c2e7d8cc6cdf4158120b50fcbe03a296da561d2089eb7ad68d84b6f5d3df5607", [:mix], [], "hexpm"},
|
||||
"geolix": {:hex, :geolix, "1.0.0", "b225d930fb0418871ce7d89dabf293bd80eb5bd66db1887f80510c122f4ef271", [:mix], [{:poolboy, "~> 1.0", [hex: :poolboy, repo: "hexpm", optional: false]}], "hexpm"},
|
||||
"geolix_adapter_mmdb2": {:hex, :geolix_adapter_mmdb2, "0.3.0", "ff36c4c0df390854e0266280c20dfa57ca68d9a0c2ff42c22bf3af5725b5bdcb", [:mix], [{:geolix, "~> 1.0", [hex: :geolix, repo: "hexpm", optional: false]}, {:mmdb2_decoder, "~> 2.1", [hex: :mmdb2_decoder, repo: "hexpm", optional: false]}], "hexpm"},
|
||||
"gettext": {:hex, :gettext, "0.17.4", "f13088e1ec10ce01665cf25f5ff779e7df3f2dc71b37084976cf89d1aa124d5c", [:mix], [], "hexpm"},
|
||||
"guardian": {:hex, :guardian, "2.1.1", "1f02b349f6ba765647cc834036a8d76fa4bd65605342fe3a031df3c99d0d411a", [:mix], [{:jose, "~> 1.8", [hex: :jose, repo: "hexpm", optional: false]}, {:plug, "~> 1.3.3 or ~> 1.4", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm"},
|
||||
"guardian_db": {:hex, :guardian_db, "2.0.3", "18c847efbf7ec3c0dd44c7aecaeeb2777588bbb8d2073ffc36e71037108b3be6", [:mix], [{:ecto, "~> 3.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.1", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:guardian, "~> 1.0 or ~> 2.0", [hex: :guardian, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.13", [hex: :postgrex, repo: "hexpm", optional: true]}], "hexpm"},
|
||||
"guardian_phoenix": {:hex, :guardian_phoenix, "2.0.1", "89a817265af09a6ddf7cb1e77f17ffca90cea2db10ff888375ef34502b2731b1", [:mix], [{:guardian, "~> 2.0", [hex: :guardian, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.3", [hex: :phoenix, repo: "hexpm", optional: false]}], "hexpm"},
|
||||
"hackney": {:hex, :hackney, "1.15.2", "07e33c794f8f8964ee86cebec1a8ed88db5070e52e904b8f12209773c1036085", [:rebar3], [{:certifi, "2.5.1", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "6.0.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "1.0.1", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~>1.1", [hex: :mimerl, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "1.1.5", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}], "hexpm"},
|
||||
"html_sanitize_ex": {:hex, :html_sanitize_ex, "1.4.0", "0310d27d7bafb662f30bff22ec732a72414799c83eaf44239781fd23b96216c0", [:mix], [{:mochiweb, "~> 2.15", [hex: :mochiweb, repo: "hexpm", optional: false]}], "hexpm"},
|
||||
"http_sign": {:hex, :http_sign, "0.1.1", "b16edb83aa282892f3271f9a048c155e772bf36e15700ab93901484c55f8dd10", [:mix], [{:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm"},
|
||||
"bunt": {:hex, :bunt, "0.2.0", "951c6e801e8b1d2cbe58ebbd3e616a869061ddadcc4863d0a2182541acae9a38", [:mix], [], "hexpm", "7af5c7e09fe1d40f76c8e4f9dd2be7cebd83909f31fee7cd0e9eadc567da8353"},
|
||||
"cachex": {:hex, :cachex, "3.2.0", "a596476c781b0646e6cb5cd9751af2e2974c3e0d5498a8cab71807618b74fe2f", [:mix], [{:eternal, "~> 1.2", [hex: :eternal, repo: "hexpm", optional: false]}, {:jumper, "~> 1.0", [hex: :jumper, repo: "hexpm", optional: false]}, {:sleeplocks, "~> 1.1", [hex: :sleeplocks, repo: "hexpm", optional: false]}, {:unsafe, "~> 1.0", [hex: :unsafe, repo: "hexpm", optional: false]}], "hexpm", "aef93694067a43697ae0531727e097754a9e992a1e7946296f5969d6dd9ac986"},
|
||||
"certifi": {:hex, :certifi, "2.5.1", "867ce347f7c7d78563450a18a6a28a8090331e77fa02380b4a21962a65d36ee5", [:rebar3], [{:parse_trans, "~>3.3", [hex: :parse_trans, repo: "hexpm", optional: false]}], "hexpm", "805abd97539caf89ec6d4732c91e62ba9da0cda51ac462380bbd28ee697a8c42"},
|
||||
"cldr_utils": {:hex, :cldr_utils, "2.8.0", "a1355a658fdf7118a678002a5333562d464e1bbdc3c89a5e0c3d8088038e57f5", [:mix], [{:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}], "hexpm", "b37b2ae815bf62a01ba4deb90bd75c70fa0ed07f05b5a1b530d21a99b223844b"},
|
||||
"combine": {:hex, :combine, "0.10.0", "eff8224eeb56498a2af13011d142c5e7997a80c8f5b97c499f84c841032e429f", [:mix], [], "hexpm", "1b1dbc1790073076580d0d1d64e42eae2366583e7aecd455d1215b0d16f2451b"},
|
||||
"comeonin": {:hex, :comeonin, "5.3.1", "7fe612b739c78c9c1a75186ef2d322ce4d25032d119823269d0aa1e2f1e20025", [:mix], [], "hexpm", "d6222483060c17f0977fad1b7401ef0c5863c985a64352755f366aee3799c245"},
|
||||
"connection": {:hex, :connection, "1.0.4", "a1cae72211f0eef17705aaededacac3eb30e6625b04a6117c1b2db6ace7d5976", [:mix], [], "hexpm", "4a0850c9be22a43af9920a71ab17c051f5f7d45c209e40269a1938832510e4d9"},
|
||||
"cors_plug": {:hex, :cors_plug, "2.0.2", "2b46083af45e4bc79632bd951550509395935d3e7973275b2b743bd63cc942ce", [:mix], [{:plug, "~> 1.8", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "f0d0e13f71c51fd4ef8b2c7e051388e4dfb267522a83a22392c856de7e46465f"},
|
||||
"cowboy": {:hex, :cowboy, "2.7.0", "91ed100138a764355f43316b1d23d7ff6bdb0de4ea618cb5d8677c93a7a2f115", [:rebar3], [{:cowlib, "~> 2.8.0", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, "~> 1.7.1", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "04fd8c6a39edc6aaa9c26123009200fc61f92a3a94f3178c527b70b767c6e605"},
|
||||
"cowlib": {:hex, :cowlib, "2.8.0", "fd0ff1787db84ac415b8211573e9a30a3ebe71b5cbff7f720089972b2319c8a4", [:rebar3], [], "hexpm", "79f954a7021b302186a950a32869dbc185523d99d3e44ce430cd1f3289f41ed4"},
|
||||
"credo": {:hex, :credo, "1.3.0", "37699fefdbe1b0480a5a6b73f259207e9cd7ad5e492277e22c2179bcb226a67b", [:mix], [{:bunt, "~> 0.2.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "8036b9226e4440d3ebce3931505e407b8d59fc95975f574c26337812e8de2a86"},
|
||||
"dataloader": {:hex, :dataloader, "1.0.7", "58351b335673cf40601429bfed6c11fece6ce7ad169b2ac0f0fe83e716587391", [:mix], [{:ecto, ">= 0.0.0", [hex: :ecto, repo: "hexpm", optional: true]}], "hexpm", "12bf66478e4a5085d09dc96932d058c206ee8c219cc7691d12a40dc35c8cefaa"},
|
||||
"db_connection": {:hex, :db_connection, "2.2.1", "caee17725495f5129cb7faebde001dc4406796f12a62b8949f4ac69315080566", [:mix], [{:connection, "~> 1.0.2", [hex: :connection, repo: "hexpm", optional: false]}], "hexpm", "2b02ece62d9f983fcd40954e443b7d9e6589664380e5546b2b9b523cd0fb59e1"},
|
||||
"decimal": {:hex, :decimal, "1.8.1", "a4ef3f5f3428bdbc0d35374029ffcf4ede8533536fa79896dd450168d9acdf3c", [:mix], [], "hexpm", "3cb154b00225ac687f6cbd4acc4b7960027c757a5152b369923ead9ddbca7aec"},
|
||||
"dialyxir": {:hex, :dialyxir, "1.0.0-rc.7", "6287f8f2cb45df8584317a4be1075b8c9b8a69de8eeb82b4d9e6c761cf2664cd", [:mix], [{:erlex, ">= 0.2.5", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "506294d6c543e4e5282d4852aead19ace8a35bedeb043f9256a06a6336827122"},
|
||||
"earmark": {:hex, :earmark, "1.4.3", "364ca2e9710f6bff494117dbbd53880d84bebb692dafc3a78eb50aa3183f2bfd", [:mix], [], "hexpm", "8cf8a291ebf1c7b9539e3cddb19e9cef066c2441b1640f13c34c1d3cfc825fec"},
|
||||
"ecto": {:hex, :ecto, "3.3.4", "95b05c82ae91361475e5491c9f3ac47632f940b3f92ae3988ac1aad04989c5bb", [:mix], [{:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm", "9b96cbb83a94713731461ea48521b178b0e3863d310a39a3948c807266eebd69"},
|
||||
"ecto_autoslug_field": {:hex, :ecto_autoslug_field, "2.0.1", "2177c1c253f6dd3efd4b56d1cb76104d0a6ef044c6b9a7a0ad6d32665c4111e5", [:mix], [{:ecto, ">= 2.1.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:slugger, ">= 0.2.0", [hex: :slugger, repo: "hexpm", optional: false]}], "hexpm", "a3cc73211f2e75b89a03332183812ebe1ac08be2e25a1df5aa3d1422f92c45c3"},
|
||||
"ecto_enum": {:hex, :ecto_enum, "1.4.0", "d14b00e04b974afc69c251632d1e49594d899067ee2b376277efd8233027aec8", [:mix], [{:ecto, ">= 3.0.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "> 3.0.0", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:mariaex, ">= 0.0.0", [hex: :mariaex, repo: "hexpm", optional: true]}, {:postgrex, ">= 0.0.0", [hex: :postgrex, repo: "hexpm", optional: true]}], "hexpm", "8fb55c087181c2b15eee406519dc22578fa60dd82c088be376d0010172764ee4"},
|
||||
"ecto_sql": {:hex, :ecto_sql, "3.3.4", "aa18af12eb875fbcda2f75e608b3bd534ebf020fc4f6448e4672fcdcbb081244", [:mix], [{:db_connection, "~> 2.2", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.4 or ~> 3.3.3", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.3.0", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.15.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "5eccbdbf92e3c6f213007a82d5dbba4cd9bb659d1a21331f89f408e4c0efd7a8"},
|
||||
"elixir_feed_parser": {:hex, :elixir_feed_parser, "2.1.0", "bb96fb6422158dc7ad59de62ef211cc69d264acbbe63941a64a5dce97bbbc2e6", [:mix], [{:timex, "~> 3.4", [hex: :timex, repo: "hexpm", optional: false]}], "hexpm", "2d3c62fe7b396ee3b73d7160bc8fadbd78bfe9597c98c7d79b3f1038d9cba28f"},
|
||||
"elixir_make": {:hex, :elixir_make, "0.6.0", "38349f3e29aff4864352084fc736fa7fa0f2995a819a737554f7ebd28b85aaab", [:mix], [], "hexpm", "d522695b93b7f0b4c0fcb2dfe73a6b905b1c301226a5a55cb42e5b14d509e050"},
|
||||
"erlex": {:hex, :erlex, "0.2.6", "c7987d15e899c7a2f34f5420d2a2ea0d659682c06ac607572df55a43753aa12e", [:mix], [], "hexpm", "2ed2e25711feb44d52b17d2780eabf998452f6efda104877a3881c2f8c0c0c75"},
|
||||
"eternal": {:hex, :eternal, "1.2.1", "d5b6b2499ba876c57be2581b5b999ee9bdf861c647401066d3eeed111d096bc4", [:mix], [], "hexpm", "b14f1dc204321429479c569cfbe8fb287541184ed040956c8862cb7a677b8406"},
|
||||
"ex_cldr": {:hex, :ex_cldr, "2.13.0", "742f14a4afcfea61a190d603d8e555d2c91d71e4e8fc2520d5dc35616969e225", [:mix], [{:cldr_utils, "~> 2.3", [hex: :cldr_utils, repo: "hexpm", optional: false]}, {:decimal, "~> 1.6", [hex: :decimal, repo: "hexpm", optional: false]}, {:gettext, "~> 0.13", [hex: :gettext, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:plug, "~> 1.4", [hex: :plug, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0", [hex: :telemetry, repo: "hexpm", optional: true]}], "hexpm", "5e4cf3e945ee60156a3342e2a762f69036ffbe1f80520cc88592d68f12c5db55"},
|
||||
"ex_cldr_calendars": {:hex, :ex_cldr_calendars, "1.7.1", "d2490358148c09b8915a20d959390ce10ea13cf6e7cfb1df823afbbc2ec71699", [:mix], [{:ex_cldr, "~> 2.12", [hex: :ex_cldr, repo: "hexpm", optional: false]}, {:ex_cldr_lists, "~> 2.4", [hex: :ex_cldr_lists, repo: "hexpm", optional: true]}, {:ex_cldr_units, "~> 2.0", [hex: :ex_cldr_units, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "7d169e5899a32f65df1d7e5cdd8ac5b0480cd2fd4aea7fcf5e0e51c487ff166a"},
|
||||
"ex_cldr_currencies": {:hex, :ex_cldr_currencies, "2.4.1", "a8e8330a6d0712b8bb34c5e3759311da1d53fa8bebef163d72615f0ea60c0738", [:mix], [{:ex_cldr, "~> 2.6", [hex: :ex_cldr, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm", "256a79113ce37689907e169d6caaeec2ca95ef728d389b8bc10ae429f0d1e854"},
|
||||
"ex_cldr_dates_times": {:hex, :ex_cldr_dates_times, "2.3.0", "5c9fe5b695ac3adf30e3e5640a1f9b0e316f5773409d30e605a7edfc86c55921", [:mix], [{:ex_cldr, "~> 2.8", [hex: :ex_cldr, repo: "hexpm", optional: false]}, {:ex_cldr_calendars, "~> 1.2", [hex: :ex_cldr_calendars, repo: "hexpm", optional: false]}, {:ex_cldr_numbers, "~> 2.6", [hex: :ex_cldr_numbers, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm", "be23d316689c85800371cc38418c31b00dc06503996ade80a3e9e5f7645b7611"},
|
||||
"ex_cldr_numbers": {:hex, :ex_cldr_numbers, "2.12.1", "2548f2a3ef6812c6600962795fb27f8725fec1afb790486f07dacc7eda9bb4b8", [:mix], [{:cldr_utils, "~> 2.6", [hex: :cldr_utils, repo: "hexpm", optional: false]}, {:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:ex_cldr, "~> 2.13", [hex: :ex_cldr, repo: "hexpm", optional: false]}, {:ex_cldr_currencies, "~> 2.3", [hex: :ex_cldr_currencies, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm", "475297a6c3eb8b7ffffce97c2411c0ab543b4ffdb9eb79ecf0c2f466e759ae3a"},
|
||||
"ex_crypto": {:hex, :ex_crypto, "0.10.0", "af600a89b784b36613a989da6e998c1b200ff1214c3cfbaf8deca4aa2f0a1739", [:mix], [{:poison, ">= 2.0.0", [hex: :poison, repo: "hexpm", optional: false]}], "hexpm", "ccc7472cfe8a0f4565f97dce7e9280119bf15a5ea51c6535e5b65f00660cde1c"},
|
||||
"ex_doc": {:hex, :ex_doc, "0.21.3", "857ec876b35a587c5d9148a2512e952e24c24345552259464b98bfbb883c7b42", [:mix], [{:earmark, "~> 1.4", [hex: :earmark, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.14", [hex: :makeup_elixir, repo: "hexpm", optional: false]}], "hexpm", "0db1ee8d1547ab4877c5b5dffc6604ef9454e189928d5ba8967d4a58a801f161"},
|
||||
"ex_ical": {:hex, :ex_ical, "0.2.0", "4b928b554614704016cc0c9ee226eb854da9327a1cc460457621ceacb1ac29a6", [:mix], [{:timex, "~> 3.1", [hex: :timex, repo: "hexpm", optional: false]}], "hexpm", "db76473b2ae0259e6633c6c479a5a4d8603f09497f55c88f9ef4d53d2b75befb"},
|
||||
"ex_machina": {:hex, :ex_machina, "2.4.0", "09a34c5d371bfb5f78399029194a8ff67aff340ebe8ba19040181af35315eabb", [:mix], [{:ecto, "~> 2.2 or ~> 3.0", [hex: :ecto, repo: "hexpm", optional: true]}, {:ecto_sql, "~> 3.0", [hex: :ecto_sql, repo: "hexpm", optional: true]}], "hexpm", "a20bc9ddc721b33ea913b93666c5d0bdca5cbad7a67540784ae277228832d72c"},
|
||||
"ex_optimizer": {:hex, :ex_optimizer, "0.1.0", "1d12f7ea289092a38a794b84bd2f42c1e0621cb307c0f3e6a7df620839af2937", [:mix], [{:file_info, "~> 0.0.4", [hex: :file_info, repo: "hexpm", optional: false]}], "hexpm", "a409cb91472e08d4791a129effe4687982f85e2debcb4ccb1a3711a36bfdc428"},
|
||||
"ex_unit_notifier": {:hex, :ex_unit_notifier, "0.1.4", "36a2dcab829f506e01bf17816590680dd1474407926d43e64c1263e627c364b8", [:mix], [], "hexpm", "fddf5054dd5fd2f809e837b749570baa5c9798e11d0163921baec49b7d5762f2"},
|
||||
"exactor": {:hex, :exactor, "2.2.4", "5efb4ddeb2c48d9a1d7c9b465a6fffdd82300eb9618ece5d34c3334d5d7245b1", [:mix], [], "hexpm", "1222419f706e01bfa1095aec9acf6421367dcfab798a6f67c54cf784733cd6b5"},
|
||||
"excoveralls": {:hex, :excoveralls, "0.12.2", "a513defac45c59e310ac42fcf2b8ae96f1f85746410f30b1ff2b710a4b6cd44b", [:mix], [{:hackney, "~> 1.0", [hex: :hackney, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "151c476331d49b45601ffc45f43cb3a8beb396b02a34e3777fea0ad34ae57d89"},
|
||||
"exgravatar": {:hex, :exgravatar, "2.0.2", "638412896170409da114f98947d3f8d4f38e851b0e329c1cc4cd324d5e2ea081", [:mix], [], "hexpm", "f3deb5baa6fcf354a965d794ee73a956d95f1f79f41bddf69800c713cfb014a1"},
|
||||
"exjsx": {:hex, :exjsx, "4.0.0", "60548841e0212df401e38e63c0078ec57b33e7ea49b032c796ccad8cde794b5c", [:mix], [{:jsx, "~> 2.8.0", [hex: :jsx, repo: "hexpm", optional: false]}], "hexpm", "32e95820a97cffea67830e91514a2ad53b888850442d6d395f53a1ac60c82e07"},
|
||||
"exvcr": {:hex, :exvcr, "0.11.1", "a5e5f57a67538e032e16cfea6cfb1232314fb146e3ceedf1cde4a11f12fb7a58", [:mix], [{:exactor, "~> 2.2", [hex: :exactor, repo: "hexpm", optional: false]}, {:exjsx, "~> 4.0", [hex: :exjsx, repo: "hexpm", optional: false]}, {:httpoison, "~> 1.0", [hex: :httpoison, repo: "hexpm", optional: true]}, {:httpotion, "~> 3.1", [hex: :httpotion, repo: "hexpm", optional: true]}, {:ibrowse, "~> 4.4", [hex: :ibrowse, repo: "hexpm", optional: true]}, {:meck, "~> 0.8", [hex: :meck, repo: "hexpm", optional: false]}], "hexpm", "984a4d52d9e01d5f0e28d45718565a41dffab3ac18e029ae45d42f16a2a58a1d"},
|
||||
"fast_html": {:hex, :fast_html, "1.0.3", "2cc0d4b68496266a1530e0c852cafeaede0bd10cfdee26fda50dc696c203162f", [:make, :mix], [], "hexpm", "ab3d782b639d3c4655fbaec0f9d032c91f8cab8dd791ac7469c2381bc7c32f85"},
|
||||
"fast_sanitize": {:hex, :fast_sanitize, "0.1.7", "2a7cd8734c88a2de6de55022104f8a3b87f1fdbe8bbf131d9049764b53d50d0d", [:mix], [{:fast_html, "~> 1.0", [hex: :fast_html, repo: "hexpm", optional: false]}, {:plug, "~> 1.8", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "f39fe8ea08fbac17487c30bf09b7d9f3e12472e51fb07a88ffeb8fd17da8ab67"},
|
||||
"file_info": {:hex, :file_info, "0.0.4", "2e0e77f211e833f38ead22cb29ce53761d457d80b3ffe0ffe0eb93880b0963b2", [:mix], [{:mimetype_parser, "~> 0.1.2", [hex: :mimetype_parser, repo: "hexpm", optional: false]}], "hexpm", "50e7ad01c2c8b9339010675fe4dc4a113b8d6ca7eddce24d1d74fd0e762781a5"},
|
||||
"file_system": {:hex, :file_system, "0.2.8", "f632bd287927a1eed2b718f22af727c5aeaccc9a98d8c2bd7bff709e851dc986", [:mix], [], "hexpm", "97a3b6f8d63ef53bd0113070102db2ce05352ecf0d25390eb8d747c2bde98bca"},
|
||||
"floki": {:hex, :floki, "0.26.0", "4df88977e2e357c6720e1b650f613444bfb48c5acfc6a0c646ab007d08ad13bf", [:mix], [{:html_entities, "~> 0.5.0", [hex: :html_entities, repo: "hexpm", optional: false]}], "hexpm", "e7b66ce7feef5518a9cd9fc7b52dd62a64028bd9cb6d6ad282a0f0fc90a4ae52"},
|
||||
"gen_smtp": {:hex, :gen_smtp, "0.15.0", "9f51960c17769b26833b50df0b96123605a8024738b62db747fece14eb2fbfcc", [:rebar3], [], "hexpm", "29bd14a88030980849c7ed2447b8db6d6c9278a28b11a44cafe41b791205440f"},
|
||||
"geo": {:hex, :geo, "3.3.3", "1119302b20d21515fbcec0a180b82653524067873ed333e7fa1f55e39959d702", [:mix], [], "hexpm", "8297ae0ac5ce47bb608b2bc8a63030460020ae537de9464a7a652f25baf6d2c1"},
|
||||
"geo_postgis": {:hex, :geo_postgis, "3.3.1", "45bc96b9121d0647341685dc9d44956d61338707482d655c803500676b0413a1", [:mix], [{:geo, "~> 3.3", [hex: :geo, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:poison, "~> 2.2 or ~> 3.0 or ~> 4.0", [hex: :poison, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.14", [hex: :postgrex, repo: "hexpm", optional: false]}], "hexpm", "3c3957d8750e3effd565f068ee658ef0e881f9a07084a23f6c5ef8262d09b8e9"},
|
||||
"geohax": {:hex, :geohax, "0.3.0", "c2e7d8cc6cdf4158120b50fcbe03a296da561d2089eb7ad68d84b6f5d3df5607", [:mix], [], "hexpm", "af9d4f4d5b031fbecac1d404a3077d941de91eea94fd1be8c97fd33c520da6a7"},
|
||||
"geolix": {:hex, :geolix, "1.0.0", "b225d930fb0418871ce7d89dabf293bd80eb5bd66db1887f80510c122f4ef271", [:mix], [{:poolboy, "~> 1.0", [hex: :poolboy, repo: "hexpm", optional: false]}], "hexpm", "871685cbe0780bebe47486353152f165a540dbaae394a04782d8bcc6ac0f5d32"},
|
||||
"geolix_adapter_mmdb2": {:hex, :geolix_adapter_mmdb2, "0.3.0", "ff36c4c0df390854e0266280c20dfa57ca68d9a0c2ff42c22bf3af5725b5bdcb", [:mix], [{:geolix, "~> 1.0", [hex: :geolix, repo: "hexpm", optional: false]}, {:mmdb2_decoder, "~> 2.1", [hex: :mmdb2_decoder, repo: "hexpm", optional: false]}], "hexpm", "0ecd1d780da12a6964b0dedd194cbf41c06d12a1fd4ea02ef9c2b0d3c64dea39"},
|
||||
"gettext": {:hex, :gettext, "0.17.4", "f13088e1ec10ce01665cf25f5ff779e7df3f2dc71b37084976cf89d1aa124d5c", [:mix], [], "hexpm", "3c75b5ea8288e2ee7ea503ff9e30dfe4d07ad3c054576a6e60040e79a801e14d"},
|
||||
"guardian": {:hex, :guardian, "2.1.1", "1f02b349f6ba765647cc834036a8d76fa4bd65605342fe3a031df3c99d0d411a", [:mix], [{:jose, "~> 1.8", [hex: :jose, repo: "hexpm", optional: false]}, {:plug, "~> 1.3.3 or ~> 1.4", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "189b87ba7ce6b40d6ba029138098b96ffc4ae78f229f5b39539b9141af8bf0f8"},
|
||||
"guardian_db": {:hex, :guardian_db, "2.0.3", "18c847efbf7ec3c0dd44c7aecaeeb2777588bbb8d2073ffc36e71037108b3be6", [:mix], [{:ecto, "~> 3.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.1", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:guardian, "~> 1.0 or ~> 2.0", [hex: :guardian, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.13", [hex: :postgrex, repo: "hexpm", optional: true]}], "hexpm", "17306e09498bca379fb8eded2ac44d7690f738ca14b17080d06a948d034ea087"},
|
||||
"guardian_phoenix": {:hex, :guardian_phoenix, "2.0.1", "89a817265af09a6ddf7cb1e77f17ffca90cea2db10ff888375ef34502b2731b1", [:mix], [{:guardian, "~> 2.0", [hex: :guardian, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.3", [hex: :phoenix, repo: "hexpm", optional: false]}], "hexpm", "21f439246715192b231f228680465d1ed5fbdf01555a4a3b17165532f5f9a08c"},
|
||||
"hackney": {:hex, :hackney, "1.15.2", "07e33c794f8f8964ee86cebec1a8ed88db5070e52e904b8f12209773c1036085", [:rebar3], [{:certifi, "2.5.1", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "6.0.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "1.0.1", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~>1.1", [hex: :mimerl, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "1.1.5", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}], "hexpm", "e0100f8ef7d1124222c11ad362c857d3df7cb5f4204054f9f0f4a728666591fc"},
|
||||
"html_entities": {:hex, :html_entities, "0.5.1", "1c9715058b42c35a2ab65edc5b36d0ea66dd083767bef6e3edb57870ef556549", [:mix], [], "hexpm", "30efab070904eb897ff05cd52fa61c1025d7f8ef3a9ca250bc4e6513d16c32de"},
|
||||
"html_sanitize_ex": {:hex, :html_sanitize_ex, "1.4.0", "0310d27d7bafb662f30bff22ec732a72414799c83eaf44239781fd23b96216c0", [:mix], [{:mochiweb, "~> 2.15", [hex: :mochiweb, repo: "hexpm", optional: false]}], "hexpm", "c5d79626be0b6e50c19ecdfb783ee26e85bd3a77436b488379ce6dc104ec4593"},
|
||||
"http_sign": {:hex, :http_sign, "0.1.1", "b16edb83aa282892f3271f9a048c155e772bf36e15700ab93901484c55f8dd10", [:mix], [{:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "2d4b1c2579d85534035f12c9e1260abdf6d03a9ad4f515b2ee53b50e68c8b787"},
|
||||
"http_signatures": {:git, "https://git.pleroma.social/pleroma/http_signatures.git", "293d77bb6f4a67ac8bde1428735c3b42f22cbb30", [ref: "293d77bb6f4a67ac8bde1428735c3b42f22cbb30"]},
|
||||
"httpoison": {:hex, :httpoison, "1.6.2", "ace7c8d3a361cebccbed19c283c349b3d26991eff73a1eaaa8abae2e3c8089b6", [:mix], [{:hackney, "~> 1.15 and >= 1.15.2", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm"},
|
||||
"httpoison": {:hex, :httpoison, "1.6.2", "ace7c8d3a361cebccbed19c283c349b3d26991eff73a1eaaa8abae2e3c8089b6", [:mix], [{:hackney, "~> 1.15 and >= 1.15.2", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm", "aa2c74bd271af34239a3948779612f87df2422c2fdcfdbcec28d9c105f0773fe"},
|
||||
"icalendar": {:git, "https://github.com/tcitworld/icalendar.git", "bd08e872c125f70a87c3ac7d87ea2f22a5577059", []},
|
||||
"idna": {:hex, :idna, "6.0.0", "689c46cbcdf3524c44d5f3dde8001f364cd7608a99556d8fbd8239a5798d4c10", [:rebar3], [{:unicode_util_compat, "0.4.1", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm"},
|
||||
"jason": {:hex, :jason, "1.1.2", "b03dedea67a99223a2eaf9f1264ce37154564de899fd3d8b9a21b1a6fd64afe7", [:mix], [{:decimal, "~> 1.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm"},
|
||||
"jose": {:hex, :jose, "1.10.1", "16d8e460dae7203c6d1efa3f277e25b5af8b659febfc2f2eb4bacf87f128b80a", [:mix, :rebar3], [], "hexpm"},
|
||||
"json_ld": {:hex, :json_ld, "0.3.0", "92f508ca831b9e4530e3e6c950976fdafcf26323e6817c325b3e1ee78affc4bd", [:mix], [{:jason, "~> 1.1.1", [hex: :jason, repo: "hexpm", optional: false]}, {:rdf, "~> 0.5", [hex: :rdf, repo: "hexpm", optional: false]}], "hexpm"},
|
||||
"jsx": {:hex, :jsx, "2.8.3", "a05252d381885240744d955fbe3cf810504eb2567164824e19303ea59eef62cf", [:mix, :rebar3], [], "hexpm"},
|
||||
"jumper": {:hex, :jumper, "1.0.1", "3c00542ef1a83532b72269fab9f0f0c82bf23a35e27d278bfd9ed0865cecabff", [:mix], [], "hexpm"},
|
||||
"makeup": {:hex, :makeup, "1.0.0", "671df94cf5a594b739ce03b0d0316aa64312cee2574b6a44becb83cd90fb05dc", [:mix], [{:nimble_parsec, "~> 0.5.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm"},
|
||||
"makeup_elixir": {:hex, :makeup_elixir, "0.14.0", "cf8b7c66ad1cff4c14679698d532f0b5d45a3968ffbcbfd590339cb57742f1ae", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm"},
|
||||
"meck": {:hex, :meck, "0.8.13", "ffedb39f99b0b99703b8601c6f17c7f76313ee12de6b646e671e3188401f7866", [:rebar3], [], "hexpm"},
|
||||
"metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm"},
|
||||
"mime": {:hex, :mime, "1.3.1", "30ce04ab3175b6ad0bdce0035cba77bba68b813d523d1aac73d9781b4d193cf8", [:mix], [], "hexpm"},
|
||||
"mimerl": {:hex, :mimerl, "1.2.0", "67e2d3f571088d5cfd3e550c383094b47159f3eee8ffa08e64106cdf5e981be3", [:rebar3], [], "hexpm"},
|
||||
"mimetype_parser": {:hex, :mimetype_parser, "0.1.3", "628ac9fe56aa7edcedb534d68397dd66674ab82493c8ebe39acb9a19b666099d", [:mix], [], "hexpm"},
|
||||
"mix_test_watch": {:hex, :mix_test_watch, "1.0.2", "34900184cbbbc6b6ed616ed3a8ea9b791f9fd2088419352a6d3200525637f785", [:mix], [{:file_system, "~> 0.2.1 or ~> 0.3", [hex: :file_system, repo: "hexpm", optional: false]}], "hexpm"},
|
||||
"mmdb2_decoder": {:hex, :mmdb2_decoder, "2.1.0", "91933e121e5370c89fdac37bba85a84116869a7267e1e93b4a8e1fa5c6ef89e7", [:mix], [], "hexpm"},
|
||||
"mochiweb": {:hex, :mochiweb, "2.20.1", "e4dbd0ed716f076366ecf62ada5755a844e1d95c781e8c77df1d4114be868cdf", [:rebar3], [], "hexpm"},
|
||||
"mock": {:hex, :mock, "0.3.4", "c5862eb3b8c64237f45f586cf00c9d892ba07bb48305a43319d428ce3c2897dd", [:mix], [{:meck, "~> 0.8.13", [hex: :meck, repo: "hexpm", optional: false]}], "hexpm"},
|
||||
"mogrify": {:hex, :mogrify, "0.7.3", "1494ee739f6e90de158dec4d4edee2d854d2f2d06a522e943f996ae176bca53d", [:mix], [], "hexpm"},
|
||||
"nimble_parsec": {:hex, :nimble_parsec, "0.5.3", "def21c10a9ed70ce22754fdeea0810dafd53c2db3219a0cd54cf5526377af1c6", [:mix], [], "hexpm"},
|
||||
"oban": {:hex, :oban, "1.2.0", "7cca94d341be43d220571e28f69131c4afc21095b25257397f50973d3fc59b07", [:mix], [{:ecto_sql, "~> 3.1", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.14", [hex: :postgrex, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm"},
|
||||
"parse_trans": {:hex, :parse_trans, "3.3.0", "09765507a3c7590a784615cfd421d101aec25098d50b89d7aa1d66646bc571c1", [:rebar3], [], "hexpm"},
|
||||
"phoenix": {:hex, :phoenix, "1.4.15", "5c39c330f46a33d752c6feceb25629ee8e62a158b997fea62bca59a59b28e3ea", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 1.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:plug, "~> 1.8.1 or ~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 1.0 or ~> 2.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm"},
|
||||
"phoenix_ecto": {:hex, :phoenix_ecto, "4.1.0", "a044d0756d0464c5a541b4a0bf4bcaf89bffcaf92468862408290682c73ae50d", [:mix], [{:ecto, "~> 3.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.9", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm"},
|
||||
"phoenix_html": {:hex, :phoenix_html, "2.14.0", "d8c6bc28acc8e65f8ea0080ee05aa13d912c8758699283b8d3427b655aabe284", [:mix], [{:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm"},
|
||||
"phoenix_live_reload": {:hex, :phoenix_live_reload, "1.2.1", "274a4b07c4adbdd7785d45a8b0bb57634d0b4f45b18d2c508b26c0344bd59b8f", [:mix], [{:file_system, "~> 0.2.1 or ~> 0.3", [hex: :file_system, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.4", [hex: :phoenix, repo: "hexpm", optional: false]}], "hexpm"},
|
||||
"phoenix_pubsub": {:hex, :phoenix_pubsub, "1.1.2", "496c303bdf1b2e98a9d26e89af5bba3ab487ba3a3735f74bf1f4064d2a845a3e", [:mix], [], "hexpm"},
|
||||
"plug": {:hex, :plug, "1.9.0", "8d7c4e26962283ff9f8f3347bd73838e2413fbc38b7bb5467d5924f68f3a5a4a", [:mix], [{:mime, "~> 1.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: true]}], "hexpm"},
|
||||
"plug_cowboy": {:hex, :plug_cowboy, "2.1.2", "8b0addb5908c5238fac38e442e81b6fcd32788eaa03246b4d55d147c47c5805e", [:mix], [{:cowboy, "~> 2.5", [hex: :cowboy, repo: "hexpm", optional: false]}, {:plug, "~> 1.7", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm"},
|
||||
"plug_crypto": {:hex, :plug_crypto, "1.1.2", "bdd187572cc26dbd95b87136290425f2b580a116d3fb1f564216918c9730d227", [:mix], [], "hexpm"},
|
||||
"poison": {:hex, :poison, "4.0.1", "bcb755a16fac91cad79bfe9fc3585bb07b9331e50cfe3420a24bcc2d735709ae", [:mix], [], "hexpm"},
|
||||
"poolboy": {:hex, :poolboy, "1.5.2", "392b007a1693a64540cead79830443abf5762f5d30cf50bc95cb2c1aaafa006b", [:rebar3], [], "hexpm"},
|
||||
"postgrex": {:hex, :postgrex, "0.15.3", "5806baa8a19a68c4d07c7a624ccdb9b57e89cbc573f1b98099e3741214746ae4", [:mix], [{:connection, "~> 1.0", [hex: :connection, repo: "hexpm", optional: false]}, {:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm"},
|
||||
"progress_bar": {:hex, :progress_bar, "2.0.0", "447285f533b4b8717881fdb7160c7360c2f2ab57276f8904ce6d40482857e573", [:mix], [{:decimal, "~> 1.0", [hex: :decimal, repo: "hexpm", optional: false]}], "hexpm"},
|
||||
"ranch": {:hex, :ranch, "1.7.1", "6b1fab51b49196860b733a49c07604465a47bdb78aa10c1c16a3d199f7f8c881", [:rebar3], [], "hexpm"},
|
||||
"rdf": {:hex, :rdf, "0.7.1", "2b707331902f31e95f0a86ca9705b385b84f84434e31f578da5aaf774293bab9", [:mix], [{:decimal, "~> 1.5", [hex: :decimal, repo: "hexpm", optional: false]}], "hexpm"},
|
||||
"rsa_ex": {:hex, :rsa_ex, "0.4.0", "e28dd7dc5236e156df434af0e4aa822384c8866c928e17b785d4edb7c253b558", [:mix], [], "hexpm"},
|
||||
"sleeplocks": {:hex, :sleeplocks, "1.1.1", "3d462a0639a6ef36cc75d6038b7393ae537ab394641beb59830a1b8271faeed3", [:rebar3], [], "hexpm"},
|
||||
"slugger": {:hex, :slugger, "0.3.0", "efc667ab99eee19a48913ccf3d038b1fb9f165fa4fbf093be898b8099e61b6ed", [:mix], [], "hexpm"},
|
||||
"ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.5", "6eaf7ad16cb568bb01753dbbd7a95ff8b91c7979482b95f38443fe2c8852a79b", [:make, :mix, :rebar3], [], "hexpm"},
|
||||
"telemetry": {:hex, :telemetry, "0.4.1", "ae2718484892448a24470e6aa341bc847c3277bfb8d4e9289f7474d752c09c7f", [:rebar3], [], "hexpm"},
|
||||
"timex": {:hex, :timex, "3.6.1", "efdf56d0e67a6b956cc57774353b0329c8ab7726766a11547e529357ffdc1d56", [:mix], [{:combine, "~> 0.10", [hex: :combine, repo: "hexpm", optional: false]}, {:gettext, "~> 0.10", [hex: :gettext, repo: "hexpm", optional: false]}, {:tzdata, "~> 0.1.8 or ~> 0.5 or ~> 1.0.0", [hex: :tzdata, repo: "hexpm", optional: false]}], "hexpm"},
|
||||
"tzdata": {:hex, :tzdata, "1.0.3", "73470ad29dde46e350c60a66e6b360d3b99d2d18b74c4c349dbebbc27a09a3eb", [:mix], [{:hackney, "~> 1.0", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm"},
|
||||
"unicode_util_compat": {:hex, :unicode_util_compat, "0.4.1", "d869e4c68901dd9531385bb0c8c40444ebf624e60b6962d95952775cac5e90cd", [:rebar3], [], "hexpm"},
|
||||
"unsafe": {:hex, :unsafe, "1.0.1", "a27e1874f72ee49312e0a9ec2e0b27924214a05e3ddac90e91727bc76f8613d8", [:mix], [], "hexpm"},
|
||||
"xml_builder": {:hex, :xml_builder, "2.0.0", "371ed27bb63bf0598dbaf3f0c466e5dc7d16cb4ecb68f06a67f953654062e21b", [:mix], [], "hexpm"},
|
||||
"idna": {:hex, :idna, "6.0.0", "689c46cbcdf3524c44d5f3dde8001f364cd7608a99556d8fbd8239a5798d4c10", [:rebar3], [{:unicode_util_compat, "0.4.1", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "4bdd305eb64e18b0273864920695cb18d7a2021f31a11b9c5fbcd9a253f936e2"},
|
||||
"inet_cidr": {:hex, :inet_cidr, "1.0.4", "a05744ab7c221ca8e395c926c3919a821eb512e8f36547c062f62c4ca0cf3d6e", [:mix], [], "hexpm", "64a2d30189704ae41ca7dbdd587f5291db5d1dda1414e0774c29ffc81088c1bc"},
|
||||
"ip_reserved": {:hex, :ip_reserved, "0.1.0", "5c3b6df25eb875618e489db47e00fb8dac53bc2b0dc2d546b713e6141210fe9f", [:mix], [{:inet_cidr, "~> 1.0.0", [hex: :inet_cidr, repo: "hexpm", optional: false]}], "hexpm", "88b0e96f40048f214b9e90e64eaebbf18acfec066008d7ef993b08282b2fe484"},
|
||||
"jason": {:hex, :jason, "1.1.2", "b03dedea67a99223a2eaf9f1264ce37154564de899fd3d8b9a21b1a6fd64afe7", [:mix], [{:decimal, "~> 1.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "fdf843bca858203ae1de16da2ee206f53416bbda5dc8c9e78f43243de4bc3afe"},
|
||||
"jose": {:hex, :jose, "1.10.1", "16d8e460dae7203c6d1efa3f277e25b5af8b659febfc2f2eb4bacf87f128b80a", [:mix, :rebar3], [], "hexpm", "3c7ddc8a9394b92891db7c2771da94bf819834a1a4c92e30857b7d582e2f8257"},
|
||||
"json_ld": {:hex, :json_ld, "0.3.0", "92f508ca831b9e4530e3e6c950976fdafcf26323e6817c325b3e1ee78affc4bd", [:mix], [{:jason, "~> 1.1.1", [hex: :jason, repo: "hexpm", optional: false]}, {:rdf, "~> 0.5", [hex: :rdf, repo: "hexpm", optional: false]}], "hexpm", "fecfe1a013944df8922b6c642ec50ebcc41df35d2ab254787abd85c1f085a745"},
|
||||
"jsx": {:hex, :jsx, "2.8.3", "a05252d381885240744d955fbe3cf810504eb2567164824e19303ea59eef62cf", [:mix, :rebar3], [], "hexpm", "fc3499fed7a726995aa659143a248534adc754ebd16ccd437cd93b649a95091f"},
|
||||
"jumper": {:hex, :jumper, "1.0.1", "3c00542ef1a83532b72269fab9f0f0c82bf23a35e27d278bfd9ed0865cecabff", [:mix], [], "hexpm", "318c59078ac220e966d27af3646026db9b5a5e6703cb2aa3e26bcfaba65b7433"},
|
||||
"makeup": {:hex, :makeup, "1.0.0", "671df94cf5a594b739ce03b0d0316aa64312cee2574b6a44becb83cd90fb05dc", [:mix], [{:nimble_parsec, "~> 0.5.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "a10c6eb62cca416019663129699769f0c2ccf39428b3bb3c0cb38c718a0c186d"},
|
||||
"makeup_elixir": {:hex, :makeup_elixir, "0.14.0", "cf8b7c66ad1cff4c14679698d532f0b5d45a3968ffbcbfd590339cb57742f1ae", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "d4b316c7222a85bbaa2fd7c6e90e37e953257ad196dc229505137c5e505e9eff"},
|
||||
"meck": {:hex, :meck, "0.8.13", "ffedb39f99b0b99703b8601c6f17c7f76313ee12de6b646e671e3188401f7866", [:rebar3], [], "hexpm", "d34f013c156db51ad57cc556891b9720e6a1c1df5fe2e15af999c84d6cebeb1a"},
|
||||
"metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm", "69b09adddc4f74a40716ae54d140f93beb0fb8978d8636eaded0c31b6f099f16"},
|
||||
"mime": {:hex, :mime, "1.3.1", "30ce04ab3175b6ad0bdce0035cba77bba68b813d523d1aac73d9781b4d193cf8", [:mix], [], "hexpm", "6cbe761d6a0ca5a31a0931bf4c63204bceb64538e664a8ecf784a9a6f3b875f1"},
|
||||
"mimerl": {:hex, :mimerl, "1.2.0", "67e2d3f571088d5cfd3e550c383094b47159f3eee8ffa08e64106cdf5e981be3", [:rebar3], [], "hexpm", "f278585650aa581986264638ebf698f8bb19df297f66ad91b18910dfc6e19323"},
|
||||
"mimetype_parser": {:hex, :mimetype_parser, "0.1.3", "628ac9fe56aa7edcedb534d68397dd66674ab82493c8ebe39acb9a19b666099d", [:mix], [], "hexpm", "7d8f80c567807ce78cd93c938e7f4b0a20b1aaaaab914bf286f68457d9f7a852"},
|
||||
"mix_test_watch": {:hex, :mix_test_watch, "1.0.2", "34900184cbbbc6b6ed616ed3a8ea9b791f9fd2088419352a6d3200525637f785", [:mix], [{:file_system, "~> 0.2.1 or ~> 0.3", [hex: :file_system, repo: "hexpm", optional: false]}], "hexpm", "47ac558d8b06f684773972c6d04fcc15590abdb97aeb7666da19fcbfdc441a07"},
|
||||
"mmdb2_decoder": {:hex, :mmdb2_decoder, "2.1.0", "91933e121e5370c89fdac37bba85a84116869a7267e1e93b4a8e1fa5c6ef89e7", [:mix], [], "hexpm", "de38bd0efda06750b88dc6c8324a0b33c1de5638eb48cb64150cf23d541b8202"},
|
||||
"mochiweb": {:hex, :mochiweb, "2.20.1", "e4dbd0ed716f076366ecf62ada5755a844e1d95c781e8c77df1d4114be868cdf", [:rebar3], [], "hexpm", "d1aeee7870470d2fa9eae0b3d5ab6c33801aa2d82b10e9dade885c5c921b36aa"},
|
||||
"mock": {:hex, :mock, "0.3.4", "c5862eb3b8c64237f45f586cf00c9d892ba07bb48305a43319d428ce3c2897dd", [:mix], [{:meck, "~> 0.8.13", [hex: :meck, repo: "hexpm", optional: false]}], "hexpm", "e6d886252f1a41f4ba06ecf2b4c8d38760b34b1c08a11c28f7397b2e03995964"},
|
||||
"mogrify": {:hex, :mogrify, "0.7.3", "1494ee739f6e90de158dec4d4edee2d854d2f2d06a522e943f996ae176bca53d", [:mix], [], "hexpm", "b3e90a87171c23efa6b5910d735dd44f3fda15ba07a57cdb89cdb8ecaf83988d"},
|
||||
"nimble_parsec": {:hex, :nimble_parsec, "0.5.3", "def21c10a9ed70ce22754fdeea0810dafd53c2db3219a0cd54cf5526377af1c6", [:mix], [], "hexpm", "589b5af56f4afca65217a1f3eb3fee7e79b09c40c742fddc1c312b3ac0b3399f"},
|
||||
"oban": {:hex, :oban, "1.2.0", "7cca94d341be43d220571e28f69131c4afc21095b25257397f50973d3fc59b07", [:mix], [{:ecto_sql, "~> 3.1", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.14", [hex: :postgrex, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "ba5f8b3f7d76967b3e23cf8014f6a13e4ccb33431e4808f036709a7f822362ee"},
|
||||
"parse_trans": {:hex, :parse_trans, "3.3.0", "09765507a3c7590a784615cfd421d101aec25098d50b89d7aa1d66646bc571c1", [:rebar3], [], "hexpm", "17ef63abde837ad30680ea7f857dd9e7ced9476cdd7b0394432af4bfc241b960"},
|
||||
"phoenix": {:hex, :phoenix, "1.4.15", "5c39c330f46a33d752c6feceb25629ee8e62a158b997fea62bca59a59b28e3ea", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 1.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:plug, "~> 1.8.1 or ~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 1.0 or ~> 2.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "fd649bf11a76e42366096dc25737f89e8d4adedcbba6e969ae97f349f3cde5e7"},
|
||||
"phoenix_ecto": {:hex, :phoenix_ecto, "4.1.0", "a044d0756d0464c5a541b4a0bf4bcaf89bffcaf92468862408290682c73ae50d", [:mix], [{:ecto, "~> 3.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.9", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "c5e666a341ff104d0399d8f0e4ff094559b2fde13a5985d4cb5023b2c2ac558b"},
|
||||
"phoenix_html": {:hex, :phoenix_html, "2.14.0", "d8c6bc28acc8e65f8ea0080ee05aa13d912c8758699283b8d3427b655aabe284", [:mix], [{:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "b0bb30eda478a06dbfbe96728061a93833db3861a49ccb516f839ecb08493fbb"},
|
||||
"phoenix_live_reload": {:hex, :phoenix_live_reload, "1.2.1", "274a4b07c4adbdd7785d45a8b0bb57634d0b4f45b18d2c508b26c0344bd59b8f", [:mix], [{:file_system, "~> 0.2.1 or ~> 0.3", [hex: :file_system, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.4", [hex: :phoenix, repo: "hexpm", optional: false]}], "hexpm", "41b4103a2fa282cfd747d377233baf213c648fdcc7928f432937676532490eee"},
|
||||
"phoenix_pubsub": {:hex, :phoenix_pubsub, "1.1.2", "496c303bdf1b2e98a9d26e89af5bba3ab487ba3a3735f74bf1f4064d2a845a3e", [:mix], [], "hexpm", "1f13f9f0f3e769a667a6b6828d29dec37497a082d195cc52dbef401a9b69bf38"},
|
||||
"plug": {:hex, :plug, "1.9.0", "8d7c4e26962283ff9f8f3347bd73838e2413fbc38b7bb5467d5924f68f3a5a4a", [:mix], [{:mime, "~> 1.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: true]}], "hexpm", "9902eda2c52ada2a096434682e99a2493f5d06a94d6ac6bcfff9805f952350f1"},
|
||||
"plug_cowboy": {:hex, :plug_cowboy, "2.1.2", "8b0addb5908c5238fac38e442e81b6fcd32788eaa03246b4d55d147c47c5805e", [:mix], [{:cowboy, "~> 2.5", [hex: :cowboy, repo: "hexpm", optional: false]}, {:plug, "~> 1.7", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "7d722581ce865a237e14da6d946f92704101740a256bd13ec91e63c0b122fc70"},
|
||||
"plug_crypto": {:hex, :plug_crypto, "1.1.2", "bdd187572cc26dbd95b87136290425f2b580a116d3fb1f564216918c9730d227", [:mix], [], "hexpm", "6b8b608f895b6ffcfad49c37c7883e8df98ae19c6a28113b02aa1e9c5b22d6b5"},
|
||||
"poison": {:hex, :poison, "4.0.1", "bcb755a16fac91cad79bfe9fc3585bb07b9331e50cfe3420a24bcc2d735709ae", [:mix], [], "hexpm", "ba8836feea4b394bb718a161fc59a288fe0109b5006d6bdf97b6badfcf6f0f25"},
|
||||
"poolboy": {:hex, :poolboy, "1.5.2", "392b007a1693a64540cead79830443abf5762f5d30cf50bc95cb2c1aaafa006b", [:rebar3], [], "hexpm", "dad79704ce5440f3d5a3681c8590b9dc25d1a561e8f5a9c995281012860901e3"},
|
||||
"postgrex": {:hex, :postgrex, "0.15.3", "5806baa8a19a68c4d07c7a624ccdb9b57e89cbc573f1b98099e3741214746ae4", [:mix], [{:connection, "~> 1.0", [hex: :connection, repo: "hexpm", optional: false]}, {:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm", "4737ce62a31747b4c63c12b20c62307e51bb4fcd730ca0c32c280991e0606c90"},
|
||||
"progress_bar": {:hex, :progress_bar, "2.0.0", "447285f533b4b8717881fdb7160c7360c2f2ab57276f8904ce6d40482857e573", [:mix], [{:decimal, "~> 1.0", [hex: :decimal, repo: "hexpm", optional: false]}], "hexpm", "9d8b879f322fd5563e8e7ec39f1d02a9da3ffc36019f05287788744e88260fde"},
|
||||
"ranch": {:hex, :ranch, "1.7.1", "6b1fab51b49196860b733a49c07604465a47bdb78aa10c1c16a3d199f7f8c881", [:rebar3], [], "hexpm", "451d8527787df716d99dc36162fca05934915db0b6141bbdac2ea8d3c7afc7d7"},
|
||||
"rdf": {:hex, :rdf, "0.7.1", "2b707331902f31e95f0a86ca9705b385b84f84434e31f578da5aaf774293bab9", [:mix], [{:decimal, "~> 1.5", [hex: :decimal, repo: "hexpm", optional: false]}], "hexpm", "d42a891b1017169716bb2428df65ecc89dfe1efc6aeb9a91779522e1dbc50b1d"},
|
||||
"rsa_ex": {:hex, :rsa_ex, "0.4.0", "e28dd7dc5236e156df434af0e4aa822384c8866c928e17b785d4edb7c253b558", [:mix], [], "hexpm", "40e1f08e8401da4be59a6dd0f4da30c42d5bb01703161f0208d839d97db27f4e"},
|
||||
"sleeplocks": {:hex, :sleeplocks, "1.1.1", "3d462a0639a6ef36cc75d6038b7393ae537ab394641beb59830a1b8271faeed3", [:rebar3], [], "hexpm", "84ee37aeff4d0d92b290fff986d6a95ac5eedf9b383fadfd1d88e9b84a1c02e1"},
|
||||
"slugger": {:hex, :slugger, "0.3.0", "efc667ab99eee19a48913ccf3d038b1fb9f165fa4fbf093be898b8099e61b6ed", [:mix], [], "hexpm", "20d0ded0e712605d1eae6c5b4889581c3460d92623a930ddda91e0e609b5afba"},
|
||||
"ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.5", "6eaf7ad16cb568bb01753dbbd7a95ff8b91c7979482b95f38443fe2c8852a79b", [:make, :mix, :rebar3], [], "hexpm", "13104d7897e38ed7f044c4de953a6c28597d1c952075eb2e328bc6d6f2bfc496"},
|
||||
"telemetry": {:hex, :telemetry, "0.4.1", "ae2718484892448a24470e6aa341bc847c3277bfb8d4e9289f7474d752c09c7f", [:rebar3], [], "hexpm", "4738382e36a0a9a2b6e25d67c960e40e1a2c95560b9f936d8e29de8cd858480f"},
|
||||
"timex": {:hex, :timex, "3.6.1", "efdf56d0e67a6b956cc57774353b0329c8ab7726766a11547e529357ffdc1d56", [:mix], [{:combine, "~> 0.10", [hex: :combine, repo: "hexpm", optional: false]}, {:gettext, "~> 0.10", [hex: :gettext, repo: "hexpm", optional: false]}, {:tzdata, "~> 0.1.8 or ~> 0.5 or ~> 1.0.0", [hex: :tzdata, repo: "hexpm", optional: false]}], "hexpm", "f354efb2400dd7a80fd9eb6c8419068c4f632da4ac47f3d8822d6e33f08bc852"},
|
||||
"tzdata": {:hex, :tzdata, "1.0.3", "73470ad29dde46e350c60a66e6b360d3b99d2d18b74c4c349dbebbc27a09a3eb", [:mix], [{:hackney, "~> 1.0", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm", "a6e1ee7003c4d04ecbd21dd3ec690d4c6662db5d3bbdd7262d53cdf5e7c746c1"},
|
||||
"unicode_util_compat": {:hex, :unicode_util_compat, "0.4.1", "d869e4c68901dd9531385bb0c8c40444ebf624e60b6962d95952775cac5e90cd", [:rebar3], [], "hexpm", "1d1848c40487cdb0b30e8ed975e34e025860c02e419cb615d255849f3427439d"},
|
||||
"unsafe": {:hex, :unsafe, "1.0.1", "a27e1874f72ee49312e0a9ec2e0b27924214a05e3ddac90e91727bc76f8613d8", [:mix], [], "hexpm", "6c7729a2d214806450d29766abc2afaa7a2cbecf415be64f36a6691afebb50e5"},
|
||||
"xml_builder": {:hex, :xml_builder, "2.0.0", "371ed27bb63bf0598dbaf3f0c466e5dc7d16cb4ecb68f06a67f953654062e21b", [:mix], [], "hexpm", "baeb5c8d42204bac2b856ffd50e8cda42d63b622984538d18d92733e4e790fbd"},
|
||||
}
|
||||
|
|
|
@ -4,7 +4,7 @@ defmodule Mobilizon.Repo.Migrations.FixEventVisibility do
|
|||
def up do
|
||||
Mobilizon.Events.EventVisibility.create_type()
|
||||
Mobilizon.Events.EventStatus.create_type()
|
||||
Mobilizon.Events.CommentVisibility.create_type()
|
||||
Mobilizon.Conversations.CommentVisibility.create_type()
|
||||
|
||||
alter table(:events) do
|
||||
remove(:public)
|
||||
|
@ -34,6 +34,6 @@ defmodule Mobilizon.Repo.Migrations.FixEventVisibility do
|
|||
|
||||
Mobilizon.Events.EventVisibility.drop_type()
|
||||
Mobilizon.Events.EventStatus.drop_type()
|
||||
Mobilizon.Events.CommentVisibility.drop_type()
|
||||
Mobilizon.Conversations.CommentVisibility.drop_type()
|
||||
end
|
||||
end
|
||||
|
|
|
@ -1,30 +0,0 @@
|
|||
defmodule Mobilizon.Repo.Migrations.CreateCollections do
|
||||
use Ecto.Migration
|
||||
|
||||
def change do
|
||||
create table(:collections, primary_key: false) do
|
||||
add(:id, :uuid, primary_key: true)
|
||||
add(:title, :string)
|
||||
add(:url, :string)
|
||||
add(:summary, :string)
|
||||
add(:public, :boolean, default: false, null: false)
|
||||
add(:actor_id, references(:actors, on_delete: :delete_all), null: false)
|
||||
|
||||
timestamps()
|
||||
end
|
||||
|
||||
create table(:resource, primary_key: false) do
|
||||
add(:id, :uuid, primary_key: true)
|
||||
add(:title, :string)
|
||||
add(:url, :string)
|
||||
add(:summary, :string)
|
||||
add(:resource_url, :string)
|
||||
|
||||
add(:collection_id, references(:collections, on_delete: :delete_all, type: :uuid),
|
||||
null: false
|
||||
)
|
||||
|
||||
timestamps()
|
||||
end
|
||||
end
|
||||
end
|
|
@ -0,0 +1,23 @@
|
|||
defmodule Mobilizon.Repo.Migrations.CreateResources do
|
||||
use Ecto.Migration
|
||||
|
||||
def change do
|
||||
create table(:resource, primary_key: false) do
|
||||
add(:id, :uuid, primary_key: true)
|
||||
add(:title, :string, null: false)
|
||||
add(:url, :string, null: false)
|
||||
add(:type, :integer, null: false)
|
||||
add(:summary, :text)
|
||||
add(:resource_url, :string)
|
||||
add(:metadata, :map)
|
||||
add(:path, :string, null: false)
|
||||
|
||||
add(:parent_id, references(:resource, on_delete: :delete_all, type: :uuid), null: true)
|
||||
|
||||
add(:actor_id, references(:actors, on_delete: :delete_all), null: false)
|
||||
add(:creator_id, references(:actors, on_delete: :nilify_all), null: true)
|
||||
|
||||
timestamps()
|
||||
end
|
||||
end
|
||||
end
|
372
schema.graphql
372
schema.graphql
|
@ -1,5 +1,5 @@
|
|||
# source: http://localhost:4000/api
|
||||
# timestamp: Fri Apr 10 2020 15:48:40 GMT+0200 (GMT+02:00)
|
||||
# timestamp: Tue May 05 2020 17:26:14 GMT+0200 (heure d’été d’Europe centrale)
|
||||
|
||||
schema {
|
||||
query: RootQueryType
|
||||
|
@ -258,18 +258,6 @@ type Application implements Actor {
|
|||
url: String
|
||||
}
|
||||
|
||||
"""A collection"""
|
||||
type Collection {
|
||||
"""The collection's ID"""
|
||||
id: ID
|
||||
|
||||
"""The collection's resources"""
|
||||
resources: PaginatedResourceList
|
||||
|
||||
"""The collection's title"""
|
||||
title: String
|
||||
}
|
||||
|
||||
"""A comment"""
|
||||
type Comment implements ActionLogObject {
|
||||
actor: Person
|
||||
|
@ -323,6 +311,7 @@ type Config {
|
|||
name: String
|
||||
registrationsOpen: Boolean
|
||||
registrationsWhitelist: Boolean
|
||||
resourceProviders: [ResourceProvider]
|
||||
|
||||
"""The instance's terms"""
|
||||
terms(locale: String = "en"): Terms
|
||||
|
@ -725,9 +714,6 @@ type Group implements Actor {
|
|||
"""The actor's banner picture"""
|
||||
banner: Picture
|
||||
|
||||
"""A paginated list of the collections this group has"""
|
||||
collections: PaginatedCollectionList
|
||||
|
||||
"""A list of the conversations for this group"""
|
||||
conversations: PaginatedConversationList
|
||||
|
||||
|
@ -770,6 +756,9 @@ type Group implements Actor {
|
|||
"""The actor's preferred username"""
|
||||
preferredUsername: String
|
||||
|
||||
"""A paginated list of the resources this group has"""
|
||||
resources(limit: Int = 10, page: Int = 1): PaginatedResourceList
|
||||
|
||||
"""The actor's summary"""
|
||||
summary: String
|
||||
|
||||
|
@ -866,6 +855,13 @@ enum MemberRoleEnum {
|
|||
REJECTED
|
||||
}
|
||||
|
||||
"""
|
||||
The `Naive DateTime` scalar type represents a naive date and time without
|
||||
timezone. The DateTime appears in a JSON response as an ISO8601 formatted
|
||||
string.
|
||||
"""
|
||||
scalar NaiveDateTime
|
||||
|
||||
"""
|
||||
Describes how an actor is opened to follows
|
||||
|
||||
|
@ -881,14 +877,6 @@ enum Openness {
|
|||
OPEN
|
||||
}
|
||||
|
||||
type PaginatedCollectionList {
|
||||
"""A list of collections"""
|
||||
elements: [Collection]
|
||||
|
||||
"""The total number of collections in the list"""
|
||||
total: Int
|
||||
}
|
||||
|
||||
type PaginatedCommentList {
|
||||
"""A list of comments"""
|
||||
elements: [Comment]
|
||||
|
@ -1233,22 +1221,104 @@ enum ReportStatus {
|
|||
|
||||
"""A resource"""
|
||||
type Resource {
|
||||
"""The resource's owner"""
|
||||
actor: Actor
|
||||
|
||||
"""Children resources in folder"""
|
||||
children: PaginatedResourceList
|
||||
|
||||
"""The resource's creator"""
|
||||
creator: Actor
|
||||
|
||||
"""The resource's ID"""
|
||||
id: ID
|
||||
|
||||
"""The resource's creation date"""
|
||||
insertedAt: NaiveDateTime
|
||||
|
||||
"""The resource's metadata"""
|
||||
metadata: ResourceMetadata
|
||||
|
||||
"""The resource's parent"""
|
||||
parent: Resource
|
||||
|
||||
"""The resource's path"""
|
||||
path: String
|
||||
|
||||
"""The resource's URL"""
|
||||
resourceUrl: String
|
||||
|
||||
"""The resource's summary"""
|
||||
summary: String
|
||||
|
||||
"""The resource's title"""
|
||||
title: String
|
||||
|
||||
"""The resource's type (if it's a folder)"""
|
||||
type: String
|
||||
|
||||
"""The resource's last update date"""
|
||||
updatedAt: NaiveDateTime
|
||||
|
||||
"""The resource's URL"""
|
||||
url: String
|
||||
}
|
||||
|
||||
type ResourceMetadata {
|
||||
authorName: String
|
||||
authorUrl: String
|
||||
|
||||
"""The resource's metadata description"""
|
||||
description: String
|
||||
faviconUrl: String
|
||||
height: Int
|
||||
html: String
|
||||
|
||||
"""The resource's metadata image"""
|
||||
imageRemoteUrl: String
|
||||
providerName: String
|
||||
providerUrl: String
|
||||
|
||||
"""The resource's metadata title"""
|
||||
title: String
|
||||
|
||||
"""The type of the resource"""
|
||||
type: String
|
||||
width: Int
|
||||
}
|
||||
|
||||
type ResourceProvider {
|
||||
endpoint: String
|
||||
software: String
|
||||
type: String
|
||||
}
|
||||
|
||||
type RootMutationType {
|
||||
"""Confirm a participation"""
|
||||
confirmParticipation(confirmationToken: String!): Participant
|
||||
"""Create an user"""
|
||||
createUser(email: String!, locale: String, password: String!): User
|
||||
|
||||
"""Register a first profile on registration"""
|
||||
registerPerson(
|
||||
"""
|
||||
The avatar for the profile, either as an object or directly the ID of an existing Picture
|
||||
"""
|
||||
avatar: PictureInput
|
||||
|
||||
"""
|
||||
The banner for the profile, either as an object or directly the ID of an existing Picture
|
||||
"""
|
||||
banner: PictureInput
|
||||
|
||||
"""The email from the user previously created"""
|
||||
email: String!
|
||||
|
||||
"""The displayed name for the new profile"""
|
||||
name: String = ""
|
||||
preferredUsername: String!
|
||||
|
||||
"""The summary for the new profile"""
|
||||
summary: String = ""
|
||||
): Person
|
||||
|
||||
"""Join a group"""
|
||||
joinGroup(actorId: ID!, groupId: ID!): Member
|
||||
|
@ -1256,41 +1326,33 @@ type RootMutationType {
|
|||
"""Create a Feed Token"""
|
||||
createFeedToken(actorId: ID): FeedToken
|
||||
|
||||
"""Update a todo"""
|
||||
updateTodo(assignedToId: ID, dueDate: DateTime, id: ID!, status: Boolean, title: String, todoListId: ID): Todo
|
||||
deleteComment(actorId: ID!, commentId: ID!): Comment
|
||||
"""Get a preview for a resource link"""
|
||||
previewResourceLink(resourceUrl: String!): ResourceMetadata
|
||||
|
||||
"""Change an user email"""
|
||||
changeEmail(email: String!, password: String!): User
|
||||
"""Create a note on a report"""
|
||||
createReportNote(content: String, moderatorId: ID!, reportId: ID!): ReportNote
|
||||
|
||||
"""Create a comment"""
|
||||
createComment(actorId: ID!, eventId: ID, inReplyToCommentId: ID, text: String!): Comment
|
||||
|
||||
"""Delete a feed token"""
|
||||
deleteFeedToken(token: String!): DeletedFeedToken
|
||||
|
||||
"""Upload a picture"""
|
||||
uploadPicture(actorId: ID!, alt: String, file: Upload!, name: String!): Picture
|
||||
|
||||
"""Validate an user after registration"""
|
||||
validateUser(token: String!): Login
|
||||
|
||||
"""Update a comment"""
|
||||
updateComment(commentId: ID!, text: String!): Comment
|
||||
"""Create a conversation"""
|
||||
createConversation(actorId: ID!, creatorId: ID!, text: String!, title: String!): Conversation
|
||||
inviteMember(actorId: ID!, groupId: ID!, targetActorUsername: String!): Member
|
||||
|
||||
"""Leave an event"""
|
||||
leaveEvent(actorId: ID!, eventId: ID!, token: String): DeletedParticipant
|
||||
deleteReportNote(moderatorId: ID!, noteId: ID!): DeletedObject
|
||||
|
||||
"""Create an user"""
|
||||
createUser(email: String!, locale: String, password: String!): User
|
||||
"""Login an user"""
|
||||
login(email: String!, password: String!): Login
|
||||
|
||||
"""Send a link through email to reset user password"""
|
||||
sendResetPassword(email: String!, locale: String): String
|
||||
"""Change default actor for user"""
|
||||
changeDefaultActor(preferredUsername: String!): User
|
||||
|
||||
"""Resend registration confirmation token"""
|
||||
resendConfirmationEmail(email: String!, locale: String): String
|
||||
updateConversation(conversationId: ID!, title: String!): Conversation
|
||||
"""Join an event"""
|
||||
joinEvent(actorId: ID!, email: String, eventId: ID!, message: String): Participant
|
||||
|
||||
"""Create a todo"""
|
||||
createTodo(assignedToId: ID, dueDate: DateTime, status: Boolean, title: String!, todoListId: ID!): Todo
|
||||
|
||||
"""Change an user password"""
|
||||
changePassword(newPassword: String!, oldPassword: String!): User
|
||||
|
||||
"""Create a new person for user"""
|
||||
createPerson(
|
||||
|
@ -1311,47 +1373,20 @@ type RootMutationType {
|
|||
"""The summary for the new profile"""
|
||||
summary: String = ""
|
||||
): Person
|
||||
replyToConversation(conversationId: ID!, text: String!): Conversation
|
||||
|
||||
"""Add a relay subscription"""
|
||||
addRelay(address: String!): Follower
|
||||
|
||||
"""Join an event"""
|
||||
joinEvent(actorId: ID!, email: String, eventId: ID!, message: String): Participant
|
||||
|
||||
"""Create a group"""
|
||||
createGroup(
|
||||
"""
|
||||
The avatar for the group, either as an object or directly the ID of an existing Picture
|
||||
"""
|
||||
avatar: PictureInput
|
||||
|
||||
"""
|
||||
The banner for the group, either as an object or directly the ID of an existing Picture
|
||||
"""
|
||||
banner: PictureInput
|
||||
|
||||
"""The identity that creates the group"""
|
||||
creatorActorId: ID!
|
||||
|
||||
"""The displayed name for the group"""
|
||||
name: String
|
||||
|
||||
"""The name for the group"""
|
||||
preferredUsername: String!
|
||||
|
||||
"""The summary for the group"""
|
||||
summary: String = ""
|
||||
): Group
|
||||
"""Resend registration confirmation token"""
|
||||
resendConfirmationEmail(email: String!, locale: String): String
|
||||
|
||||
"""Create a todo list"""
|
||||
createTodoList(groupId: ID!, title: String!): TodoList
|
||||
|
||||
"""Change default actor for user"""
|
||||
changeDefaultActor(preferredUsername: String!): User
|
||||
"""Delete an event"""
|
||||
deleteEvent(actorId: ID!, eventId: ID!): DeletedObject
|
||||
|
||||
"""Create a conversation"""
|
||||
createConversation(actorId: ID!, creatorId: ID!, text: String!, title: String!): Conversation
|
||||
inviteMember(actorId: ID!, groupId: ID!, targetActorUsername: String!): Member
|
||||
"""Change an user email"""
|
||||
changeEmail(email: String!, password: String!): User
|
||||
updateConversation(conversationId: ID!, title: String!): Conversation
|
||||
|
||||
"""Create an event"""
|
||||
createEvent(
|
||||
|
@ -1381,31 +1416,39 @@ type RootMutationType {
|
|||
visibility: EventVisibility = PUBLIC
|
||||
): Event
|
||||
|
||||
"""Register a first profile on registration"""
|
||||
registerPerson(
|
||||
"""
|
||||
The avatar for the profile, either as an object or directly the ID of an existing Picture
|
||||
"""
|
||||
avatar: PictureInput
|
||||
"""Delete a feed token"""
|
||||
deleteFeedToken(token: String!): DeletedFeedToken
|
||||
|
||||
"""
|
||||
The banner for the profile, either as an object or directly the ID of an existing Picture
|
||||
"""
|
||||
banner: PictureInput
|
||||
"""Delete a resource"""
|
||||
deleteResource(id: ID!): DeletedObject
|
||||
|
||||
"""The email from the user previously created"""
|
||||
email: String!
|
||||
"""Reset user password"""
|
||||
resetPassword(locale: String = "en", password: String!, token: String!): Login
|
||||
deleteReportNote(moderatorId: ID!, noteId: ID!): DeletedObject
|
||||
|
||||
"""The displayed name for the new profile"""
|
||||
name: String = ""
|
||||
preferredUsername: String!
|
||||
"""Delete an identity"""
|
||||
deletePerson(id: ID!): Person
|
||||
|
||||
"""The summary for the new profile"""
|
||||
summary: String = ""
|
||||
): Person
|
||||
"""Upload a picture"""
|
||||
uploadPicture(actorId: ID!, alt: String, file: Upload!, name: String!): Picture
|
||||
|
||||
"""Delete a todo"""
|
||||
deleteTodo(id: ID!): DeletedObject
|
||||
"""Refresh a token"""
|
||||
refreshToken(refreshToken: String!): RefreshedToken
|
||||
|
||||
"""Delete a group"""
|
||||
deleteGroup(actorId: ID!, groupId: ID!): DeletedObject
|
||||
|
||||
"""Update a resource"""
|
||||
updateResource(actorId: ID, id: ID!, parentId: ID, path: String, resourceUrl: String, summary: String, title: String): Resource
|
||||
|
||||
"""Create a comment"""
|
||||
createComment(actorId: ID!, eventId: ID, inReplyToCommentId: ID, text: String!): Comment
|
||||
|
||||
"""Create a resource"""
|
||||
createResource(actorId: ID!, parentId: ID, path: String! = "/", resourceUrl: String, summary: String, title: String, type: String): Resource
|
||||
|
||||
"""Update a report"""
|
||||
updateReportStatus(moderatorId: ID!, reportId: ID!, status: ReportStatus!): Report
|
||||
|
||||
"""Update an event"""
|
||||
updateEvent(
|
||||
|
@ -1435,14 +1478,37 @@ type RootMutationType {
|
|||
visibility: EventVisibility = PUBLIC
|
||||
): Event
|
||||
|
||||
"""Create a note on a report"""
|
||||
createReportNote(content: String, moderatorId: ID!, reportId: ID!): ReportNote
|
||||
"""Confirm a participation"""
|
||||
confirmParticipation(confirmationToken: String!): Participant
|
||||
deleteConversation(conversationId: ID!): Conversation
|
||||
|
||||
"""Create a todo"""
|
||||
createTodo(assignedToId: ID, dueDate: DateTime, status: Boolean, title: String!, todoListId: ID!): Todo
|
||||
"""Reject a relay subscription"""
|
||||
rejectRelay(address: String!): Follower
|
||||
|
||||
"""Delete an account"""
|
||||
deleteAccount(password: String!): DeletedObject
|
||||
"""Accept a participation"""
|
||||
updateParticipation(id: ID!, moderatorActorId: ID!, role: ParticipantRoleEnum!): Participant
|
||||
|
||||
"""Delete a todo"""
|
||||
deleteTodo(id: ID!): DeletedObject
|
||||
deleteComment(actorId: ID!, commentId: ID!): Comment
|
||||
|
||||
"""Validate an user email"""
|
||||
validateEmail(token: String!): User
|
||||
|
||||
"""Send a link through email to reset user password"""
|
||||
sendResetPassword(email: String!, locale: String): String
|
||||
|
||||
"""Update a comment"""
|
||||
updateComment(commentId: ID!, text: String!): Comment
|
||||
|
||||
"""Accept a relay subscription"""
|
||||
acceptRelay(address: String!): Follower
|
||||
|
||||
"""Create a report"""
|
||||
createReport(commentsIds: [ID] = [""], content: String, eventId: ID, forward: Boolean = false, reportedId: ID!, reporterId: ID!): Report
|
||||
|
||||
"""Delete a relay subscription"""
|
||||
removeRelay(address: String!): Follower
|
||||
|
||||
"""Update an identity"""
|
||||
updatePerson(
|
||||
|
@ -1464,53 +1530,46 @@ type RootMutationType {
|
|||
summary: String
|
||||
): Person
|
||||
|
||||
"""Delete a relay subscription"""
|
||||
removeRelay(address: String!): Follower
|
||||
|
||||
"""Accept a participation"""
|
||||
updateParticipation(id: ID!, moderatorActorId: ID!, role: ParticipantRoleEnum!): Participant
|
||||
|
||||
"""Login an user"""
|
||||
login(email: String!, password: String!): Login
|
||||
|
||||
"""Delete a group"""
|
||||
deleteGroup(actorId: ID!, groupId: ID!): DeletedObject
|
||||
|
||||
"""Delete an event"""
|
||||
deleteEvent(actorId: ID!, eventId: ID!): DeletedObject
|
||||
replyToConversation(conversationId: ID!, text: String!): Conversation
|
||||
|
||||
"""Reset user password"""
|
||||
resetPassword(locale: String = "en", password: String!, token: String!): Login
|
||||
"""Validate an user after registration"""
|
||||
validateUser(token: String!): Login
|
||||
|
||||
"""Leave an event"""
|
||||
leaveGroup(actorId: ID!, groupId: ID!): DeletedMember
|
||||
|
||||
"""Accept a relay subscription"""
|
||||
acceptRelay(address: String!): Follower
|
||||
"""Delete an account"""
|
||||
deleteAccount(password: String!): DeletedObject
|
||||
|
||||
"""Reject a relay subscription"""
|
||||
rejectRelay(address: String!): Follower
|
||||
"""Create a group"""
|
||||
createGroup(
|
||||
"""
|
||||
The avatar for the group, either as an object or directly the ID of an existing Picture
|
||||
"""
|
||||
avatar: PictureInput
|
||||
|
||||
"""
|
||||
The banner for the group, either as an object or directly the ID of an existing Picture
|
||||
"""
|
||||
banner: PictureInput
|
||||
|
||||
"""The identity that creates the group"""
|
||||
creatorActorId: ID!
|
||||
|
||||
"""The displayed name for the group"""
|
||||
name: String
|
||||
|
||||
"""The name for the group"""
|
||||
preferredUsername: String!
|
||||
|
||||
"""The summary for the group"""
|
||||
summary: String = ""
|
||||
): Group
|
||||
|
||||
"""Add a relay subscription"""
|
||||
addRelay(address: String!): Follower
|
||||
saveAdminSettings(instanceDescription: String, instanceName: String, instanceTerms: String, instanceTermsType: InstanceTermsType, instanceTermsUrl: String, registrationsOpen: Boolean): AdminSettings
|
||||
|
||||
"""Validate an user email"""
|
||||
validateEmail(token: String!): User
|
||||
|
||||
"""Delete an identity"""
|
||||
deletePerson(id: ID!): Person
|
||||
deleteConversation(conversationId: ID!): Conversation
|
||||
|
||||
"""Update a report"""
|
||||
updateReportStatus(moderatorId: ID!, reportId: ID!, status: ReportStatus!): Report
|
||||
|
||||
"""Change an user password"""
|
||||
changePassword(newPassword: String!, oldPassword: String!): User
|
||||
|
||||
"""Refresh a token"""
|
||||
refreshToken(refreshToken: String!): RefreshedToken
|
||||
|
||||
"""Create a report"""
|
||||
createReport(commentsIds: [ID] = [""], content: String, eventId: ID, forward: Boolean = false, reportedId: ID!, reporterId: ID!): Report
|
||||
"""Update a todo"""
|
||||
updateTodo(assignedToId: ID, dueDate: DateTime, id: ID!, status: Boolean, title: String, todoListId: ID): Todo
|
||||
}
|
||||
|
||||
"""
|
||||
|
@ -1567,6 +1626,9 @@ type RootQueryType {
|
|||
"""Get all reports"""
|
||||
reports(limit: Int = 10, page: Int = 1, status: ReportStatus = OPEN): [Report]
|
||||
|
||||
"""Get a resource"""
|
||||
resource(id: ID, path: String, username: String): Resource
|
||||
|
||||
"""Reverse geocode coordinates"""
|
||||
reverseGeocode(latitude: Float!, locale: String = "en", longitude: Float!, zoom: Int = 15): [Address]
|
||||
|
||||
|
@ -1677,7 +1739,7 @@ type TodoList {
|
|||
"""The todo list's title"""
|
||||
title: String
|
||||
|
||||
"""The collection's todos"""
|
||||
"""The todo-list's todos"""
|
||||
todos: PaginatedTodoList
|
||||
}
|
||||
|
||||
|
|
|
@ -0,0 +1,584 @@
|
|||
defmodule Mobilizon.GraphQL.Resolvers.ResourceTest do
|
||||
use Mobilizon.Web.ConnCase
|
||||
|
||||
import Mobilizon.Factory
|
||||
|
||||
alias Mobilizon.Actors.{Actor, Member}
|
||||
alias Mobilizon.Resources.Resource
|
||||
alias Mobilizon.Users.User
|
||||
|
||||
alias Mobilizon.GraphQL.AbsintheHelpers
|
||||
|
||||
@metadata_fragment """
|
||||
fragment ResourceMetadataBasicFields on ResourceMetadata {
|
||||
imageRemoteUrl,
|
||||
height,
|
||||
width,
|
||||
type,
|
||||
faviconUrl
|
||||
}
|
||||
"""
|
||||
|
||||
@get_group_resources """
|
||||
query($name: String!) {
|
||||
group(preferredUsername: $name) {
|
||||
id,
|
||||
url,
|
||||
name,
|
||||
domain,
|
||||
summary,
|
||||
preferredUsername,
|
||||
resources(page: 1, limit: 3) {
|
||||
elements {
|
||||
id,
|
||||
title,
|
||||
resourceUrl,
|
||||
summary,
|
||||
updatedAt,
|
||||
type,
|
||||
path,
|
||||
metadata {
|
||||
...ResourceMetadataBasicFields
|
||||
}
|
||||
},
|
||||
total
|
||||
},
|
||||
}
|
||||
}
|
||||
#{@metadata_fragment}
|
||||
"""
|
||||
|
||||
@get_resource """
|
||||
query GetResource($id: ID, $path: String, $username: String) {
|
||||
resource(id: $id, path: $path, username: $username) {
|
||||
id,
|
||||
title,
|
||||
summary,
|
||||
url,
|
||||
path,
|
||||
metadata {
|
||||
...ResourceMetadataBasicFields
|
||||
authorName,
|
||||
authorUrl,
|
||||
providerName,
|
||||
providerUrl,
|
||||
html
|
||||
},
|
||||
parent {
|
||||
id
|
||||
},
|
||||
actor {
|
||||
id,
|
||||
preferredUsername
|
||||
},
|
||||
children {
|
||||
total,
|
||||
elements {
|
||||
id,
|
||||
title,
|
||||
summary,
|
||||
url,
|
||||
type,
|
||||
path,
|
||||
resourceUrl,
|
||||
metadata {
|
||||
...ResourceMetadataBasicFields
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
#{@metadata_fragment}
|
||||
"""
|
||||
|
||||
@create_resource """
|
||||
mutation CreateResource($title: String!, $parentId: ID, $summary: String, $actorId: ID!, $resourceUrl: String, $type: String) {
|
||||
createResource(title: $title, parentId: $parentId, summary: $summary, actorId: $actorId, resourceUrl: $resourceUrl, type: $type) {
|
||||
id,
|
||||
title,
|
||||
summary,
|
||||
url,
|
||||
resourceUrl,
|
||||
updatedAt,
|
||||
path,
|
||||
type,
|
||||
metadata {
|
||||
...ResourceMetadataBasicFields
|
||||
authorName,
|
||||
authorUrl,
|
||||
providerName,
|
||||
providerUrl,
|
||||
html
|
||||
}
|
||||
}
|
||||
}
|
||||
#{@metadata_fragment}
|
||||
"""
|
||||
|
||||
@update_resource """
|
||||
mutation UpdateResource($id: ID!, $title: String, $summary: String, $parentId: ID, $resourceUrl: String) {
|
||||
updateResource(id: $id, title: $title, parentId: $parentId, summary: $summary, resourceUrl: $resourceUrl) {
|
||||
id,
|
||||
title,
|
||||
summary,
|
||||
url,
|
||||
path,
|
||||
resourceUrl,
|
||||
type
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
@resource_url "https://framasoft.org/fr/full"
|
||||
@resource_title "my resource"
|
||||
@updated_resource_title "my updated resource"
|
||||
@folder_title "my folder"
|
||||
|
||||
setup do
|
||||
%User{} = user = insert(:user)
|
||||
%Actor{} = actor = insert(:actor, user: user)
|
||||
%Actor{} = group = insert(:group)
|
||||
%Member{} = insert(:member, parent: group, actor: actor, role: :member)
|
||||
resource_in_root = %Resource{} = insert(:resource, actor: group)
|
||||
|
||||
folder_in_root =
|
||||
%Resource{id: parent_id, path: parent_path} =
|
||||
insert(:resource, type: :folder, resource_url: nil, actor: group)
|
||||
|
||||
resource_in_folder =
|
||||
%Resource{} =
|
||||
insert(:resource,
|
||||
resource_url: nil,
|
||||
actor: group,
|
||||
parent_id: parent_id,
|
||||
path: "#{parent_path}/titre",
|
||||
title: "titre"
|
||||
)
|
||||
|
||||
{:ok,
|
||||
user: user,
|
||||
group: group,
|
||||
root_resources: [folder_in_root, resource_in_root],
|
||||
resource_in_folder: resource_in_folder}
|
||||
end
|
||||
|
||||
describe "Resolver: Get group's resources" do
|
||||
test "find_resources_for_group/3", %{
|
||||
conn: conn,
|
||||
user: user,
|
||||
group: group,
|
||||
root_resources: root_resources,
|
||||
resource_in_folder: resource_in_folder
|
||||
} do
|
||||
res =
|
||||
conn
|
||||
|> auth_conn(user)
|
||||
|> AbsintheHelpers.graphql_query(
|
||||
query: @get_group_resources,
|
||||
variables: %{
|
||||
name: group.preferred_username
|
||||
}
|
||||
)
|
||||
|
||||
assert is_nil(res["errors"])
|
||||
|
||||
assert res["data"]["group"]["resources"]["total"] == 3
|
||||
|
||||
assert res["data"]["group"]["resources"]["elements"]
|
||||
|> Enum.map(&{&1["path"], &1["type"]})
|
||||
|> MapSet.new() ==
|
||||
(root_resources ++ [resource_in_folder])
|
||||
|> Enum.map(&{&1.path, Atom.to_string(&1.type)})
|
||||
|> MapSet.new()
|
||||
end
|
||||
|
||||
test "find_resources_for_group/3 when not member of group", %{
|
||||
conn: conn,
|
||||
group: group
|
||||
} do
|
||||
%User{} = user = insert(:user)
|
||||
%Actor{} = actor = insert(:actor, user: user)
|
||||
|
||||
res =
|
||||
conn
|
||||
|> auth_conn(user)
|
||||
|> AbsintheHelpers.graphql_query(
|
||||
query: @get_group_resources,
|
||||
variables: %{
|
||||
name: group.preferred_username
|
||||
}
|
||||
)
|
||||
|
||||
assert is_nil(res["errors"])
|
||||
|
||||
assert res["data"]["group"]["resources"]["total"] == 0
|
||||
assert res["data"]["group"]["resources"]["elements"] == []
|
||||
end
|
||||
|
||||
test "find_resources_for_group/3 when not connected", %{
|
||||
conn: conn,
|
||||
group: group
|
||||
} do
|
||||
res =
|
||||
conn
|
||||
|> AbsintheHelpers.graphql_query(
|
||||
query: @get_group_resources,
|
||||
variables: %{
|
||||
name: group.preferred_username
|
||||
}
|
||||
)
|
||||
|
||||
assert is_nil(res["errors"])
|
||||
|
||||
assert res["data"]["group"]["resources"]["total"] == 0
|
||||
assert res["data"]["group"]["resources"]["elements"] == []
|
||||
end
|
||||
end
|
||||
|
||||
describe "Resolver: Get a specific resource" do
|
||||
test "get_resource/3 for the root path", %{
|
||||
conn: conn,
|
||||
user: user,
|
||||
group: group,
|
||||
root_resources: root_resources
|
||||
} do
|
||||
res =
|
||||
conn
|
||||
|> auth_conn(user)
|
||||
|> AbsintheHelpers.graphql_query(
|
||||
query: @get_resource,
|
||||
variables: %{
|
||||
path: "/",
|
||||
username: group.preferred_username
|
||||
}
|
||||
)
|
||||
|
||||
assert is_nil(res["errors"])
|
||||
|
||||
assert res["data"]["resource"]["path"] == "/"
|
||||
assert String.starts_with?(res["data"]["resource"]["id"], "root_")
|
||||
|
||||
assert res["data"]["resource"]["children"]["elements"]
|
||||
|> Enum.map(& &1["id"])
|
||||
|> MapSet.new() == root_resources |> Enum.map(& &1.id) |> MapSet.new()
|
||||
end
|
||||
|
||||
test "get_resource/3 for a folder path", %{
|
||||
conn: conn,
|
||||
user: user,
|
||||
group: group,
|
||||
root_resources: [root_folder, _],
|
||||
resource_in_folder: resource_in_folder
|
||||
} do
|
||||
res =
|
||||
conn
|
||||
|> auth_conn(user)
|
||||
|> AbsintheHelpers.graphql_query(
|
||||
query: @get_resource,
|
||||
variables: %{
|
||||
path: root_folder.path,
|
||||
username: group.preferred_username
|
||||
}
|
||||
)
|
||||
|
||||
assert is_nil(res["errors"])
|
||||
|
||||
assert res["data"]["resource"]["path"] == root_folder.path
|
||||
assert is_nil(res["data"]["resource"]["parent"]["id"])
|
||||
|
||||
assert res["data"]["resource"]["children"]["total"] == 1
|
||||
|
||||
assert res["data"]["resource"]["children"]["elements"]
|
||||
|> Enum.map(& &1["id"])
|
||||
|> MapSet.new() == [resource_in_folder] |> Enum.map(& &1.id) |> MapSet.new()
|
||||
end
|
||||
|
||||
test "get_resource/3 for a non-existing path", %{
|
||||
conn: conn,
|
||||
user: user,
|
||||
group: group
|
||||
} do
|
||||
res =
|
||||
conn
|
||||
|> auth_conn(user)
|
||||
|> AbsintheHelpers.graphql_query(
|
||||
query: @get_resource,
|
||||
variables: %{
|
||||
path: "/non existing",
|
||||
username: group.preferred_username
|
||||
}
|
||||
)
|
||||
|
||||
assert hd(res["errors"])["message"] == "No such resource"
|
||||
end
|
||||
|
||||
test "get_resource/3 for a non-existing group", %{
|
||||
conn: conn,
|
||||
user: user
|
||||
} do
|
||||
%Actor{preferred_username: group_name} = group = insert(:group)
|
||||
|
||||
res =
|
||||
conn
|
||||
|> auth_conn(user)
|
||||
|> AbsintheHelpers.graphql_query(
|
||||
query: @get_resource,
|
||||
variables: %{
|
||||
path: "/non existing",
|
||||
username: group_name
|
||||
}
|
||||
)
|
||||
|
||||
assert hd(res["errors"])["message"] == "Actor is not member of group"
|
||||
end
|
||||
|
||||
test "get_resource/3 when not connected", %{
|
||||
conn: conn,
|
||||
group: group,
|
||||
resource_in_folder: resource_in_folder
|
||||
} do
|
||||
res =
|
||||
conn
|
||||
|> AbsintheHelpers.graphql_query(
|
||||
query: @get_resource,
|
||||
variables: %{
|
||||
path: resource_in_folder.path,
|
||||
username: group.preferred_username
|
||||
}
|
||||
)
|
||||
|
||||
assert hd(res["errors"])["message"] == "You need to be logged-in to access resources"
|
||||
end
|
||||
end
|
||||
|
||||
describe "Resolver: Create a resource" do
|
||||
test "create_resource/3 creates a resource for a group", %{
|
||||
conn: conn,
|
||||
user: user,
|
||||
group: group
|
||||
} do
|
||||
res =
|
||||
conn
|
||||
|> auth_conn(user)
|
||||
|> AbsintheHelpers.graphql_query(
|
||||
query: @create_resource,
|
||||
variables: %{
|
||||
title: @resource_title,
|
||||
parentId: nil,
|
||||
actorId: group.id,
|
||||
resourceUrl: @resource_url
|
||||
}
|
||||
)
|
||||
|
||||
assert is_nil(res["errors"])
|
||||
|
||||
assert res["data"]["createResource"]["metadata"]["faviconUrl"] ==
|
||||
"https://framasoft.org/icons/favicon.png"
|
||||
|
||||
assert res["data"]["createResource"]["metadata"]["imageRemoteUrl"] ==
|
||||
"https://framasoft.org/img/opengraph/full.jpg"
|
||||
|
||||
assert res["data"]["createResource"]["path"] == "/#{@resource_title}"
|
||||
assert res["data"]["createResource"]["resourceUrl"] == @resource_url
|
||||
assert res["data"]["createResource"]["title"] == @resource_title
|
||||
assert res["data"]["createResource"]["type"] == "link"
|
||||
end
|
||||
|
||||
test "create_resource/3 creates a folder", %{conn: conn, user: user, group: group} do
|
||||
%User{} = user = insert(:user)
|
||||
%Actor{} = actor = insert(:actor, user: user)
|
||||
%Actor{} = group = insert(:group)
|
||||
%Member{} = insert(:member, parent: group, actor: actor, role: :member)
|
||||
|
||||
res =
|
||||
conn
|
||||
|> auth_conn(user)
|
||||
|> AbsintheHelpers.graphql_query(
|
||||
query: @create_resource,
|
||||
variables: %{
|
||||
title: @folder_title,
|
||||
parentId: nil,
|
||||
actorId: group.id,
|
||||
type: "folder"
|
||||
}
|
||||
)
|
||||
|
||||
assert is_nil(res["errors"])
|
||||
|
||||
assert res["data"]["createResource"]["path"] == "/#{@folder_title}"
|
||||
assert res["data"]["createResource"]["title"] == @folder_title
|
||||
assert res["data"]["createResource"]["type"] == "folder"
|
||||
end
|
||||
|
||||
test "create_resource/3 creates a resource in a folder", %{
|
||||
conn: conn,
|
||||
user: user,
|
||||
group: group
|
||||
} do
|
||||
%Resource{id: parent_id, path: parent_path} =
|
||||
insert(:resource, type: :folder, resource_url: nil, actor: group)
|
||||
|
||||
res =
|
||||
conn
|
||||
|> auth_conn(user)
|
||||
|> AbsintheHelpers.graphql_query(
|
||||
query: @create_resource,
|
||||
variables: %{
|
||||
title: @resource_title,
|
||||
parentId: parent_id,
|
||||
actorId: group.id,
|
||||
resourceUrl: @resource_url
|
||||
}
|
||||
)
|
||||
|
||||
assert is_nil(res["errors"])
|
||||
|
||||
assert res["data"]["createResource"]["metadata"]["faviconUrl"] ==
|
||||
"https://framasoft.org/icons/favicon.png"
|
||||
|
||||
assert res["data"]["createResource"]["metadata"]["imageRemoteUrl"] ==
|
||||
"https://framasoft.org/img/opengraph/full.jpg"
|
||||
|
||||
assert res["data"]["createResource"]["path"] == "#{parent_path}/#{@resource_title}"
|
||||
assert res["data"]["createResource"]["resourceUrl"] == @resource_url
|
||||
assert res["data"]["createResource"]["title"] == @resource_title
|
||||
assert res["data"]["createResource"]["type"] == "link"
|
||||
end
|
||||
|
||||
test "create_resource/3 doesn't create a resource in a folder if no group is defined", %{
|
||||
conn: conn,
|
||||
user: user,
|
||||
group: group
|
||||
} do
|
||||
res =
|
||||
conn
|
||||
|> auth_conn(user)
|
||||
|> AbsintheHelpers.graphql_query(
|
||||
query: @create_resource,
|
||||
variables: %{
|
||||
title: @resource_title,
|
||||
parentId: nil,
|
||||
resourceUrl: @resource_url
|
||||
}
|
||||
)
|
||||
|
||||
assert Enum.map(res["errors"], & &1["message"]) == [
|
||||
"In argument \"actorId\": Expected type \"ID!\", found null.",
|
||||
"Variable \"actorId\": Expected non-null, found null."
|
||||
]
|
||||
end
|
||||
|
||||
test "create_resource/3 doesn't create a resource if the actor is not a member of the group",
|
||||
%{
|
||||
conn: conn,
|
||||
group: group
|
||||
} do
|
||||
%User{} = user = insert(:user)
|
||||
%Actor{} = actor = insert(:actor, user: user)
|
||||
|
||||
res =
|
||||
conn
|
||||
|> auth_conn(user)
|
||||
|> AbsintheHelpers.graphql_query(
|
||||
query: @create_resource,
|
||||
variables: %{
|
||||
title: @resource_title,
|
||||
parentId: nil,
|
||||
actorId: group.id,
|
||||
resourceUrl: @resource_url
|
||||
}
|
||||
)
|
||||
|
||||
assert Enum.map(res["errors"], & &1["message"]) == [
|
||||
"Actor id is not member of group"
|
||||
]
|
||||
end
|
||||
|
||||
test "create_resource/3 doesn't create a resource if the referenced parent folder is not owned by the group",
|
||||
%{
|
||||
conn: conn,
|
||||
user: user,
|
||||
group: group
|
||||
} do
|
||||
%Actor{} = group2 = insert(:group)
|
||||
|
||||
%Resource{id: parent_id, path: parent_path} =
|
||||
insert(:resource, type: :folder, resource_url: nil, actor: group2)
|
||||
|
||||
res =
|
||||
conn
|
||||
|> auth_conn(user)
|
||||
|> AbsintheHelpers.graphql_query(
|
||||
query: @create_resource,
|
||||
variables: %{
|
||||
title: @resource_title,
|
||||
parentId: parent_id,
|
||||
actorId: group.id,
|
||||
resourceUrl: @resource_url
|
||||
}
|
||||
)
|
||||
|
||||
assert Enum.map(res["errors"], & &1["message"]) == [
|
||||
"Parent resource doesn't match this group"
|
||||
]
|
||||
end
|
||||
end
|
||||
|
||||
describe "Resolver: Update a resource" do
|
||||
test "update_resource/3 renames a resource for a group", %{
|
||||
conn: conn,
|
||||
user: user,
|
||||
group: group
|
||||
} do
|
||||
%Resource{id: resource_id} =
|
||||
resource = insert(:resource, resource_url: @resource_url, actor: group)
|
||||
|
||||
res =
|
||||
conn
|
||||
|> auth_conn(user)
|
||||
|> AbsintheHelpers.graphql_query(
|
||||
query: @update_resource,
|
||||
variables: %{
|
||||
id: resource_id,
|
||||
title: @updated_resource_title
|
||||
}
|
||||
)
|
||||
|
||||
assert is_nil(res["errors"])
|
||||
|
||||
assert res["data"]["updateResource"]["path"] == "/#{@updated_resource_title}"
|
||||
assert res["data"]["updateResource"]["resourceUrl"] == @resource_url
|
||||
assert res["data"]["updateResource"]["title"] == @updated_resource_title
|
||||
assert res["data"]["updateResource"]["type"] == "link"
|
||||
end
|
||||
|
||||
test "update_resource/3 moves and renames a resource for a group", %{
|
||||
conn: conn,
|
||||
user: user,
|
||||
group: group,
|
||||
root_resources: [root_folder, _],
|
||||
} do
|
||||
%Resource{id: resource_id} =
|
||||
resource = insert(:resource, resource_url: @resource_url, actor: group)
|
||||
|
||||
res =
|
||||
conn
|
||||
|> auth_conn(user)
|
||||
|> AbsintheHelpers.graphql_query(
|
||||
query: @update_resource,
|
||||
variables: %{
|
||||
id: resource_id,
|
||||
title: @updated_resource_title,
|
||||
parentId: root_folder.id
|
||||
}
|
||||
)
|
||||
|
||||
assert is_nil(res["errors"])
|
||||
|
||||
assert res["data"]["updateResource"]["path"] == "#{root_folder.path}/#{@updated_resource_title}"
|
||||
assert res["data"]["updateResource"]["resourceUrl"] == @resource_url
|
||||
assert res["data"]["updateResource"]["title"] == @updated_resource_title
|
||||
assert res["data"]["updateResource"]["type"] == "link"
|
||||
end
|
||||
end
|
||||
end
|
|
@ -4,6 +4,7 @@ defmodule Mobilizon.GraphQL.AbsintheHelpers do
|
|||
"""
|
||||
|
||||
use Phoenix.ConnTest
|
||||
alias Plug.Conn
|
||||
|
||||
@endpoint Mobilizon.Web.Endpoint
|
||||
|
||||
|
@ -23,6 +24,7 @@ defmodule Mobilizon.GraphQL.AbsintheHelpers do
|
|||
}
|
||||
end
|
||||
|
||||
@spec graphql_query(Conn.t(), Keyword.t()) :: map | no_return
|
||||
def graphql_query(conn, options) do
|
||||
conn
|
||||
|> post("/api", build_query(options[:query], Keyword.get(options, :variables, %{})))
|
||||
|
|
|
@ -266,7 +266,7 @@ defmodule Mobilizon.Factory do
|
|||
title: sequence("todo list"),
|
||||
actor: build(:group),
|
||||
id: uuid,
|
||||
url: Routes.page_url(Endpoint, :todo_list, uuid)
|
||||
url: Routes.todo_list_url(Endpoint, :todo_list, uuid)
|
||||
}
|
||||
end
|
||||
|
||||
|
@ -280,8 +280,25 @@ defmodule Mobilizon.Factory do
|
|||
status: false,
|
||||
due_date: Timex.shift(DateTime.utc_now(), hours: 2),
|
||||
assigned_to: build(:actor),
|
||||
url: Routes.page_url(Endpoint, :todo, uuid),
|
||||
url: Routes.todo_url(Endpoint, :todo, uuid),
|
||||
creator: build(:actor)
|
||||
}
|
||||
end
|
||||
|
||||
def resource_factory do
|
||||
uuid = Ecto.UUID.generate()
|
||||
title = sequence("my resource")
|
||||
|
||||
%Mobilizon.Resources.Resource{
|
||||
id: uuid,
|
||||
title: title,
|
||||
type: :link,
|
||||
resource_url: "https://somewebsite.com/path",
|
||||
actor: build(:group),
|
||||
creator: build(:actor),
|
||||
parent: nil,
|
||||
url: Routes.resource_url(Endpoint, :resource, uuid),
|
||||
path: "/#{title}"
|
||||
}
|
||||
end
|
||||
end
|
||||
|
|
Loading…
Reference in New Issue