Introduce group posts

Signed-off-by: Thomas Citharel <tcit@tcit.fr>
This commit is contained in:
Thomas Citharel 2020-07-09 17:24:28 +02:00
parent a6fad4bc17
commit a338caca5e
249 changed files with 11886 additions and 5023 deletions

View File

@ -163,6 +163,8 @@ config :auto_linker,
rel: "noopener noreferrer ugc" rel: "noopener noreferrer ugc"
] ]
config :tesla, adapter: Tesla.Adapter.Hackney
config :phoenix, :format_encoders, json: Jason, "activity-json": Jason config :phoenix, :format_encoders, json: Jason, "activity-json": Jason
config :phoenix, :json_library, Jason config :phoenix, :json_library, Jason

View File

@ -44,6 +44,11 @@ config :mobilizon, Mobilizon.Web.Upload.Uploader.Local, uploads: "test/uploads"
config :exvcr, config :exvcr,
vcr_cassette_library_dir: "test/fixtures/vcr_cassettes" vcr_cassette_library_dir: "test/fixtures/vcr_cassettes"
config :tesla, Mobilizon.Service.HTTP.ActivityPub,
adapter: Mobilizon.Service.HTTP.ActivityPub.Mock
config :tesla, Mobilizon.Service.HTTP.BaseClient, adapter: Mobilizon.Service.HTTP.BaseClient.Mock
config :mobilizon, Mobilizon.Service.Geospatial, service: Mobilizon.Service.Geospatial.Mock config :mobilizon, Mobilizon.Service.Geospatial, service: Mobilizon.Service.Geospatial.Mock
config :mobilizon, Oban, queues: false, prune: :disabled, crontab: false config :mobilizon, Oban, queues: false, prune: :disabled, crontab: false

View File

@ -49,7 +49,7 @@
"vue-router": "^3.1.6", "vue-router": "^3.1.6",
"vue-scrollto": "^2.17.1", "vue-scrollto": "^2.17.1",
"vue2-leaflet": "^2.0.3", "vue2-leaflet": "^2.0.3",
"vuedraggable": "^2.23.2" "vuedraggable": "2.23.2"
}, },
"devDependencies": { "devDependencies": {
"@types/chai": "^4.2.11", "@types/chai": "^4.2.11",
@ -90,7 +90,7 @@
"prettier-eslint": "^10.1.1", "prettier-eslint": "^10.1.1",
"sass-loader": "^8.0.2", "sass-loader": "^8.0.2",
"typescript": "~3.9.3", "typescript": "~3.9.3",
"vue-cli-plugin-styleguidist": "~4.26.0", "vue-cli-plugin-styleguidist": "~4.29.1",
"vue-cli-plugin-svg": "~0.1.3", "vue-cli-plugin-svg": "~0.1.3",
"vue-i18n-extract": "^1.0.2", "vue-i18n-extract": "^1.0.2",
"vue-template-compiler": "^2.6.11", "vue-template-compiler": "^2.6.11",

View File

@ -59,6 +59,7 @@ import { initializeCurrentActor } from "./utils/auth";
import { CONFIG } from "./graphql/config"; import { CONFIG } from "./graphql/config";
import { IConfig } from "./types/config.model"; import { IConfig } from "./types/config.model";
import { ICurrentUser } from "./types/current-user.model"; import { ICurrentUser } from "./types/current-user.model";
@Component({ @Component({
apollo: { apollo: {
currentUser: CURRENT_USER_CLIENT, currentUser: CURRENT_USER_CLIENT,
@ -72,6 +73,7 @@ import { ICurrentUser } from "./types/current-user.model";
}) })
export default class App extends Vue { export default class App extends Vue {
config!: IConfig; config!: IConfig;
currentUser!: ICurrentUser; currentUser!: ICurrentUser;
async created() { async created() {

View File

@ -138,7 +138,7 @@ import { IEvent, CommentModeration } from "../../types/event.model";
import ReportModal from "../Report/ReportModal.vue"; import ReportModal from "../Report/ReportModal.vue";
import { IReport } from "../../types/report.model"; import { IReport } from "../../types/report.model";
import { CREATE_REPORT } from "../../graphql/report"; import { CREATE_REPORT } from "../../graphql/report";
import PopoverActorCard from "../../components/Account/PopoverActorCard.vue"; import PopoverActorCard from "../Account/PopoverActorCard.vue";
@Component({ @Component({
apollo: { apollo: {

View File

@ -12,7 +12,9 @@
<span>@{{ comment.actor.preferredUsername }}</span> <span>@{{ comment.actor.preferredUsername }}</span>
</div> </div>
<div class="post-infos"> <div class="post-infos">
<span>{{ comment.updatedAt | formatDateTimeString }}</span> <span :title="comment.insertedAt | formatDateTimeString">
{{ $timeAgo.format(comment.insertedAt, "twitter") || $t("Right now") }}</span
>
</div> </div>
</div> </div>
<div class="description-content" v-html="comment.text"></div> <div class="description-content" v-html="comment.text"></div>
@ -21,10 +23,10 @@
</template> </template>
<script lang="ts"> <script lang="ts">
import { Component, Prop, Vue } from "vue-property-decorator"; import { Component, Prop, Vue } from "vue-property-decorator";
import { IComment } from "../../types/comment.model"; import { IComment, CommentModel } from "../../types/comment.model";
@Component @Component
export default class ConversationComment extends Vue { export default class DiscussionComment extends Vue {
@Prop({ required: true, type: Object }) comment!: IComment; @Prop({ required: true, type: Object }) comment!: IComment;
} }
</script> </script>

View File

@ -1,42 +1,45 @@
<template> <template>
<router-link <router-link
class="conversation-minimalist-card-wrapper" class="discussion-minimalist-card-wrapper"
:to="{ name: RouteName.CONVERSATION, params: { slug: conversation.slug, id: conversation.id } }" :to="{ name: RouteName.DISCUSSION, params: { slug: discussion.slug, id: discussion.id } }"
> >
<div class="media-left"> <div class="media-left">
<figure class="image is-32x32" v-if="conversation.lastComment.actor.avatar"> <figure class="image is-32x32" v-if="discussion.lastComment.actor.avatar">
<img class="is-rounded" :src="conversation.lastComment.actor.avatar.url" alt /> <img class="is-rounded" :src="discussion.lastComment.actor.avatar.url" alt />
</figure> </figure>
<b-icon v-else size="is-medium" icon="account-circle" /> <b-icon v-else size="is-medium" icon="account-circle" />
</div> </div>
<div class="title-info-wrapper"> <div class="title-info-wrapper">
<p class="conversation-minimalist-title">{{ conversation.title }}</p> <p class="discussion-minimalist-title">{{ discussion.title }}</p>
<div class="has-text-grey">{{ htmlTextEllipsis }}</div> <div class="has-text-grey">{{ htmlTextEllipsis }}</div>
</div> </div>
</router-link> </router-link>
</template> </template>
<script lang="ts"> <script lang="ts">
import { Component, Prop, Vue } from "vue-property-decorator"; import { Component, Prop, Vue } from "vue-property-decorator";
import { IConversation } from "../../types/conversations"; import { IDiscussion } from "../../types/discussions";
import RouteName from "../../router/name"; import RouteName from "../../router/name";
@Component @Component
export default class ConversationListItem extends Vue { export default class DiscussionListItem extends Vue {
@Prop({ required: true, type: Object }) conversation!: IConversation; @Prop({ required: true, type: Object }) discussion!: IDiscussion;
RouteName = RouteName; RouteName = RouteName;
get htmlTextEllipsis() { get htmlTextEllipsis() {
const element = document.createElement("div"); const element = document.createElement("div");
element.innerHTML = this.conversation.lastComment.text if (this.discussion.lastComment) {
.replace(/<br\s*\/?>/gi, " ") element.innerHTML = this.discussion.lastComment.text
.replace(/<p>/gi, " "); .replace(/<br\s*\/?>/gi, " ")
.replace(/<p>/gi, " ");
}
return element.innerText; return element.innerText;
} }
} }
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
.conversation-minimalist-card-wrapper { .discussion-minimalist-card-wrapper {
text-decoration: none;
display: flex; display: flex;
width: 100%; width: 100%;
color: initial; color: initial;
@ -50,7 +53,7 @@ export default class ConversationListItem extends Vue {
.title-info-wrapper { .title-info-wrapper {
flex: 2; flex: 2;
.conversation-minimalist-title { .discussion-minimalist-title {
color: #3c376e; color: #3c376e;
font-family: "Liberation Sans", "Helvetica Neue", Roboto, Helvetica, Arial, serif; font-family: "Liberation Sans", "Helvetica Neue", Roboto, Helvetica, Arial, serif;
font-size: 1.25rem; font-size: 1.25rem;

View File

@ -247,6 +247,7 @@ export default class EventListCard extends mixins(ActorMixin, EventMixin) {
* Delete the event * Delete the event
*/ */
async openDeleteEventModalWrapper() { async openDeleteEventModalWrapper() {
// @ts-ignore
await this.openDeleteEventModal(this.participation.event, this.currentActor); await this.openDeleteEventModal(this.participation.event, this.currentActor);
} }

View File

@ -87,13 +87,16 @@ import DiasporaLogo from "../../assets/diaspora-icon.svg?inline";
}) })
export default class ShareEventModal extends Vue { export default class ShareEventModal extends Vue {
@Prop({ type: Object, required: true }) event!: IEvent; @Prop({ type: Object, required: true }) event!: IEvent;
@Prop({ type: Boolean, required: false, default: true }) eventCapacityOK!: boolean; @Prop({ type: Boolean, required: false, default: true }) eventCapacityOK!: boolean;
@Ref("eventURLInput") readonly eventURLInput!: any; @Ref("eventURLInput") readonly eventURLInput!: any;
EventVisibility = EventVisibility; EventVisibility = EventVisibility;
EventStatus = EventStatus; EventStatus = EventStatus;
showCopiedTooltip: boolean = false; showCopiedTooltip = false;
get twitterShareUrl(): string { get twitterShareUrl(): string {
return `https://twitter.com/intent/tweet?url=${encodeURIComponent(this.event.url)}&text=${ return `https://twitter.com/intent/tweet?url=${encodeURIComponent(this.event.url)}&text=${

View File

@ -23,7 +23,7 @@
<router-link <router-link
:to="{ :to="{
name: RouteName.GROUP, name: RouteName.GROUP,
params: { preferredUsername: member.parent.preferredUsername }, params: { preferredUsername: usernameWithDomain(member.parent) },
}" }"
> >
<h3>{{ member.parent.name }}</h3> <h3>{{ member.parent.name }}</h3>
@ -57,7 +57,7 @@
<script lang="ts"> <script lang="ts">
import { Component, Prop, Vue } from "vue-property-decorator"; import { Component, Prop, Vue } from "vue-property-decorator";
import { IGroup, IMember } from "@/types/actor"; import { IGroup, IMember, usernameWithDomain } from "@/types/actor";
import RouteName from "../../router/name"; import RouteName from "../../router/name";
@Component @Component
@ -65,6 +65,8 @@ export default class InvitationCard extends Vue {
@Prop({ required: true }) member!: IMember; @Prop({ required: true }) member!: IMember;
RouteName = RouteName; RouteName = RouteName;
usernameWithDomain = usernameWithDomain;
} }
</script> </script>

View File

@ -7,6 +7,7 @@ import { Component, Prop, Vue } from "vue-property-decorator";
// eslint-disable-next-line @typescript-eslint/ban-ts-ignore // eslint-disable-next-line @typescript-eslint/ban-ts-ignore
// @ts-ignore // @ts-ignore
import MobilizonLogo from "../assets/mobilizon_logo.svg?inline"; import MobilizonLogo from "../assets/mobilizon_logo.svg?inline";
@Component({ @Component({
components: { components: {
MobilizonLogo, MobilizonLogo,

View File

@ -0,0 +1,48 @@
<template>
<router-link
class="post-minimalist-card-wrapper"
:to="{ name: RouteName.POST, params: { slug: post.slug } }"
>
<div class="title-info-wrapper">
<p class="post-minimalist-title">{{ post.title }}</p>
<small class="has-text-grey">{{ $timeAgo.format(new Date(post.insertedAt)) }}</small>
</div>
</router-link>
</template>
<script lang="ts">
import { Component, Prop, Vue } from "vue-property-decorator";
import RouteName from "../../router/name";
import { IPost } from "../../types/post.model";
@Component
export default class PostListItem extends Vue {
@Prop({ required: true, type: Object }) post!: IPost;
RouteName = RouteName;
}
</script>
<style lang="scss" scoped>
.post-minimalist-card-wrapper {
text-decoration: none;
display: flex;
width: 100%;
color: initial;
border-bottom: 1px solid #e9e9e9;
align-items: center;
.title-info-wrapper {
flex: 2;
.post-minimalist-title {
color: #3c376e;
font-family: "Liberation Sans", "Helvetica Neue", Roboto, Helvetica, Arial, serif;
font-size: 1rem;
font-weight: 700;
overflow: hidden;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
}
}
</style>

View File

@ -142,7 +142,7 @@ a {
position: relative; position: relative;
.preview { .preview {
flex: 0 0 100px; flex: 0 0 50px;
position: relative; position: relative;
display: flex; display: flex;
align-items: center; align-items: center;
@ -159,7 +159,7 @@ a {
display: block; display: block;
font-weight: 500; font-weight: 500;
margin-bottom: 5px; margin-bottom: 5px;
color: $background-color; color: $primary;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
text-decoration: none; text-decoration: none;

View File

@ -81,7 +81,7 @@ a {
flex: 1; flex: 1;
.preview { .preview {
flex: 0 0 100px; flex: 0 0 50px;
position: relative; position: relative;
display: flex; display: flex;
align-items: center; align-items: center;

View File

@ -76,6 +76,7 @@ import { IResource } from "../../types/resource";
}) })
export default class ResourceSelector extends Vue { export default class ResourceSelector extends Vue {
@Prop({ required: true }) initialResource!: IResource; @Prop({ required: true }) initialResource!: IResource;
@Prop({ required: true }) username!: string; @Prop({ required: true }) username!: string;
resource: IResource | undefined = this.initialResource.parent; resource: IResource | undefined = this.initialResource.parent;

View File

@ -13,6 +13,7 @@ import { Route } from "vue-router";
@Component @Component
export default class SettingMenuItem extends Vue { export default class SettingMenuItem extends Vue {
@Prop({ required: false, type: String }) title!: string; @Prop({ required: false, type: String }) title!: string;
@Prop({ required: true, type: Object }) to!: Route; @Prop({ required: true, type: Object }) to!: Route;
get isActive() { get isActive() {

View File

@ -11,11 +11,13 @@
import { Component, Prop, Vue } from "vue-property-decorator"; import { Component, Prop, Vue } from "vue-property-decorator";
import SettingMenuItem from "@/components/Settings/SettingMenuItem.vue"; import SettingMenuItem from "@/components/Settings/SettingMenuItem.vue";
import { Route } from "vue-router"; import { Route } from "vue-router";
@Component({ @Component({
components: { SettingMenuItem }, components: { SettingMenuItem },
}) })
export default class SettingMenuSection extends Vue { export default class SettingMenuSection extends Vue {
@Prop({ required: false, type: String }) title!: string; @Prop({ required: false, type: String }) title!: string;
@Prop({ required: true, type: Object }) to!: Route; @Prop({ required: true, type: Object }) to!: Route;
get sectionActive() { get sectionActive() {

View File

@ -63,6 +63,7 @@ import { CURRENT_USER_CLIENT } from "../../graphql/user";
import { ICurrentUser, ICurrentUserRole } from "../../types/current-user.model"; import { ICurrentUser, ICurrentUserRole } from "../../types/current-user.model";
import RouteName from "../../router/name"; import RouteName from "../../router/name";
@Component({ @Component({
components: { SettingMenuSection, SettingMenuItem }, components: { SettingMenuSection, SettingMenuItem },
apollo: { apollo: {

View File

@ -24,6 +24,7 @@ import RouteName from "../../router/name";
import { UPDATE_TODO } from "../../graphql/todos"; import { UPDATE_TODO } from "../../graphql/todos";
import ActorAutoComplete from "../Account/ActorAutoComplete.vue"; import ActorAutoComplete from "../Account/ActorAutoComplete.vue";
import { IPerson } from "../../types/actor"; import { IPerson } from "../../types/actor";
@Component({ @Component({
components: { ActorAutoComplete }, components: { ActorAutoComplete },
}) })

View File

@ -1,6 +1,7 @@
import gql from "graphql-tag"; import gql from "graphql-tag";
import { CONVERSATION_BASIC_FIELDS_FRAGMENT } from "@/graphql/conversation"; import { DISCUSSION_BASIC_FIELDS_FRAGMENT } from "@/graphql/discussion";
import { RESOURCE_METADATA_BASIC_FIELDS_FRAGMENT } from "@/graphql/resources"; import { RESOURCE_METADATA_BASIC_FIELDS_FRAGMENT } from "@/graphql/resources";
import { POST_BASIC_FIELDS } from "./post";
export const FETCH_PERSON = gql` export const FETCH_PERSON = gql`
query($username: String!) { query($username: String!) {
@ -479,10 +480,16 @@ export const FETCH_GROUP = gql`
} }
total total
} }
conversations { discussions {
total total
elements { elements {
...ConversationBasicFields ...DiscussionBasicFields
}
}
posts {
total
elements {
...PostBasicFields
} }
} }
members { members {
@ -497,6 +504,7 @@ export const FETCH_GROUP = gql`
url url
} }
} }
insertedAt
} }
total total
} }
@ -537,9 +545,11 @@ export const FETCH_GROUP = gql`
} }
} }
} }
${CONVERSATION_BASIC_FIELDS_FRAGMENT} ${DISCUSSION_BASIC_FIELDS_FRAGMENT}
${POST_BASIC_FIELDS}
${RESOURCE_METADATA_BASIC_FIELDS_FRAGMENT} ${RESOURCE_METADATA_BASIC_FIELDS_FRAGMENT}
`; `;
export const CREATE_GROUP = gql` export const CREATE_GROUP = gql`
mutation CreateGroup( mutation CreateGroup(
$creatorActorId: ID! $creatorActorId: ID!
@ -571,6 +581,29 @@ export const CREATE_GROUP = gql`
} }
`; `;
export const UPDATE_GROUP = gql`
mutation UpdateGroup(
$id: ID!
$name: String
$summary: String
$avatar: PictureInput
$banner: PictureInput
) {
createGroup(id: $id, name: $name, summary: $summary, banner: $banner, avatar: $avatar) {
id
preferredUsername
name
summary
avatar {
url
}
banner {
url
}
}
}
`;
export const SUSPEND_PROFILE = gql` export const SUSPEND_PROFILE = gql`
mutation SuspendProfile($id: ID!) { mutation SuspendProfile($id: ID!) {
suspendProfile(id: $id) { suspendProfile(id: $id) {

View File

@ -1,120 +0,0 @@
import gql from "graphql-tag";
export const CONVERSATION_BASIC_FIELDS_FRAGMENT = gql`
fragment ConversationBasicFields on Conversation {
id
title
slug
lastComment {
id
text
actor {
preferredUsername
avatar {
url
}
}
}
}
`;
export const CONVERSATION_FIELDS_FOR_REPLY_FRAGMENT = gql`
fragment ConversationFieldsReply on Conversation {
id
title
slug
lastComment {
id
text
updatedAt
actor {
id
preferredUsername
avatar {
url
}
}
}
actor {
id
preferredUsername
}
creator {
id
preferredUsername
}
}
`;
export const CONVERSATION_FIELDS_FRAGMENT = gql`
fragment ConversationFields on Conversation {
id
title
slug
lastComment {
id
text
updatedAt
}
actor {
id
preferredUsername
}
creator {
id
preferredUsername
}
}
`;
export const CREATE_CONVERSATION = gql`
mutation createConversation($title: String!, $creatorId: ID!, $actorId: ID!, $text: String!) {
createConversation(title: $title, text: $text, creatorId: $creatorId, actorId: $actorId) {
...ConversationFields
}
}
${CONVERSATION_FIELDS_FRAGMENT}
`;
export const REPLY_TO_CONVERSATION = gql`
mutation replyToConversation($conversationId: ID!, $text: String!) {
replyToConversation(conversationId: $conversationId, text: $text) {
...ConversationFieldsReply
}
}
${CONVERSATION_FIELDS_FOR_REPLY_FRAGMENT}
`;
export const GET_CONVERSATION = gql`
query getConversation($id: ID!, $page: Int, $limit: Int) {
conversation(id: $id) {
comments(page: $page, limit: $limit) {
total
elements {
id
text
actor {
id
avatar {
url
}
preferredUsername
}
insertedAt
updatedAt
}
}
...ConversationFields
}
}
${CONVERSATION_FIELDS_FRAGMENT}
`;
export const UPDATE_CONVERSATION = gql`
mutation updateConversation($conversationId: ID!, $title: String!) {
updateConversation(conversationId: $conversationId, title: $title) {
...ConversationFields
}
}
${CONVERSATION_FIELDS_FRAGMENT}
`;

View File

@ -0,0 +1,158 @@
import gql from "graphql-tag";
export const DISCUSSION_BASIC_FIELDS_FRAGMENT = gql`
fragment DiscussionBasicFields on Discussion {
id
title
slug
lastComment {
id
text
actor {
id
preferredUsername
avatar {
url
}
}
}
}
`;
export const DISCUSSION_FIELDS_FOR_REPLY_FRAGMENT = gql`
fragment DiscussionFieldsReply on Discussion {
id
title
slug
lastComment {
id
text
updatedAt
actor {
id
preferredUsername
avatar {
url
}
}
}
actor {
id
preferredUsername
}
creator {
id
preferredUsername
}
}
`;
export const DISCUSSION_FIELDS_FRAGMENT = gql`
fragment DiscussionFields on Discussion {
id
title
slug
lastComment {
id
text
updatedAt
}
actor {
id
domain
name
preferredUsername
}
creator {
id
domain
name
preferredUsername
}
}
`;
export const CREATE_DISCUSSION = gql`
mutation createDiscussion($title: String!, $creatorId: ID!, $actorId: ID!, $text: String!) {
createDiscussion(title: $title, text: $text, creatorId: $creatorId, actorId: $actorId) {
...DiscussionFields
}
}
${DISCUSSION_FIELDS_FRAGMENT}
`;
export const REPLY_TO_DISCUSSION = gql`
mutation replyToDiscussion($discussionId: ID!, $text: String!) {
replyToDiscussion(discussionId: $discussionId, text: $text) {
...DiscussionFields
}
}
${DISCUSSION_FIELDS_FRAGMENT}
`;
export const GET_DISCUSSION = gql`
query getDiscussion($slug: String!, $page: Int, $limit: Int) {
discussion(slug: $slug) {
comments(page: $page, limit: $limit)
@connection(key: "discussion-comments", filter: ["slug"]) {
total
elements {
id
text
actor {
id
avatar {
url
}
name
domain
preferredUsername
}
insertedAt
updatedAt
}
}
...DiscussionFields
}
}
${DISCUSSION_FIELDS_FRAGMENT}
`;
export const UPDATE_DISCUSSION = gql`
mutation updateDiscussion($discussionId: ID!, $title: String!) {
updateDiscussion(discussionId: $discussionId, title: $title) {
...DiscussionFields
}
}
${DISCUSSION_FIELDS_FRAGMENT}
`;
export const DELETE_DISCUSSION = gql`
mutation deleteDiscussion($discussionId: ID!) {
deleteDiscussion(discussionId: $discussionId) {
id
}
}
`;
export const DISCUSSION_COMMENT_CHANGED = gql`
subscription($slug: String!) {
discussionCommentChanged(slug: $slug) {
id
lastComment {
id
text
updatedAt
insertedAt
actor {
id
preferredUsername
domain
avatar {
url
}
}
}
}
}
`;

View File

@ -22,3 +22,31 @@ export const ACCEPT_INVITATION = gql`
} }
} }
`; `;
export const GROUP_MEMBERS = gql`
query($name: String!, $roles: String, $page: Int, $limit: Int) {
group(preferredUsername: $name) {
id
url
name
domain
preferredUsername
members(page: $page, limit: $limit, roles: $roles) {
elements {
role
actor {
id
name
domain
preferredUsername
avatar {
url
}
}
insertedAt
}
total
}
}
}
`;

151
js/src/graphql/post.ts Normal file
View File

@ -0,0 +1,151 @@
import gql from "graphql-tag";
import { TAG_FRAGMENT } from "./tags";
export const POST_FRAGMENT = gql`
fragment PostFragment on Post {
id
title
slug
url
body
author {
id
preferredUsername
name
domain
avatar {
url
}
}
attributedTo {
id
preferredUsername
name
domain
avatar {
url
}
}
insertedAt
updatedAt
publishAt
draft
visibility
tags {
...TagFragment
}
}
${TAG_FRAGMENT}
`;
export const POST_BASIC_FIELDS = gql`
fragment PostBasicFields on Post {
id
title
slug
url
author {
id
preferredUsername
name
avatar {
url
}
}
attributedTo {
id
preferredUsername
name
avatar {
url
}
}
insertedAt
updatedAt
publishAt
draft
}
`;
export const FETCH_GROUP_POSTS = gql`
query GroupPosts($preferredUsername: String!, $page: Int, $limit: Int) {
group(preferredUsername: $preferredUsername) {
id
preferredUsername
domain
name
posts(page: $page, limit: $limit) {
total
elements {
...PostBasicFields
}
}
}
}
${POST_BASIC_FIELDS}
`;
export const FETCH_POST = gql`
query Post($slug: String!) {
post(slug: $slug) {
...PostFragment
}
}
${POST_FRAGMENT}
`;
export const CREATE_POST = gql`
mutation CreatePost(
$title: String!
$body: String
$attributedToId: ID!
$visibility: PostVisibility
$draft: Boolean
$tags: [String]
) {
createPost(
title: $title
body: $body
attributedToId: $attributedToId
visibility: $visibility
draft: $draft
tags: $tags
) {
...PostFragment
}
}
${POST_FRAGMENT}
`;
export const UPDATE_POST = gql`
mutation UpdatePost(
$id: ID!
$title: String
$body: String
$attributedToId: ID
$visibility: PostVisibility
$draft: Boolean
$tags: [String]
) {
updatePost(
id: $id
title: $title
body: $body
attributedToId: $attributedToId
visibility: $visibility
draft: $draft
tags: $tags
) {
...PostFragment
}
}
${POST_FRAGMENT}
`;
export const DELETE_POST = gql`
mutation DeletePost($id: ID!) {
deletePost(id: $id) {
id
}
}
`;

View File

@ -1,6 +1,13 @@
import gql from "graphql-tag"; import gql from "graphql-tag";
/* eslint-disable import/prefer-default-export */ export const TAG_FRAGMENT = gql`
fragment TagFragment on Tag {
id
slug
title
}
`;
export const TAGS = gql` export const TAGS = gql`
query { query {
tags { tags {

View File

@ -55,7 +55,7 @@
"Continue editing": "مواصلة التحرير", "Continue editing": "مواصلة التحرير",
"Country": "البلد", "Country": "البلد",
"Create": "انشاء", "Create": "انشاء",
"Create a new conversation": "أنشئ محادثة جديدة", "Create a new discussion": "أنشئ محادثة جديدة",
"Create a new event": "انشاء فعالية جديدة", "Create a new event": "انشاء فعالية جديدة",
"Create a new group": "إنشاء فريق جديد", "Create a new group": "إنشاء فريق جديد",
"Create a new identity": "إنشاء هوية جديدة", "Create a new identity": "إنشاء هوية جديدة",
@ -186,7 +186,7 @@
"My events": "فعالياتي", "My events": "فعالياتي",
"My identities": "هوياتي", "My identities": "هوياتي",
"Name": "الإسم", "Name": "الإسم",
"New conversation": "محادثة جديدة", "New discussion": "محادثة جديدة",
"New email": "العنوان الجديد للبريد الإلكتروني", "New email": "العنوان الجديد للبريد الإلكتروني",
"New folder": "مجلد جديد", "New folder": "مجلد جديد",
"New link": "رابط جديد", "New link": "رابط جديد",

View File

@ -21,7 +21,7 @@
"An error has occurred.": "Адбылася памылка.", "An error has occurred.": "Адбылася памылка.",
"Approve": "Пацвердзіць", "Approve": "Пацвердзіць",
"Are you sure you want to <b>delete</b> this comment? This action cannot be undone.": "Вы сапраўды хочаце <b>выдаліць</b> гэты каментарый? Гэта дзеянне нельга адмяніць.", "Are you sure you want to <b>delete</b> this comment? This action cannot be undone.": "Вы сапраўды хочаце <b>выдаліць</b> гэты каментарый? Гэта дзеянне нельга адмяніць.",
"Are you sure you want to <b>delete</b> this event? This action cannot be undone. You may want to engage the conversation with the event creator or edit its event instead.": "Вы сапраўды жадаеце <b>выдаліць</b> гэту падзею? Гэта дзеянне нельга адмяніць. Магчыма, варта замест гэтага пагаварыць з аўтарам ці аўтаркай падзеі ці адрэдагаваць падзею.", "Are you sure you want to <b>delete</b> this event? This action cannot be undone. You may want to engage the discussion with the event creator or edit its event instead.": "Вы сапраўды жадаеце <b>выдаліць</b> гэту падзею? Гэта дзеянне нельга адмяніць. Магчыма, варта замест гэтага пагаварыць з аўтарам ці аўтаркай падзеі ці адрэдагаваць падзею.",
"Are you sure you want to cancel the event creation? You'll lose all modifications.": "Вы сапраўды хочаце адмяніць стварэнне падзеі? Вы страціце ўсе свае рэдагаванні.", "Are you sure you want to cancel the event creation? You'll lose all modifications.": "Вы сапраўды хочаце адмяніць стварэнне падзеі? Вы страціце ўсе свае рэдагаванні.",
"Are you sure you want to cancel the event edition? You'll lose all modifications.": "Вы сапраўды хочаце адмяніць рэдагаванне падзеі? Вы страціце ўсе рэдагаванні.", "Are you sure you want to cancel the event edition? You'll lose all modifications.": "Вы сапраўды хочаце адмяніць рэдагаванне падзеі? Вы страціце ўсе рэдагаванні.",
"Are you sure you want to cancel your participation at event \"{title}\"?": "Вы сапраўды хочаце адмовіцца ад удзелу ў падзеі «{title}»?", "Are you sure you want to cancel your participation at event \"{title}\"?": "Вы сапраўды хочаце адмовіцца ад удзелу ў падзеі «{title}»?",

View File

@ -30,7 +30,7 @@
"Approve": "Aprova", "Approve": "Aprova",
"Are you really sure you want to delete your whole account? You'll lose everything. Identities, settings, events created, messages and participations will be gone forever.": "Segur que voleu suprimir tot el compte? Ho perdràs tot. Les identitats, la configuració, els esdeveniments creats, els missatges i les participacions desapareixeran per sempre.", "Are you really sure you want to delete your whole account? You'll lose everything. Identities, settings, events created, messages and participations will be gone forever.": "Segur que voleu suprimir tot el compte? Ho perdràs tot. Les identitats, la configuració, els esdeveniments creats, els missatges i les participacions desapareixeran per sempre.",
"Are you sure you want to <b>delete</b> this comment? This action cannot be undone.": "Segur que vols <b>esborrar</b> aquest comentari? Aquesta acció és irreversible.", "Are you sure you want to <b>delete</b> this comment? This action cannot be undone.": "Segur que vols <b>esborrar</b> aquest comentari? Aquesta acció és irreversible.",
"Are you sure you want to <b>delete</b> this event? This action cannot be undone. You may want to engage the conversation with the event creator or edit its event instead.": "Segur que vols <b>esborrar</b> aquesta activitat? Aquesta acció és irreversible. En comptes d'això, pots parlar amb la persona creadora de l'activitat o modificar l'activitat.", "Are you sure you want to <b>delete</b> this event? This action cannot be undone. You may want to engage the discussion with the event creator or edit its event instead.": "Segur que vols <b>esborrar</b> aquesta activitat? Aquesta acció és irreversible. En comptes d'això, pots parlar amb la persona creadora de l'activitat o modificar l'activitat.",
"Are you sure you want to cancel the event creation? You'll lose all modifications.": "Segur que vols esborrar aquesta activitat? Perdràs tots els canvis.", "Are you sure you want to cancel the event creation? You'll lose all modifications.": "Segur que vols esborrar aquesta activitat? Perdràs tots els canvis.",
"Are you sure you want to cancel the event edition? You'll lose all modifications.": "Segur que vols canceŀlar l'edició? Perdràs tots els canvis que hagis fet.", "Are you sure you want to cancel the event edition? You'll lose all modifications.": "Segur que vols canceŀlar l'edició? Perdràs tots els canvis que hagis fet.",
"Are you sure you want to cancel your participation at event \"{title}\"?": "Segur que vols deixar de participar a l'activitat \"{title}\"?", "Are you sure you want to cancel your participation at event \"{title}\"?": "Segur que vols deixar de participar a l'activitat \"{title}\"?",

View File

@ -27,7 +27,7 @@
"Approve": "Bestätigen", "Approve": "Bestätigen",
"Are you really sure you want to delete your whole account? You'll lose everything. Identities, settings, events created, messages and participations will be gone forever.": "Bist du dir sicher, dass du den gesamten Account löschen möchtest? Du verlierst dadurch alles. Identitäten, Einstellungen, erstellte Events, Nachrichten, Teilnahmen sind dann für immer verschwunden.", "Are you really sure you want to delete your whole account? You'll lose everything. Identities, settings, events created, messages and participations will be gone forever.": "Bist du dir sicher, dass du den gesamten Account löschen möchtest? Du verlierst dadurch alles. Identitäten, Einstellungen, erstellte Events, Nachrichten, Teilnahmen sind dann für immer verschwunden.",
"Are you sure you want to <b>delete</b> this comment? This action cannot be undone.": "Bist du sicher, dass du diesen Kommentar <b>löschen</b> willst? Diese Aktion kann nicht rückgängig gemacht werden.", "Are you sure you want to <b>delete</b> this comment? This action cannot be undone.": "Bist du sicher, dass du diesen Kommentar <b>löschen</b> willst? Diese Aktion kann nicht rückgängig gemacht werden.",
"Are you sure you want to <b>delete</b> this event? This action cannot be undone. You may want to engage the conversation with the event creator or edit its event instead.": "Bist du sicher, dass du diese Veranstaltung <b>löschen</b> willst? Diese Aktion kann nicht rückgängig gemacht werden.", "Are you sure you want to <b>delete</b> this event? This action cannot be undone. You may want to engage the discussion with the event creator or edit its event instead.": "Bist du sicher, dass du diese Veranstaltung <b>löschen</b> willst? Diese Aktion kann nicht rückgängig gemacht werden.",
"Are you sure you want to cancel the event creation? You'll lose all modifications.": "Bist Du dir sicher, dass du das Erstellen der Veranstaltung abbrechen möchtest? Alle Änderungen werden verloren gehen.", "Are you sure you want to cancel the event creation? You'll lose all modifications.": "Bist Du dir sicher, dass du das Erstellen der Veranstaltung abbrechen möchtest? Alle Änderungen werden verloren gehen.",
"Are you sure you want to cancel the event edition? You'll lose all modifications.": "Bist du dir sicher, dass Du die Bearbeitung der Veranstaltung abbrechen möchtest? Alle Änderungen werden verloren gehen.", "Are you sure you want to cancel the event edition? You'll lose all modifications.": "Bist du dir sicher, dass Du die Bearbeitung der Veranstaltung abbrechen möchtest? Alle Änderungen werden verloren gehen.",
"Are you sure you want to cancel your participation at event \"{title}\"?": "Bist Du dir sicher, dass Du nicht mehr an der Veranstaltung \"{title}\" teilnehmen möchtest?", "Are you sure you want to cancel your participation at event \"{title}\"?": "Bist Du dir sicher, dass Du nicht mehr an der Veranstaltung \"{title}\" teilnehmen möchtest?",

View File

@ -29,7 +29,7 @@
"Approve": "Approve", "Approve": "Approve",
"Are you really sure you want to delete your whole account? You'll lose everything. Identities, settings, events created, messages and participations will be gone forever.": "Are you really sure you want to delete your whole account? You'll lose everything. Identities, settings, events created, messages and participations will be gone forever.", "Are you really sure you want to delete your whole account? You'll lose everything. Identities, settings, events created, messages and participations will be gone forever.": "Are you really sure you want to delete your whole account? You'll lose everything. Identities, settings, events created, messages and participations will be gone forever.",
"Are you sure you want to <b>delete</b> this comment? This action cannot be undone.": "Are you sure you want to <b>delete</b> this comment? This action cannot be undone.", "Are you sure you want to <b>delete</b> this comment? This action cannot be undone.": "Are you sure you want to <b>delete</b> this comment? This action cannot be undone.",
"Are you sure you want to <b>delete</b> this event? This action cannot be undone. You may want to engage the conversation with the event creator or edit its event instead.": "Are you sure you want to <b>delete</b> this event? This action cannot be undone. You may want to engage the conversation with the event creator or edit its event instead.", "Are you sure you want to <b>delete</b> this event? This action cannot be undone. You may want to engage the discussion with the event creator or edit its event instead.": "Are you sure you want to <b>delete</b> this event? This action cannot be undone. You may want to engage the discussion with the event creator or edit its event instead.",
"Are you sure you want to cancel the event creation? You'll lose all modifications.": "Are you sure you want to cancel the event creation? You'll lose all modifications.", "Are you sure you want to cancel the event creation? You'll lose all modifications.": "Are you sure you want to cancel the event creation? You'll lose all modifications.",
"Are you sure you want to cancel the event edition? You'll lose all modifications.": "Are you sure you want to cancel the event edition? You'll lose all modifications.", "Are you sure you want to cancel the event edition? You'll lose all modifications.": "Are you sure you want to cancel the event edition? You'll lose all modifications.",
"Are you sure you want to cancel your participation at event \"{title}\"?": "Are you sure you want to cancel your participation at event \"{title}\"?", "Are you sure you want to cancel your participation at event \"{title}\"?": "Are you sure you want to cancel your participation at event \"{title}\"?",
@ -710,5 +710,9 @@
"Error while login with {provider}. Retry or login another way.": "Error while login with {provider}. Retry or login another way.", "Error while login with {provider}. Retry or login another way.": "Error while login with {provider}. Retry or login another way.",
"Error while login with {provider}. This login provider doesn't exist.": "Error while login with {provider}. This login provider doesn't exist.", "Error while login with {provider}. This login provider doesn't exist.": "Error while login with {provider}. This login provider doesn't exist.",
"This user has been disabled": "This user has been disabled", "This user has been disabled": "This user has been disabled",
"You can't reset your password because you use a 3rd-party auth provider to login.": "You can't reset your password because you use a 3rd-party auth provider to login." "You can't reset your password because you use a 3rd-party auth provider to login.": "You can't reset your password because you use a 3rd-party auth provider to login.",
"Update post {name}": "Update post {name}",
"Create a new post": "Create a new post",
"Post": "Post",
"By {author}": "By {author}"
} }

View File

@ -55,7 +55,7 @@
"Approve": "Aprobar", "Approve": "Aprobar",
"Are you really sure you want to delete your whole account? You'll lose everything. Identities, settings, events created, messages and participations will be gone forever.": "¿Estás realmente seguro de que deseas eliminar toda tu cuenta? Lo perderás todo. Las identidades, la configuración, los eventos creados, los mensajes y las participaciones desaparecerán para siempre.", "Are you really sure you want to delete your whole account? You'll lose everything. Identities, settings, events created, messages and participations will be gone forever.": "¿Estás realmente seguro de que deseas eliminar toda tu cuenta? Lo perderás todo. Las identidades, la configuración, los eventos creados, los mensajes y las participaciones desaparecerán para siempre.",
"Are you sure you want to <b>delete</b> this comment? This action cannot be undone.": "¿Estás seguro de que quieres <b> eliminar </b> este comentario? Esta acción no se puede deshacer.", "Are you sure you want to <b>delete</b> this comment? This action cannot be undone.": "¿Estás seguro de que quieres <b> eliminar </b> este comentario? Esta acción no se puede deshacer.",
"Are you sure you want to <b>delete</b> this event? This action cannot be undone. You may want to engage the conversation with the event creator or edit its event instead.": "¿Estás seguro de que quieres <b> eliminar </b> este evento? Esta acción no se puede deshacer. Es posible que desee entablar una conversación con el creador del evento o editar el evento en su lugar.", "Are you sure you want to <b>delete</b> this event? This action cannot be undone. You may want to engage the discussion with the event creator or edit its event instead.": "¿Estás seguro de que quieres <b> eliminar </b> este evento? Esta acción no se puede deshacer. Es posible que desee entablar una conversación con el creador del evento o editar el evento en su lugar.",
"Are you sure you want to cancel the event creation? You'll lose all modifications.": "¿Seguro que quieres cancelar la creación del evento? Perderás todas las modificaciones.", "Are you sure you want to cancel the event creation? You'll lose all modifications.": "¿Seguro que quieres cancelar la creación del evento? Perderás todas las modificaciones.",
"Are you sure you want to cancel the event edition? You'll lose all modifications.": "¿Seguro que quieres cancelar la edición del evento? Perderás todas las modificaciones.", "Are you sure you want to cancel the event edition? You'll lose all modifications.": "¿Seguro que quieres cancelar la edición del evento? Perderás todas las modificaciones.",
"Are you sure you want to cancel your participation at event \"{title}\"?": "¿Está seguro de que desea cancelar su participación en el evento \"{title}\"?", "Are you sure you want to cancel your participation at event \"{title}\"?": "¿Está seguro de que desea cancelar su participación en el evento \"{title}\"?",
@ -103,14 +103,14 @@
"Confirmed: Will happen": "Confirmado: sucederá", "Confirmed: Will happen": "Confirmado: sucederá",
"Contact": "Contacto", "Contact": "Contacto",
"Continue editing": "Continua editando", "Continue editing": "Continua editando",
"Conversations": "Conversaciones", "Discussions": "Conversaciones",
"Cookies and Local storage": "Cookies y almacenamiento local", "Cookies and Local storage": "Cookies y almacenamiento local",
"Country": "País", "Country": "País",
"Create": "Crear", "Create": "Crear",
"Create a calc": "Crear un calco", "Create a calc": "Crear un calco",
"Create a discussion": "Crear una discusión", "Create a discussion": "Crear una discusión",
"Create a folder": "Crear una carpeta", "Create a folder": "Crear una carpeta",
"Create a new conversation": "Crea una nueva conversación", "Create a new discussion": "Crea una nueva conversación",
"Create a new event": "Crear un nuevo evento", "Create a new event": "Crear un nuevo evento",
"Create a new group": "Crear un nuevo grupo", "Create a new group": "Crear un nuevo grupo",
"Create a new identity": "Crear una nueva identidad", "Create a new identity": "Crear una nueva identidad",
@ -347,7 +347,7 @@
"My identities": "Mis identidades", "My identities": "Mis identidades",
"NOTE! The default terms have not been checked over by a lawyer and thus are unlikely to provide full legal protection for all situations for an instance admin using them. They are also not specific to all countries and jurisdictions. If you are unsure, please check with a lawyer.": "¡NOTA! Los términos predeterminados no han sido revisados por un abogado y, por lo tanto, es poco probable que brinden protección legal completa para todas las situaciones para un administrador de instancia que los use. Tampoco son específicos de todos los países y jurisdicciones. Si no está seguro, consulte con un abogado.", "NOTE! The default terms have not been checked over by a lawyer and thus are unlikely to provide full legal protection for all situations for an instance admin using them. They are also not specific to all countries and jurisdictions. If you are unsure, please check with a lawyer.": "¡NOTA! Los términos predeterminados no han sido revisados por un abogado y, por lo tanto, es poco probable que brinden protección legal completa para todas las situaciones para un administrador de instancia que los use. Tampoco son específicos de todos los países y jurisdicciones. Si no está seguro, consulte con un abogado.",
"Name": "Nombre", "Name": "Nombre",
"New conversation": "Nueva conversación", "New discussion": "Nueva conversación",
"New discussion": "Nueva discusión", "New discussion": "Nueva discusión",
"New email": "Nuevo correo electrónico", "New email": "Nuevo correo electrónico",
"New folder": "Nueva carpeta", "New folder": "Nueva carpeta",
@ -620,7 +620,7 @@
"Username": "Nombre de usuario", "Username": "Nombre de usuario",
"Users": "Los usuarios", "Users": "Los usuarios",
"View a reply": "|Ver una respuesta|Ver {totalReplies} respuestas", "View a reply": "|Ver una respuesta|Ver {totalReplies} respuestas",
"View all conversations": "Ver todas las conversaciones", "View all discussions": "Ver todas las conversaciones",
"View all discussions": "Ver todas las discusiones", "View all discussions": "Ver todas las discusiones",
"View all resources": "Ver todos los recursos", "View all resources": "Ver todos los recursos",
"View all todos": "Ver todas las tareas pendientes", "View all todos": "Ver todas las tareas pendientes",

View File

@ -54,7 +54,7 @@
"Approve": "Hyväksy", "Approve": "Hyväksy",
"Are you really sure you want to delete your whole account? You'll lose everything. Identities, settings, events created, messages and participations will be gone forever.": "Haluatko varmasti poistaa koko tilin? Tällöin kaikki poistetaan. Identiteetit, asetukset, luodut tapahtumat, viestit ja osallistumiset poistetaan pysyvästi.", "Are you really sure you want to delete your whole account? You'll lose everything. Identities, settings, events created, messages and participations will be gone forever.": "Haluatko varmasti poistaa koko tilin? Tällöin kaikki poistetaan. Identiteetit, asetukset, luodut tapahtumat, viestit ja osallistumiset poistetaan pysyvästi.",
"Are you sure you want to <b>delete</b> this comment? This action cannot be undone.": "Haluatko varmasti <b>poistaa</b> tämän kommentin? Toimintoa ei voi perua.", "Are you sure you want to <b>delete</b> this comment? This action cannot be undone.": "Haluatko varmasti <b>poistaa</b> tämän kommentin? Toimintoa ei voi perua.",
"Are you sure you want to <b>delete</b> this event? This action cannot be undone. You may want to engage the conversation with the event creator or edit its event instead.": "Haluatko varmasti <b>poistaa</b> tämän tapahtuman? Toimintoa ei voi perua. Poistamisen sijaan voisit ehkä keskustella tapahtuman luojan kanssa tai muokata tapahtumaa.", "Are you sure you want to <b>delete</b> this event? This action cannot be undone. You may want to engage the discussion with the event creator or edit its event instead.": "Haluatko varmasti <b>poistaa</b> tämän tapahtuman? Toimintoa ei voi perua. Poistamisen sijaan voisit ehkä keskustella tapahtuman luojan kanssa tai muokata tapahtumaa.",
"Are you sure you want to cancel the event creation? You'll lose all modifications.": "Haluatko varmasti keskeyttää tapahtuman luomisen? Kaikki muutokset menetetään.", "Are you sure you want to cancel the event creation? You'll lose all modifications.": "Haluatko varmasti keskeyttää tapahtuman luomisen? Kaikki muutokset menetetään.",
"Are you sure you want to cancel the event edition? You'll lose all modifications.": "Haluatko varmasti keskeyttää tapahtuman muokkaamisen? Kaikki muutokset menetetään.", "Are you sure you want to cancel the event edition? You'll lose all modifications.": "Haluatko varmasti keskeyttää tapahtuman muokkaamisen? Kaikki muutokset menetetään.",
"Are you sure you want to cancel your participation at event \"{title}\"?": "Haluatko varmasti perua osallistumisesi tapahtumaan {title}?", "Are you sure you want to cancel your participation at event \"{title}\"?": "Haluatko varmasti perua osallistumisesi tapahtumaan {title}?",
@ -101,14 +101,14 @@
"Confirmed: Will happen": "Vahvistettu: Tapahtuu", "Confirmed: Will happen": "Vahvistettu: Tapahtuu",
"Contact": "Ota yhteyttä", "Contact": "Ota yhteyttä",
"Continue editing": "Jatka muokkausta", "Continue editing": "Jatka muokkausta",
"Conversations": "Keskustelut", "Discussions": "Keskustelut",
"Cookies and Local storage": "Evästeet ja paikallisesti tallennettavat tiedot", "Cookies and Local storage": "Evästeet ja paikallisesti tallennettavat tiedot",
"Country": "Maa", "Country": "Maa",
"Create": "Luo", "Create": "Luo",
"Create a calc": "Luo taulukko", "Create a calc": "Luo taulukko",
"Create a discussion": "Luo keskustelu", "Create a discussion": "Luo keskustelu",
"Create a folder": "Luo kansio", "Create a folder": "Luo kansio",
"Create a new conversation": "Luo uusi keskustelu", "Create a new discussion": "Luo uusi keskustelu",
"Create a new event": "Luo uusi tapahtuma", "Create a new event": "Luo uusi tapahtuma",
"Create a new group": "Luo uusi ryhmä", "Create a new group": "Luo uusi ryhmä",
"Create a new identity": "Luo uusi identiteetti", "Create a new identity": "Luo uusi identiteetti",
@ -341,7 +341,7 @@
"My identities": "Omat identiteetit", "My identities": "Omat identiteetit",
"NOTE! The default terms have not been checked over by a lawyer and thus are unlikely to provide full legal protection for all situations for an instance admin using them. They are also not specific to all countries and jurisdictions. If you are unsure, please check with a lawyer.": "HUOM! Oletusehdot eivät ole juristin tarkistamia, joten palvelimen ylläpitäjän ei ole syytä luottaa niiden tarjoamaan juridiseen suojaan. Niitä ei ole myöskään sovitettu eri maiden ja lainkäyttöalueiden olosuhteisiin. Epävarmoissa tilanteissa suosittelemme tarkistuttamaan ehdot lakiasiantuntijalla.", "NOTE! The default terms have not been checked over by a lawyer and thus are unlikely to provide full legal protection for all situations for an instance admin using them. They are also not specific to all countries and jurisdictions. If you are unsure, please check with a lawyer.": "HUOM! Oletusehdot eivät ole juristin tarkistamia, joten palvelimen ylläpitäjän ei ole syytä luottaa niiden tarjoamaan juridiseen suojaan. Niitä ei ole myöskään sovitettu eri maiden ja lainkäyttöalueiden olosuhteisiin. Epävarmoissa tilanteissa suosittelemme tarkistuttamaan ehdot lakiasiantuntijalla.",
"Name": "Nimi", "Name": "Nimi",
"New conversation": "Uusi keskustelu", "New discussion": "Uusi keskustelu",
"New discussion": "Uusi keskustelu", "New discussion": "Uusi keskustelu",
"New email": "Uusi sähköpostiosoite", "New email": "Uusi sähköpostiosoite",
"New folder": "Uusi kansio", "New folder": "Uusi kansio",
@ -615,7 +615,7 @@
"Username": "Käyttäjänimi", "Username": "Käyttäjänimi",
"Users": "Käyttäjät", "Users": "Käyttäjät",
"View a reply": "|Näytä vastaus|Näytä {totalReplies} vastausta", "View a reply": "|Näytä vastaus|Näytä {totalReplies} vastausta",
"View all conversations": "Näytä kaikki keskustelut", "View all discussions": "Näytä kaikki keskustelut",
"View all discussions": "Näytä kaikki keskustelut", "View all discussions": "Näytä kaikki keskustelut",
"View all resources": "Näytä kaikki resurssit", "View all resources": "Näytä kaikki resurssit",
"View all todos": "Näytä kaikki tehtävät", "View all todos": "Näytä kaikki tehtävät",

View File

@ -53,7 +53,7 @@
"Approve": "Approuver", "Approve": "Approuver",
"Are you really sure you want to delete your whole account? You'll lose everything. Identities, settings, events created, messages and participations will be gone forever.": "Êtes-vous vraiment certain⋅e de vouloir supprimer votre compte ? Vous allez tout perdre. Identités, paramètres, événements créés, messages et participations disparaîtront pour toujours.", "Are you really sure you want to delete your whole account? You'll lose everything. Identities, settings, events created, messages and participations will be gone forever.": "Êtes-vous vraiment certain⋅e de vouloir supprimer votre compte ? Vous allez tout perdre. Identités, paramètres, événements créés, messages et participations disparaîtront pour toujours.",
"Are you sure you want to <b>delete</b> this comment? This action cannot be undone.": "Êtes-vous certain⋅e de vouloir <b>supprimer</b> ce commentaire ? Cette action ne peut pas être annulée.", "Are you sure you want to <b>delete</b> this comment? This action cannot be undone.": "Êtes-vous certain⋅e de vouloir <b>supprimer</b> ce commentaire ? Cette action ne peut pas être annulée.",
"Are you sure you want to <b>delete</b> this event? This action cannot be undone. You may want to engage the conversation with the event creator or edit its event instead.": "Êtes-vous certain⋅e de vouloir <b>supprimer</b> cet évènement ? Cette action n'est pas réversible. Vous voulez peut-être engager la conversation avec le créateur de l'évènement ou bien modifier son évènement à la place.", "Are you sure you want to <b>delete</b> this event? This action cannot be undone. You may want to engage the discussion with the event creator or edit its event instead.": "Êtes-vous certain⋅e de vouloir <b>supprimer</b> cet évènement ? Cette action n'est pas réversible. Vous voulez peut-être engager la discussion avec le créateur de l'évènement ou bien modifier son évènement à la place.",
"Are you sure you want to cancel the event creation? You'll lose all modifications.": "Étes-vous certain⋅e de vouloir annuler la création de l'évènement ? Vous allez perdre toutes vos modifications.", "Are you sure you want to cancel the event creation? You'll lose all modifications.": "Étes-vous certain⋅e de vouloir annuler la création de l'évènement ? Vous allez perdre toutes vos modifications.",
"Are you sure you want to cancel the event edition? You'll lose all modifications.": "Êtes-vous certain⋅e de vouloir annuler la modification de l'évènement ? Vous allez perdre toutes vos modifications.", "Are you sure you want to cancel the event edition? You'll lose all modifications.": "Êtes-vous certain⋅e de vouloir annuler la modification de l'évènement ? Vous allez perdre toutes vos modifications.",
"Are you sure you want to cancel your participation at event \"{title}\"?": "Êtes-vous certain⋅e de vouloir annuler votre participation à l'évènement « {title} » ?", "Are you sure you want to cancel your participation at event \"{title}\"?": "Êtes-vous certain⋅e de vouloir annuler votre participation à l'évènement « {title} » ?",
@ -710,5 +710,9 @@
"Error while login with {provider}. Retry or login another way.": "Erreur lors de la connexion avec {provider}. Réessayez ou bien connectez vous autrement.", "Error while login with {provider}. Retry or login another way.": "Erreur lors de la connexion avec {provider}. Réessayez ou bien connectez vous autrement.",
"Error while login with {provider}. This login provider doesn't exist.": "Erreur lors de la connexion avec {provider}. Cette méthode de connexion n'existe pas.", "Error while login with {provider}. This login provider doesn't exist.": "Erreur lors de la connexion avec {provider}. Cette méthode de connexion n'existe pas.",
"This user has been disabled": "Cet utilisateur·ice a été désactivé·e", "This user has been disabled": "Cet utilisateur·ice a été désactivé·e",
"You can't reset your password because you use a 3rd-party auth provider to login.": "Vous ne pouvez pas réinitialiser votre mot de passe car vous vous connectez via une méthode externe." "You can't reset your password because you use a 3rd-party auth provider to login.": "Vous ne pouvez pas réinitialiser votre mot de passe car vous vous connectez via une méthode externe.",
"Update post {name}": "Mettre à jour le billet {name}",
"Create a new post": "Créer un nouveau billet",
"Post": "Billet",
"By {author}": "Par {author}"
} }

View File

@ -41,7 +41,7 @@
"Are you going to this event?": "Anatz a aqueste eveniment ?", "Are you going to this event?": "Anatz a aqueste eveniment ?",
"Are you really sure you want to delete your whole account? You'll lose everything. Identities, settings, events created, messages and participations will be gone forever.": "Volètz vertadièrament suprimir vòstre compte? O perdretz tot. Identitats, paramètres, eveniments creats, messatges e participacions desapareisseràn per totjorn.", "Are you really sure you want to delete your whole account? You'll lose everything. Identities, settings, events created, messages and participations will be gone forever.": "Volètz vertadièrament suprimir vòstre compte? O perdretz tot. Identitats, paramètres, eveniments creats, messatges e participacions desapareisseràn per totjorn.",
"Are you sure you want to <b>delete</b> this comment? This action cannot be undone.": "Volètz vertadièrament <b>suprimir</b> aqueste comentari? Aquesta accion es irreversibla.", "Are you sure you want to <b>delete</b> this comment? This action cannot be undone.": "Volètz vertadièrament <b>suprimir</b> aqueste comentari? Aquesta accion es irreversibla.",
"Are you sure you want to <b>delete</b> this event? This action cannot be undone. You may want to engage the conversation with the event creator or edit its event instead.": "Volètz vertadièrament <b>suprimir</b> aqueste eveniment? Aquesta accion es irreversibla. Benlèu qua la plaça volètz començar una conversacion amb lorganizaire o modificar sos eveniment.", "Are you sure you want to <b>delete</b> this event? This action cannot be undone. You may want to engage the discussion with the event creator or edit its event instead.": "Volètz vertadièrament <b>suprimir</b> aqueste eveniment? Aquesta accion es irreversibla. Benlèu qua la plaça volètz començar una conversacion amb lorganizaire o modificar sos eveniment.",
"Are you sure you want to cancel the event creation? You'll lose all modifications.": "Volètz vertadièrament anullar la creacion de leveniment ? Perdretz totas vòstras modificacions.", "Are you sure you want to cancel the event creation? You'll lose all modifications.": "Volètz vertadièrament anullar la creacion de leveniment ? Perdretz totas vòstras modificacions.",
"Are you sure you want to cancel the event edition? You'll lose all modifications.": "Volètz vertadièrament anullar la modificacion de leveniment ? Perdretz totas vòstras modificacions.", "Are you sure you want to cancel the event edition? You'll lose all modifications.": "Volètz vertadièrament anullar la modificacion de leveniment ? Perdretz totas vòstras modificacions.",
"Are you sure you want to cancel your participation at event \"{title}\"?": "Volètz vertadièrament anullar vòstra participacion a leveniment « {title} » ?", "Are you sure you want to cancel your participation at event \"{title}\"?": "Volètz vertadièrament anullar vòstra participacion a leveniment « {title} » ?",
@ -84,10 +84,10 @@
"Confirmed: Will happen": "Confirmat : se tendrà", "Confirmed: Will happen": "Confirmat : se tendrà",
"Contact": "Contacte", "Contact": "Contacte",
"Continue editing": "Contunhar la modificacion", "Continue editing": "Contunhar la modificacion",
"Conversations": "Conversacions", "Discussions": "Conversacions",
"Country": "País", "Country": "País",
"Create": "Crear", "Create": "Crear",
"Create a new conversation": "Crear una conversacion novèla", "Create a new discussion": "Crear una conversacion novèla",
"Create a new event": "Crear un eveniment novèl", "Create a new event": "Crear un eveniment novèl",
"Create a new group": "Crear un grop novèl", "Create a new group": "Crear un grop novèl",
"Create a new identity": "Crear una identitat novèla", "Create a new identity": "Crear una identitat novèla",
@ -273,7 +273,7 @@
"My groups": "Mos grops", "My groups": "Mos grops",
"My identities": "Mas identitats", "My identities": "Mas identitats",
"Name": "Nom", "Name": "Nom",
"New conversation": "Conversacion novèla", "New discussion": "Conversacion novèla",
"New email": "Adreça novèla", "New email": "Adreça novèla",
"New folder": "Dossièr novèl", "New folder": "Dossièr novèl",
"New link": "Ligam novèl", "New link": "Ligam novèl",

View File

@ -26,7 +26,7 @@
"Anonymous participations": "Participações anônimas", "Anonymous participations": "Participações anônimas",
"Approve": "Aprovar", "Approve": "Aprovar",
"Are you sure you want to <b>delete</b> this comment? This action cannot be undone.": "Você está seguro que quer <b>apagar</b> este comentário? Esta ação não pode ser desfeita.", "Are you sure you want to <b>delete</b> this comment? This action cannot be undone.": "Você está seguro que quer <b>apagar</b> este comentário? Esta ação não pode ser desfeita.",
"Are you sure you want to <b>delete</b> this event? This action cannot be undone. You may want to engage the conversation with the event creator or edit its event instead.": "Você está seguro que quer <b>apagar</b> este evento? Esta ação não pode ser desfeita. Talvez você queira tentar uma conversa com o criador do evento ou, então, editar este evento.", "Are you sure you want to <b>delete</b> this event? This action cannot be undone. You may want to engage the discussion with the event creator or edit its event instead.": "Você está seguro que quer <b>apagar</b> este evento? Esta ação não pode ser desfeita. Talvez você queira tentar uma conversa com o criador do evento ou, então, editar este evento.",
"Are you sure you want to cancel the event creation? You'll lose all modifications.": "Você está seguro que quer cancelar a criação do evento? Você perderá todas as modificações.", "Are you sure you want to cancel the event creation? You'll lose all modifications.": "Você está seguro que quer cancelar a criação do evento? Você perderá todas as modificações.",
"Are you sure you want to cancel the event edition? You'll lose all modifications.": "Você está seguro que quer cancelar a edição do evento? Você perderá todas as modificações.", "Are you sure you want to cancel the event edition? You'll lose all modifications.": "Você está seguro que quer cancelar a edição do evento? Você perderá todas as modificações.",
"Are you sure you want to cancel your participation at event \"{title}\"?": "Você está seguro que quer cancelar a sua participação no evento \"{title}\"?", "Are you sure you want to cancel your participation at event \"{title}\"?": "Você está seguro que quer cancelar a sua participação no evento \"{title}\"?",

View File

@ -6,15 +6,29 @@ import Component from "vue-class-component";
import VueScrollTo from "vue-scrollto"; import VueScrollTo from "vue-scrollto";
import VueMeta from "vue-meta"; import VueMeta from "vue-meta";
import VTooltip from "v-tooltip"; import VTooltip from "v-tooltip";
import TimeAgo from "javascript-time-ago";
import App from "./App.vue"; import App from "./App.vue";
import router from "./router"; import router from "./router";
import { NotifierPlugin } from "./plugins/notifier"; import { NotifierPlugin } from "./plugins/notifier";
import filters from "./filters"; import filters from "./filters";
import { i18n } from "./utils/i18n"; import { i18n } from "./utils/i18n";
import messages from "./i18n";
import apolloProvider from "./vue-apollo"; import apolloProvider from "./vue-apollo";
Vue.config.productionTip = false; Vue.config.productionTip = false;
let language = document.documentElement.getAttribute("lang") as string;
language =
language ||
((window.navigator as any).userLanguage || window.navigator.language).replace(/-/, "_");
export const locale =
language && messages.hasOwnProperty(language) ? language : language.split("-")[0];
import(`javascript-time-ago/locale/${locale}`).then((localeFile) => {
TimeAgo.addLocale(localeFile);
Vue.prototype.$timeAgo = new TimeAgo(locale);
});
Vue.use(Buefy); Vue.use(Buefy);
Vue.use(NotifierPlugin); Vue.use(NotifierPlugin);
Vue.use(filters); Vue.use(filters);

View File

@ -1,34 +0,0 @@
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",
}
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 },
},
];

View File

@ -0,0 +1,34 @@
import { RouteConfig } from "vue-router";
import CreateDiscussion from "@/views/Discussions/Create.vue";
import DiscussionsList from "@/views/Discussions/DiscussionsList.vue";
import discussion from "@/views/Discussions/Discussion.vue";
export enum DiscussionRouteName {
DISCUSSION_LIST = "DISCUSSION_LIST",
CREATE_DISCUSSION = "CREATE_DISCUSSION",
DISCUSSION = "DISCUSSION",
}
export const discussionRoutes: RouteConfig[] = [
{
path: "/@:preferredUsername/discussions",
name: DiscussionRouteName.DISCUSSION_LIST,
component: DiscussionsList,
props: true,
meta: { requiredAuth: false },
},
{
path: "/@:preferredUsername/discussions/new",
name: DiscussionRouteName.CREATE_DISCUSSION,
component: CreateDiscussion,
props: true,
meta: { requiredAuth: true },
},
{
path: "/@:preferredUsername/c/:slug/:comment_id?",
name: DiscussionRouteName.DISCUSSION,
component: discussion,
props: true,
meta: { requiredAuth: false },
},
];

View File

@ -1,4 +1,4 @@
import { RouteConfig } from "vue-router"; import { RouteConfig, Route } from "vue-router";
export enum GroupsRouteName { export enum GroupsRouteName {
TODO_LISTS = "TODO_LISTS", TODO_LISTS = "TODO_LISTS",
@ -10,6 +10,10 @@ export enum GroupsRouteName {
RESOURCES = "RESOURCES", RESOURCES = "RESOURCES",
RESOURCE_FOLDER_ROOT = "RESOURCE_FOLDER_ROOT", RESOURCE_FOLDER_ROOT = "RESOURCE_FOLDER_ROOT",
RESOURCE_FOLDER = "RESOURCE_FOLDER", RESOURCE_FOLDER = "RESOURCE_FOLDER",
POST_CREATE = "POST_CREATE",
POST_EDIT = "POST_EDIT",
POST = "POST",
POSTS = "POSTS",
} }
const resourceFolder = () => import("@/views/Resources/ResourceFolder.vue"); const resourceFolder = () => import("@/views/Resources/ResourceFolder.vue");
@ -61,6 +65,7 @@ export const groupsRoutes: RouteConfig[] = [
{ {
path: "public", path: "public",
name: GroupsRouteName.GROUP_PUBLIC_SETTINGS, name: GroupsRouteName.GROUP_PUBLIC_SETTINGS,
component: () => import("../views/Group/GroupSettings.vue"),
}, },
{ {
path: "members", path: "members",
@ -70,4 +75,28 @@ export const groupsRoutes: RouteConfig[] = [
}, },
], ],
}, },
{
path: "/@:preferredUsername/p/new",
component: () => import("@/views/Posts/Edit.vue"),
props: true,
name: GroupsRouteName.POST_CREATE,
},
{
path: "/p/:slug/edit",
component: () => import("@/views/Posts/Edit.vue"),
props: (route: Route) => ({ ...route.params, ...{ isUpdate: true } }),
name: GroupsRouteName.POST_EDIT,
},
{
path: "/p/:slug",
component: () => import("@/views/Posts/Post.vue"),
props: true,
name: GroupsRouteName.POST,
},
{
path: "/@:preferredUsername/p",
component: () => import("@/views/Posts/List.vue"),
props: true,
name: GroupsRouteName.POSTS,
},
]; ];

View File

@ -11,7 +11,7 @@ import { authGuardIfNeeded } from "./guards/auth-guard";
import Search from "../views/Search.vue"; import Search from "../views/Search.vue";
import { settingsRoutes } from "./settings"; import { settingsRoutes } from "./settings";
import { groupsRoutes } from "./groups"; import { groupsRoutes } from "./groups";
import { conversationRoutes } from "./conversation"; import { discussionRoutes } from "./discussion";
import { userRoutes } from "./user"; import { userRoutes } from "./user";
import RouteName from "./name"; import RouteName from "./name";
@ -46,7 +46,7 @@ const router = new Router({
...settingsRoutes, ...settingsRoutes,
...actorRoutes, ...actorRoutes,
...groupsRoutes, ...groupsRoutes,
...conversationRoutes, ...discussionRoutes,
...errorRoutes, ...errorRoutes,
{ {
path: "/search/:searchTerm/:searchType?", path: "/search/:searchTerm/:searchType?",

View File

@ -3,7 +3,7 @@ import { ActorRouteName } from "./actor";
import { ErrorRouteName } from "./error"; import { ErrorRouteName } from "./error";
import { SettingsRouteName } from "./settings"; import { SettingsRouteName } from "./settings";
import { GroupsRouteName } from "./groups"; import { GroupsRouteName } from "./groups";
import { ConversationRouteName } from "./conversation"; import { DiscussionRouteName } from "./discussion";
import { UserRouteName } from "./user"; import { UserRouteName } from "./user";
enum GlobalRouteName { enum GlobalRouteName {
@ -29,6 +29,6 @@ export default {
...ActorRouteName, ...ActorRouteName,
...SettingsRouteName, ...SettingsRouteName,
...GroupsRouteName, ...GroupsRouteName,
...ConversationRouteName, ...DiscussionRouteName,
...ErrorRouteName, ...ErrorRouteName,
}; };

View File

@ -3,8 +3,9 @@ import { Paginate } from "../paginate";
import { IResource } from "../resource"; import { IResource } from "../resource";
import { ITodoList } from "../todos"; import { ITodoList } from "../todos";
import { IEvent } from "../event.model"; import { IEvent } from "../event.model";
import { IConversation } from "../conversations"; import { IDiscussion } from "../discussions";
import { IPerson } from "./person.model"; import { IPerson } from "./person.model";
import { IPost } from "../post.model";
export enum MemberRole { export enum MemberRole {
NOT_APPROVED = "NOT_APPROVED", NOT_APPROVED = "NOT_APPROVED",
@ -20,7 +21,7 @@ export interface IGroup extends IActor {
members: Paginate<IMember>; members: Paginate<IMember>;
resources: Paginate<IResource>; resources: Paginate<IResource>;
todoLists: Paginate<ITodoList>; todoLists: Paginate<ITodoList>;
conversations: Paginate<IConversation>; discussions: Paginate<IDiscussion>;
organizedEvents: Paginate<IEvent>; organizedEvents: Paginate<IEvent>;
} }
@ -39,9 +40,11 @@ export class Group extends Actor implements IGroup {
todoLists: Paginate<ITodoList> = { elements: [], total: 0 }; todoLists: Paginate<ITodoList> = { elements: [], total: 0 };
conversations: Paginate<IConversation> = { elements: [], total: 0 }; discussions: Paginate<IDiscussion> = { elements: [], total: 0 };
organizedEvents!: Paginate<IEvent>; organizedEvents: Paginate<IEvent> = { elements: [], total: 0 };
posts: Paginate<IPost> = { elements: [], total: 0 };
constructor(hash: IGroup | {} = {}) { constructor(hash: IGroup | {} = {}) {
super(hash); super(hash);

View File

@ -12,9 +12,10 @@ export interface IComment {
originComment?: IComment; originComment?: IComment;
replies: IComment[]; replies: IComment[];
event?: IEvent; event?: IEvent;
updatedAt?: Date; updatedAt?: Date | string;
deletedAt?: Date; deletedAt?: Date | string;
totalReplies: number; totalReplies: number;
insertedAt?: Date | string;
} }
export class CommentModel implements IComment { export class CommentModel implements IComment {
@ -38,9 +39,11 @@ export class CommentModel implements IComment {
event?: IEvent = undefined; event?: IEvent = undefined;
updatedAt?: Date = undefined; updatedAt?: Date | string = undefined;
deletedAt?: Date = undefined; deletedAt?: Date | string = undefined;
insertedAt?: Date | string = undefined;
totalReplies = 0; totalReplies = 0;
@ -58,6 +61,7 @@ export class CommentModel implements IComment {
this.replies = hash.replies; this.replies = hash.replies;
this.updatedAt = hash.updatedAt; this.updatedAt = hash.updatedAt;
this.deletedAt = hash.deletedAt; this.deletedAt = hash.deletedAt;
this.insertedAt = new Date(hash.insertedAt as string);
this.totalReplies = hash.totalReplies; this.totalReplies = hash.totalReplies;
} }
} }

View File

@ -1,13 +0,0 @@
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>;
}

View File

@ -0,0 +1,44 @@
import { IActor, IPerson } from "@/types/actor";
import { IComment, CommentModel } from "@/types/comment.model";
import { Paginate } from "@/types/paginate";
export interface IDiscussion {
id?: string;
title: string;
slug?: string;
creator?: IPerson;
actor?: IActor;
lastComment?: IComment;
comments: Paginate<IComment>;
}
export class Discussion implements IDiscussion {
id?: string;
title = "";
comments: Paginate<IComment> = { total: 0, elements: [] };
slug?: string = undefined;
creator?: IPerson = undefined;
actor?: IActor = undefined;
lastComment?: IComment = undefined;
constructor(hash?: IDiscussion) {
if (!hash) return;
this.id = hash.id;
this.title = hash.title;
this.comments = {
total: hash.comments.total,
elements: hash.comments.elements.map((comment: IComment) => new CommentModel(comment)),
};
this.slug = hash.slug;
this.creator = hash.creator;
this.actor = hash.actor;
this.lastComment = hash.lastComment;
}
}

View File

@ -0,0 +1,26 @@
import { ITag } from "./tag.model";
import { IPicture } from "./picture.model";
import { IActor } from "./actor";
export enum PostVisibility {
PUBLIC = "PUBLIC",
UNLISTED = "UNLISTED",
RESTRICTED = "RESTRICTED",
PRIVATE = "PRIVATE",
}
export interface IPost {
id?: string;
slug?: string;
url?: string;
local: boolean;
title: string;
body: string;
tags?: ITag[];
picture?: IPicture | null;
draft: boolean;
visibility: PostVisibility;
author?: IActor;
attributedTo?: IActor;
publishAt?: Date;
}

View File

@ -4,7 +4,7 @@ import messages from "../i18n/index";
let language = document.documentElement.getAttribute("lang") as string; let language = document.documentElement.getAttribute("lang") as string;
language = language || ((window.navigator as any).userLanguage || window.navigator.language).replace(/-/, "_"); language = language || ((window.navigator as any).userLanguage || window.navigator.language).replace(/-/, "_");
const locale = language && messages.hasOwnProperty(language) ? language : language.split("-")[0]; export const locale = language && messages.hasOwnProperty(language) ? language : language.split("-")[0];
Vue.use(VueI18n); Vue.use(VueI18n);

View File

@ -83,6 +83,7 @@ import { IStatistics } from "../../types/statistics.model";
}) })
export default class AboutInstance extends Vue { export default class AboutInstance extends Vue {
config!: IConfig; config!: IConfig;
statistics!: IStatistics; statistics!: IStatistics;
get isContactEmail(): boolean { get isContactEmail(): boolean {
@ -97,7 +98,8 @@ export default class AboutInstance extends Vue {
if (!this.config.contact) return null; if (!this.config.contact) return null;
if (this.isContactEmail) { if (this.isContactEmail) {
return { uri: `mailto:${this.config.contact}`, text: this.config.contact }; return { uri: `mailto:${this.config.contact}`, text: this.config.contact };
} else if (this.isContactURL) { }
if (this.isContactURL) {
return { return {
uri: this.config.contact, uri: this.config.contact,
text: this.urlToHostname(this.config.contact) || (this.$t("Contact") as string), text: this.urlToHostname(this.config.contact) || (this.$t("Contact") as string),

View File

@ -160,7 +160,7 @@ const EVENTS_PER_PAGE = 10;
}, },
}) })
export default class AdminProfile extends Vue { export default class AdminProfile extends Vue {
@Prop({ required: true }) id!: String; @Prop({ required: true }) id!: string;
person!: IPerson; person!: IPerson;
@ -171,6 +171,7 @@ export default class AdminProfile extends Vue {
EVENTS_PER_PAGE = EVENTS_PER_PAGE; EVENTS_PER_PAGE = EVENTS_PER_PAGE;
organizedEventsPage = 1; organizedEventsPage = 1;
participationsPage = 1; participationsPage = 1;
get metadata(): Array<object> { get metadata(): Array<object> {

View File

@ -81,7 +81,7 @@ import { IPerson } from "../../types/actor";
}, },
}) })
export default class AdminUserProfile extends Vue { export default class AdminUserProfile extends Vue {
@Prop({ required: true }) id!: String; @Prop({ required: true }) id!: string;
user!: IUser; user!: IUser;

View File

@ -105,13 +105,19 @@ const PROFILES_PER_PAGE = 10;
}) })
export default class Profiles extends Vue { export default class Profiles extends Vue {
page = 1; page = 1;
preferredUsername = ""; preferredUsername = "";
name = ""; name = "";
domain = ""; domain = "";
local = true; local = true;
suspended = false; suspended = false;
PROFILES_PER_PAGE = PROFILES_PER_PAGE; PROFILES_PER_PAGE = PROFILES_PER_PAGE;
RouteName = RouteName; RouteName = RouteName;
async onPageChange(page: number) { async onPageChange(page: number) {

View File

@ -270,6 +270,7 @@ export default class Settings extends Vue {
adminSettings!: IAdminSettings; adminSettings!: IAdminSettings;
InstanceTermsType = InstanceTermsType; InstanceTermsType = InstanceTermsType;
InstancePrivacyType = InstancePrivacyType; InstancePrivacyType = InstancePrivacyType;
RouteName = RouteName; RouteName = RouteName;

View File

@ -109,9 +109,11 @@ const USERS_PER_PAGE = 10;
}) })
export default class Users extends Vue { export default class Users extends Vue {
page = 1; page = 1;
email = ""; email = "";
USERS_PER_PAGE = USERS_PER_PAGE; USERS_PER_PAGE = USERS_PER_PAGE;
RouteName = RouteName; RouteName = RouteName;
async onPageChange(page: number) { async onPageChange(page: number) {

View File

@ -1,243 +0,0 @@
<template>
<div class="container section" v-if="conversation">
<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: conversation.actor.preferredUsername },
}"
>{{ `@${conversation.actor.preferredUsername}` }}</router-link
>
</li>
<li>
<router-link
:to="{
name: RouteName.CONVERSATION_LIST,
params: { preferredUsername: conversation.actor.preferredUsername },
}"
>{{ $t("Discussions") }}</router-link
>
</li>
<li class="is-active">
<router-link :to="{ name: RouteName.CONVERSATION, params: { id: conversation.id } }">{{
conversation.title
}}</router-link>
</li>
</ul>
</nav>
<section>
<div class="conversation-title">
<h2 class="title" v-if="!editTitleMode">
{{ conversation.title }}
<span
@click="
() => {
newTitle = conversation.title;
editTitleMode = true;
}
"
>
<b-icon icon="pencil" />
</span>
</h2>
<form v-else @submit.prevent="updateConversation" class="title-edit">
<b-input :value="conversation.title" v-model="newTitle" />
<div class="buttons">
<b-button type="is-primary" native-type="submit" icon-right="check" />
<b-button
@click="
() => {
editTitleMode = false;
newTitle = '';
}
"
icon-right="close"
/>
</div>
</form>
</div>
<conversation-comment
v-for="comment in conversation.comments.elements"
:key="comment.id"
:comment="comment"
/>
<b-button
v-if="conversation.comments.elements.length < conversation.comments.total"
@click="loadMoreComments"
>Fetch more</b-button
>
<form @submit.prevent="reply">
<b-field :label="$t('Text')">
<editor v-model="newComment" />
</b-field>
<b-button native-type="submit" type="is-primary">{{ $t("Reply") }}</b-button>
</form>
</section>
</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/name";
@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 = "";
newTitle = "";
editTitleMode = false;
page = 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.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;
}
}
</script>
<style lang="scss" scoped>
div.container.section {
background: white;
div.conversation-title {
margin-bottom: 0.75rem;
h2.title {
span {
cursor: pointer;
}
}
form.title-edit {
div.control {
margin-bottom: 0.75rem;
}
}
}
}
</style>

View File

@ -2,13 +2,13 @@
<section class="section container"> <section class="section container">
<h1>{{ $t("Create a discussion") }}</h1> <h1>{{ $t("Create a discussion") }}</h1>
<form @submit.prevent="createConversation"> <form @submit.prevent="createDiscussion">
<b-field :label="$t('Title')"> <b-field :label="$t('Title')">
<b-input aria-required="true" required v-model="conversation.title" /> <b-input aria-required="true" required v-model="discussion.title" />
</b-field> </b-field>
<b-field :label="$t('Text')"> <b-field :label="$t('Text')">
<editor v-model="conversation.text" /> <editor v-model="discussion.text" />
</b-field> </b-field>
<button class="button is-primary" type="submit">{{ $t("Create the discussion") }}</button> <button class="button is-primary" type="submit">{{ $t("Create the discussion") }}</button>
@ -20,7 +20,7 @@
import { Component, Prop, Vue } from "vue-property-decorator"; import { Component, Prop, Vue } from "vue-property-decorator";
import { IGroup, IPerson } from "@/types/actor"; import { IGroup, IPerson } from "@/types/actor";
import { CURRENT_ACTOR_CLIENT, FETCH_GROUP } from "@/graphql/actor"; import { CURRENT_ACTOR_CLIENT, FETCH_GROUP } from "@/graphql/actor";
import { CREATE_CONVERSATION } from "@/graphql/conversation"; import { CREATE_DISCUSSION } from "@/graphql/discussion";
import RouteName from "../../router/name"; import RouteName from "../../router/name";
@Component({ @Component({
@ -41,36 +41,45 @@ import RouteName from "../../router/name";
}, },
}, },
}, },
metaInfo() {
return {
// eslint-disable-next-line @typescript-eslint/ban-ts-ignore
// @ts-ignore
title: this.$t("Create a discussion") as string,
// all titles will be injected into this template
titleTemplate: "%s | Mobilizon",
};
},
}) })
export default class CreateConversation extends Vue { export default class CreateDiscussion extends Vue {
@Prop({ type: String, required: true }) preferredUsername!: string; @Prop({ type: String, required: true }) preferredUsername!: string;
group!: IGroup; group!: IGroup;
currentActor!: IPerson; currentActor!: IPerson;
conversation = { title: "", text: "" }; discussion = { title: "", text: "" };
async createConversation() { async createDiscussion() {
try { try {
const { data } = await this.$apollo.mutate({ const { data } = await this.$apollo.mutate({
mutation: CREATE_CONVERSATION, mutation: CREATE_DISCUSSION,
variables: { variables: {
title: this.conversation.title, title: this.discussion.title,
text: this.conversation.text, text: this.discussion.text,
actorId: this.group.id, actorId: this.group.id,
creatorId: this.currentActor.id, creatorId: this.currentActor.id,
}, },
// update: (store, { data: { createConversation } }) => { // update: (store, { data: { createDiscussion } }) => {
// // TODO: update group list cache // // TODO: update group list cache
// }, // },
}); });
await this.$router.push({ await this.$router.push({
name: RouteName.CONVERSATION, name: RouteName.DISCUSSION,
params: { params: {
id: data.createConversation.id, id: data.createDiscussion.id,
slug: data.createConversation.slug, slug: data.createDiscussion.slug,
}, },
}); });
} catch (err) { } catch (err) {

View File

@ -0,0 +1,350 @@
<template>
<div class="container section" v-if="discussion">
<nav class="breadcrumb" aria-label="breadcrumbs">
<ul>
<li>
<router-link :to="{ name: RouteName.MY_GROUPS }">{{ $t("My groups") }}</router-link>
</li>
<li>
<router-link
v-if="discussion.actor"
:to="{
name: RouteName.GROUP,
params: { preferredUsername: usernameWithDomain(discussion.actor) },
}"
>{{ discussion.actor.name }}</router-link
>
<b-skeleton v-else animated />
</li>
<li>
<router-link
v-if="discussion.actor"
:to="{
name: RouteName.DISCUSSION_LIST,
params: { preferredUsername: usernameWithDomain(discussion.actor) },
}"
>{{ $t("Discussions") }}</router-link
>
<b-skeleton animated v-else />
</li>
<li class="is-active">
<router-link :to="{ name: RouteName.DISCUSSION, params: { id: discussion.id } }">{{
discussion.title
}}</router-link>
</li>
</ul>
</nav>
<section>
<div class="discussion-title">
<h2 class="title" v-if="discussion.title && !editTitleMode">
{{ discussion.title }}
<span
@click="
() => {
newTitle = discussion.title;
editTitleMode = true;
}
"
>
<b-icon icon="pencil" />
</span>
</h2>
<b-skeleton v-else-if="!editTitleMode" height="50px" animated />
<form v-else @submit.prevent="updateDiscussion" class="title-edit">
<b-input :value="discussion.title" v-model="newTitle" />
<div class="buttons">
<b-button type="is-primary" native-type="submit" icon-right="check" />
<b-button
@click="
() => {
editTitleMode = false;
newTitle = '';
}
"
icon-right="close"
/>
<b-button
@click="deleteConversation"
type="is-danger"
native-type="button"
icon-left="delete"
>{{ $t("Delete conversation") }}</b-button
>
</div>
</form>
</div>
<discussion-comment
v-for="comment in discussion.comments.elements"
:key="comment.id"
:comment="comment"
/>
<b-button
v-if="discussion.comments.elements.length < discussion.comments.total"
@click="loadMoreComments"
>{{ $t("Fetch more") }}</b-button
>
<form @submit.prevent="reply">
<b-field :label="$t('Text')">
<editor v-model="newComment" />
</b-field>
<b-button native-type="submit" type="is-primary">{{ $t("Reply") }}</b-button>
</form>
</section>
</div>
</template>
<script lang="ts">
import { Component, Prop, Vue } from "vue-property-decorator";
import {
GET_DISCUSSION,
REPLY_TO_DISCUSSION,
UPDATE_DISCUSSION,
DELETE_DISCUSSION,
DISCUSSION_COMMENT_CHANGED,
} from "@/graphql/discussion";
import { IDiscussion, Discussion } from "@/types/discussions";
import { usernameWithDomain } from "@/types/actor";
import DiscussionComment from "@/components/Discussion/DiscussionComment.vue";
import { GraphQLError } from "graphql";
import RouteName from "../../router/name";
import { IComment } from "../../types/comment.model";
@Component({
apollo: {
discussion: {
query: GET_DISCUSSION,
variables() {
return {
slug: this.slug,
page: 1,
limit: this.COMMENTS_PER_PAGE,
};
},
skip() {
return !this.slug;
},
error({ graphQLErrors }) {
this.handleErrors(graphQLErrors);
},
update: (data) => new Discussion(data.discussion),
subscribeToMore: {
document: DISCUSSION_COMMENT_CHANGED,
variables() {
return {
slug: this.slug,
};
},
updateQuery: (previousResult, { subscriptionData }) => {
const previousDiscussion = previousResult.discussion;
console.log("updating subscription with ", subscriptionData);
if (
!previousDiscussion.comments.elements.find(
(comment: IComment) =>
comment.id === subscriptionData.data.discussionCommentChanged.lastComment.id
)
) {
previousDiscussion.lastComment =
subscriptionData.data.discussionCommentChanged.lastComment;
previousDiscussion.comments.elements.push(
subscriptionData.data.discussionCommentChanged.lastComment
);
previousDiscussion.comments.total += 1;
}
return previousDiscussion;
},
},
},
},
components: {
DiscussionComment,
editor: () => import(/* webpackChunkName: "editor" */ "@/components/Editor.vue"),
},
metaInfo() {
return {
// eslint-disable-next-line @typescript-eslint/ban-ts-ignore
// @ts-ignore
title: this.discussion.title,
// all titles will be injected into this template
titleTemplate: "%s | Mobilizon",
};
},
})
export default class discussion extends Vue {
@Prop({ type: String, required: true }) slug!: string;
discussion: IDiscussion = new Discussion();
newComment = "";
newTitle = "";
editTitleMode = false;
page = 1;
hasMoreComments = true;
COMMENTS_PER_PAGE = 10;
RouteName = RouteName;
usernameWithDomain = usernameWithDomain;
async reply() {
await this.$apollo.mutate({
mutation: REPLY_TO_DISCUSSION,
variables: {
discussionId: this.discussion.id,
text: this.newComment,
},
update: (store, { data: { replyToDiscussion } }) => {
const discussionData = store.readQuery<{
discussion: IDiscussion;
}>({
query: GET_DISCUSSION,
variables: {
slug: this.slug,
page: this.page,
},
});
if (!discussionData) return;
const { discussion } = discussionData;
discussion.lastComment = replyToDiscussion.lastComment;
discussion.comments.elements.push(replyToDiscussion.lastComment);
discussion.comments.total += 1;
store.writeQuery({
query: GET_DISCUSSION,
variables: { slug: this.slug, page: this.page },
data: { discussion },
});
},
// We don't need to handle cache update since there's the subscription that handles this for us
});
this.newComment = "";
}
async loadMoreComments() {
if (!this.hasMoreComments) return;
this.page += 1;
try {
await this.$apollo.queries.discussion.fetchMore({
// New variables
variables: {
slug: this.slug,
page: this.page,
limit: this.COMMENTS_PER_PAGE,
},
// Transform the previous result with new data
updateQuery: (previousResult, { fetchMoreResult }) => {
if (!fetchMoreResult) return previousResult;
const newComments = fetchMoreResult.discussion.comments.elements;
this.hasMoreComments = newComments.length === 1;
const { discussion } = previousResult;
discussion.comments.elements = [
...previousResult.discussion.comments.elements,
...newComments,
];
return { discussion };
},
});
} catch (e) {
console.error(e);
}
}
async updateDiscussion() {
await this.$apollo.mutate({
mutation: UPDATE_DISCUSSION,
variables: {
discussionId: this.discussion.id,
title: this.newTitle,
},
update: (store, { data: { updateDiscussion } }) => {
const discussionData = store.readQuery<{
discussion: IDiscussion;
}>({
query: GET_DISCUSSION,
variables: {
slug: this.slug,
page: this.page,
},
});
if (!discussionData) return;
const { discussion } = discussionData;
discussion.title = updateDiscussion.title;
store.writeQuery({
query: GET_DISCUSSION,
variables: { slug: this.slug, page: this.page },
data: { discussion },
});
},
});
this.editTitleMode = false;
}
async deleteConversation() {
await this.$apollo.mutate({
mutation: DELETE_DISCUSSION,
variables: {
discussionId: this.discussion.id,
},
});
if (this.discussion.actor) {
return this.$router.push({
name: RouteName.DISCUSSION_LIST,
params: { preferredUsername: usernameWithDomain(this.discussion.actor) },
});
}
}
async handleErrors(errors: GraphQLError[]) {
if (errors[0].message.includes("No such discussion")) {
await this.$router.push({ name: RouteName.PAGE_NOT_FOUND });
}
}
mounted() {
window.addEventListener("scroll", this.handleScroll);
}
destroyed() {
window.removeEventListener("scroll", this.handleScroll);
}
handleScroll() {
const scrollTop =
(document.documentElement && document.documentElement.scrollTop) || document.body.scrollTop;
const scrollHeight =
(document.documentElement && document.documentElement.scrollHeight) ||
document.body.scrollHeight;
const clientHeight = document.documentElement.clientHeight || window.innerHeight;
const scrolledToBottom = Math.ceil(scrollTop + clientHeight + 800) >= scrollHeight;
if (scrolledToBottom) {
this.loadMoreComments();
}
}
}
</script>
<style lang="scss" scoped>
div.container.section {
background: white;
padding: 1rem 5% 4rem;
div.discussion-title {
margin-bottom: 0.75rem;
h2.title {
span {
cursor: pointer;
}
}
form.title-edit {
div.control {
margin-bottom: 0.75rem;
}
}
}
}
</style>

View File

@ -17,7 +17,7 @@
<li class="is-active"> <li class="is-active">
<router-link <router-link
:to="{ :to="{
name: RouteName.CONVERSATION_LIST, name: RouteName.DISCUSSION_LIST,
params: { preferredUsername: usernameWithDomain(group) }, params: { preferredUsername: usernameWithDomain(group) },
}" }"
>{{ $t("Discussions") }}</router-link >{{ $t("Discussions") }}</router-link
@ -26,17 +26,17 @@
</ul> </ul>
</nav> </nav>
<section> <section>
<div v-if="group.conversations.elements.length > 0"> <div v-if="group.discussions.elements.length > 0">
<conversation-list-item <discussion-list-item
:conversation="conversation" :discussion="discussion"
v-for="conversation in group.conversations.elements" v-for="discussion in group.discussions.elements"
:key="conversation.id" :key="discussion.id"
/> />
</div> </div>
<b-button <b-button
tag="router-link" tag="router-link"
:to="{ :to="{
name: RouteName.CREATE_CONVERSATION, name: RouteName.CREATE_DISCUSSION,
params: { preferredUsername: this.preferredUsername }, params: { preferredUsername: this.preferredUsername },
}" }"
>{{ $t("New discussion") }}</b-button >{{ $t("New discussion") }}</b-button
@ -48,11 +48,11 @@
import { Component, Prop, Vue } from "vue-property-decorator"; import { Component, Prop, Vue } from "vue-property-decorator";
import { FETCH_GROUP } from "@/graphql/actor"; import { FETCH_GROUP } from "@/graphql/actor";
import { IGroup, usernameWithDomain } from "@/types/actor"; import { IGroup, usernameWithDomain } from "@/types/actor";
import ConversationListItem from "@/components/Conversation/ConversationListItem.vue"; import DiscussionListItem from "@/components/Discussion/DiscussionListItem.vue";
import RouteName from "../../router/name"; import RouteName from "../../router/name";
@Component({ @Component({
components: { ConversationListItem }, components: { DiscussionListItem },
apollo: { apollo: {
group: { group: {
query: FETCH_GROUP, query: FETCH_GROUP,
@ -66,8 +66,17 @@ import RouteName from "../../router/name";
}, },
}, },
}, },
metaInfo() {
return {
// eslint-disable-next-line @typescript-eslint/ban-ts-ignore
// @ts-ignore
title: this.$t("Discussions") as string,
// all titles will be injected into this template
titleTemplate: "%s | Mobilizon",
};
},
}) })
export default class ConversationsList extends Vue { export default class DiscussionsList extends Vue {
@Prop({ type: String, required: true }) preferredUsername!: string; @Prop({ type: String, required: true }) preferredUsername!: string;
group!: IGroup; group!: IGroup;

View File

@ -777,10 +777,8 @@ export default class Event extends EventMixin {
let reporterId = null; let reporterId = null;
if (this.currentActor.id) { if (this.currentActor.id) {
reporterId = this.currentActor.id; reporterId = this.currentActor.id;
} else { } else if (this.config.anonymous.reports.allowed) {
if (this.config.anonymous.reports.allowed) { reporterId = this.config.anonymous.actorId;
reporterId = this.config.anonymous.actorId;
}
} }
if (!reporterId) return; if (!reporterId) return;
try { try {

View File

@ -171,7 +171,7 @@ export default class MyEvents extends Vue {
static monthlyParticipations( static monthlyParticipations(
participations: IParticipant[], participations: IParticipant[],
revertSort: boolean = false revertSort = false
): Map<string, Participant[]> { ): Map<string, Participant[]> {
const res = participations.filter( const res = participations.filter(
({ event, role }) => event.beginsOn != null && role !== ParticipantRole.REJECTED ({ event, role }) => event.beginsOn != null && role !== ParticipantRole.REJECTED

View File

@ -91,7 +91,7 @@
<span v-if="props.row.actor.name">{{ props.row.actor.name }}</span <span v-if="props.row.actor.name">{{ props.row.actor.name }}</span
><br /> ><br />
<span class="is-size-7 has-text-grey" <span class="is-size-7 has-text-grey"
>@{{ props.row.actor.preferredUsername }}</span >@{{ usernameWithDomain(props.row.actor) }}</span
> >
</span> </span>
<span v-else> <span v-else>
@ -184,6 +184,7 @@
<script lang="ts"> <script lang="ts">
import { Component, Prop, Vue, Watch, Ref } from "vue-property-decorator"; import { Component, Prop, Vue, Watch, Ref } from "vue-property-decorator";
import { DataProxy } from "apollo-cache";
import { import {
IEvent, IEvent,
IEventParticipantStats, IEventParticipantStats,
@ -192,13 +193,11 @@ import {
ParticipantRole, ParticipantRole,
} from "../../types/event.model"; } from "../../types/event.model";
import { PARTICIPANTS, UPDATE_PARTICIPANT } from "../../graphql/event"; import { PARTICIPANTS, UPDATE_PARTICIPANT } from "../../graphql/event";
import ParticipantCard from "../../components/Account/ParticipantCard.vue";
import { CURRENT_ACTOR_CLIENT } from "../../graphql/actor"; import { CURRENT_ACTOR_CLIENT } from "../../graphql/actor";
import { IPerson } from "../../types/actor"; import { IPerson, usernameWithDomain } from "../../types/actor";
import { CONFIG } from "../../graphql/config"; import { CONFIG } from "../../graphql/config";
import { IConfig } from "../../types/config.model"; import { IConfig } from "../../types/config.model";
import { Paginate } from "../../types/paginate"; import { Paginate } from "../../types/paginate";
import { DataProxy } from "apollo-cache";
import { nl2br } from "../../utils/html"; import { nl2br } from "../../utils/html";
import { asyncForEach } from "../../utils/asyncForEach"; import { asyncForEach } from "../../utils/asyncForEach";
import RouteName from "../../router/name"; import RouteName from "../../router/name";
@ -207,9 +206,6 @@ const PARTICIPANTS_PER_PAGE = 10;
const MESSAGE_ELLIPSIS_LENGTH = 130; const MESSAGE_ELLIPSIS_LENGTH = 130;
@Component({ @Component({
components: {
ParticipantCard,
},
apollo: { apollo: {
currentActor: { currentActor: {
query: CURRENT_ACTOR_CLIENT, query: CURRENT_ACTOR_CLIENT,
@ -259,6 +255,8 @@ export default class Participants extends Vue {
RouteName = RouteName; RouteName = RouteName;
usernameWithDomain = usernameWithDomain;
@Ref("queueTable") readonly queueTable!: any; @Ref("queueTable") readonly queueTable!: any;
mounted() { mounted() {

View File

@ -1,60 +1,71 @@
<template> <template>
<div class="container is-widescreen"> <div class="container is-widescreen">
<div <div class="header">
v-if="group && groupMemberships && groupMemberships.includes(group.id)" <nav class="breadcrumb" aria-label="breadcrumbs">
class="block-container" <ul>
> <li>
<div class="block-column"> <router-link :to="{ name: RouteName.MY_GROUPS }">{{ $t("My groups") }}</router-link>
<nav class="breadcrumb" aria-label="breadcrumbs" v-if="group"> </li>
<ul> <li class="is-active">
<li> <router-link
<router-link :to="{ name: RouteName.MY_GROUPS }">{{ $t("My groups") }}</router-link> v-if="group.preferredUsername"
</li> :to="{
<li class="is-active"> name: RouteName.GROUP,
<router-link params: { preferredUsername: usernameWithDomain(group) },
:to="{ }"
name: RouteName.GROUP, >{{ group.name }}</router-link
params: { preferredUsername: usernameWithDomain(group.preferredUsername) },
}"
>{{ group.name }}</router-link
>
</li>
</ul>
</nav>
<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">
<h1>{{ group.name }}</h1>
<small class="has-text-grey">@{{ usernameWithDomain(group) }}</small>
</div>
</div>
<div class="members">
<figure
class="image is-48x48"
:title="
$t(`@{username} ({role})`, {
username: usernameWithDomain(member.actor),
role: member.role,
})
"
v-for="member in group.members.elements"
:key="member.actor.id"
> >
<img <b-skeleton v-else :animated="true"></b-skeleton>
class="is-rounded" </li>
:src="member.actor.avatar.url" </ul>
v-if="member.actor.avatar" </nav>
alt <header class="block-container presentation">
/> <div class="block-column media">
<div class="media-left">
<figure class="image rounded is-128x128" v-if="group.avatar">
<img :src="group.avatar.url" />
</figure> </figure>
<b-icon v-else size="is-large" icon="account-group" />
</div> </div>
</section> <div class="media-content">
<h1 v-if="group.name">{{ group.name }}</h1>
<b-skeleton v-else :animated="true" />
<small class="has-text-grey" v-if="group.preferredUsername"
>@{{ usernameWithDomain(group) }}</small
>
<b-skeleton v-else :animated="true" />
<br />
<router-link
v-if="isCurrentActorAGroupAdmin"
:to="{
name: RouteName.GROUP_PUBLIC_SETTINGS,
params: { preferredUsername: usernameWithDomain(group) },
}"
class="button is-outlined"
>{{ $t("Group settings") }}</router-link
>
</div>
</div>
<div class="block-column members">
<figure
class="image is-48x48"
:title="
$t(`@{username} ({role})`, {
username: usernameWithDomain(member.actor),
role: member.role,
})
"
v-for="member in group.members.elements"
:key="member.actor.id"
>
<img class="is-rounded" :src="member.actor.avatar.url" v-if="member.actor.avatar" alt />
<b-icon v-else size="is-large" icon="account-circle" />
</figure>
</div>
</header>
</div>
<div v-if="isCurrentActorAGroupMember" class="block-container">
<div class="block-column">
<section> <section>
<subtitle>{{ $t("Upcoming events") }}</subtitle> <subtitle>{{ $t("Upcoming events") }}</subtitle>
<div class="organized-events-wrapper" v-if="group.organizedEvents.total > 0"> <div class="organized-events-wrapper" v-if="group.organizedEvents.total > 0">
@ -92,8 +103,17 @@
<section> <section>
<subtitle>{{ $t("Public page") }}</subtitle> <subtitle>{{ $t("Public page") }}</subtitle>
<p>{{ $t("Followed by {count} persons", { count: group.members.total }) }}</p> <p>{{ $t("Followed by {count} persons", { count: group.members.total }) }}</p>
<b-button type="is-light">{{ $t("Edit biography") }}</b-button> <div v-if="group.posts.total > 0" class="posts-wrapper">
<b-button type="is-primary">{{ $t("Post a public message") }}</b-button> <post-list-item v-for="post in group.posts.elements" :key="post.id" :post="post" />
</div>
<router-link
:to="{
name: RouteName.POST_CREATE,
params: { preferredUsername: usernameWithDomain(group) },
}"
class="button is-primary"
>{{ $t("Post a public message") }}</router-link
>
</section> </section>
<section> <section>
<subtitle>{{ $t("Ongoing tasks") }}</subtitle> <subtitle>{{ $t("Ongoing tasks") }}</subtitle>
@ -122,15 +142,15 @@
</section> </section>
<section> <section>
<subtitle>{{ $t("Discussions") }}</subtitle> <subtitle>{{ $t("Discussions") }}</subtitle>
<conversation-list-item <discussion-list-item
v-if="group.conversations.total > 0" v-if="group.discussions.total > 0"
v-for="conversation in group.conversations.elements" v-for="discussion in group.discussions.elements"
:key="conversation.id" :key="discussion.id"
:conversation="conversation" :discussion="discussion"
/> />
<router-link <router-link
:to="{ :to="{
name: RouteName.CONVERSATION_LIST, name: RouteName.DISCUSSION_LIST,
params: { preferredUsername: usernameWithDomain(group) }, params: { preferredUsername: usernameWithDomain(group) },
}" }"
>{{ $t("View all discussions") }}</router-link >{{ $t("View all discussions") }}</router-link
@ -138,24 +158,13 @@
</section> </section>
</div> </div>
</div> </div>
<div v-else-if="group"> <b-message v-else-if="!group && $apollo.loading === false" type="is-danger">
<section class="presentation"> {{ $t("No group found") }}
<div class="media"> </b-message>
<div class="media-left"> <div v-else class="public-container">
<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">@{{ usernameWithDomain(group) }}</small>
</div>
</div>
</section>
<section> <section>
<subtitle>{{ $t("Upcoming events") }}</subtitle> <subtitle>{{ $t("Upcoming events") }}</subtitle>
<div class="organized-events-wrapper" v-if="group.organizedEvents.total > 0"> <div class="organized-events-wrapper" v-if="group && group.organizedEvents.total > 0">
<EventMinimalistCard <EventMinimalistCard
v-for="event in group.organizedEvents.elements" v-for="event in group.organizedEvents.elements"
:event="event" :event="event"
@ -164,16 +173,24 @@
/> />
<router-link :to="{}">{{ $t("View all upcoming events") }}</router-link> <router-link :to="{}">{{ $t("View all upcoming events") }}</router-link>
</div> </div>
<span v-else>{{ $t("No public upcoming events") }}</span> <span v-else-if="group">{{ $t("No public upcoming events") }}</span>
<b-skeleton animated v-else></b-skeleton>
</section> </section>
<!-- {{ group }}--> <!-- {{ group }}-->
<section> <section>
<subtitle>{{ $t("Latest posts") }}</subtitle> <subtitle>{{ $t("Latest posts") }}</subtitle>
<div v-if="group && group.posts.elements">
<router-link
v-for="post in group.posts.elements"
:key="post.id"
:to="{ name: RouteName.POST, params: { slug: post.slug } }"
>
{{ post.title }}
</router-link>
</div>
<b-skeleton animated v-else></b-skeleton>
</section> </section>
</div> </div>
<b-message v-else-if="!group && $apollo.loading === false" type="is-danger">
{{ $t("No group found") }}
</b-message>
</div> </div>
</template> </template>
@ -181,11 +198,19 @@
import { Component, Prop, Vue, Watch } from "vue-property-decorator"; import { Component, Prop, Vue, Watch } from "vue-property-decorator";
import EventCard from "@/components/Event/EventCard.vue"; import EventCard from "@/components/Event/EventCard.vue";
import { FETCH_GROUP, CURRENT_ACTOR_CLIENT, PERSON_MEMBERSHIPS } from "@/graphql/actor"; import { FETCH_GROUP, CURRENT_ACTOR_CLIENT, PERSON_MEMBERSHIPS } from "@/graphql/actor";
import { IActor, IGroup, IPerson, usernameWithDomain } from "@/types/actor"; import {
IActor,
IGroup,
IPerson,
usernameWithDomain,
Group as GroupModel,
MemberRole,
} from "@/types/actor";
import Subtitle from "@/components/Utils/Subtitle.vue"; import Subtitle from "@/components/Utils/Subtitle.vue";
import CompactTodo from "@/components/Todo/CompactTodo.vue"; import CompactTodo from "@/components/Todo/CompactTodo.vue";
import EventMinimalistCard from "@/components/Event/EventMinimalistCard.vue"; import EventMinimalistCard from "@/components/Event/EventMinimalistCard.vue";
import ConversationListItem from "@/components/Conversation/ConversationListItem.vue"; import DiscussionListItem from "@/components/Discussion/DiscussionListItem.vue";
import PostListItem from "@/components/Post/PostListItem.vue";
import ResourceItem from "@/components/Resource/ResourceItem.vue"; import ResourceItem from "@/components/Resource/ResourceItem.vue";
import FolderItem from "@/components/Resource/FolderItem.vue"; import FolderItem from "@/components/Resource/FolderItem.vue";
import RouteName from "../../router/name"; import RouteName from "../../router/name";
@ -214,7 +239,8 @@ import RouteName from "../../router/name";
currentActor: CURRENT_ACTOR_CLIENT, currentActor: CURRENT_ACTOR_CLIENT,
}, },
components: { components: {
ConversationListItem, DiscussionListItem,
PostListItem,
EventMinimalistCard, EventMinimalistCard,
CompactTodo, CompactTodo,
Subtitle, Subtitle,
@ -243,7 +269,7 @@ export default class Group extends Vue {
person!: IPerson; person!: IPerson;
group!: IGroup; group: IGroup = new GroupModel();
loading = true; loading = true;
@ -272,18 +298,63 @@ export default class Group extends Vue {
if (!this.person || !this.person.id) return undefined; if (!this.person || !this.person.id) return undefined;
return this.person.memberships.elements.map(({ parent: { id } }) => id); return this.person.memberships.elements.map(({ parent: { id } }) => id);
} }
get isCurrentActorAGroupMember(): boolean {
return this.groupMemberships != undefined && this.groupMemberships.includes(this.group.id);
}
get isCurrentActorAGroupAdmin(): boolean {
return (
this.person &&
this.person.memberships.elements.some(
({ parent: { id }, role }) => id === this.group.id && role === MemberRole.ADMINISTRATOR
)
);
}
} }
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
@import "../../variables.scss";
div.container { div.container {
background: white; background: white;
margin-bottom: 3rem; margin-bottom: 3rem;
padding: 2rem 0; padding: 2rem 0;
.header,
.public-container {
margin: auto 2rem;
display: flex;
flex-direction: column;
}
.block-container { .block-container {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
&.presentation {
border: 2px solid $purple-2;
padding: 10px 0;
h1 {
color: $purple-1;
font-size: 2rem;
font-weight: 500;
}
.button.is-outlined {
border-color: $purple-2;
}
}
.members {
display: flex;
figure:not(:first-child) {
margin-left: -10px;
}
}
.block-column { .block-column {
flex: 1; flex: 1;
margin: 0 2rem; margin: 0 2rem;
@ -293,10 +364,8 @@ div.container {
display: block; display: block;
} }
&.presentation { .posts-wrapper {
.members { padding-bottom: 1rem;
display: flex;
}
} }
.organized-events-wrapper { .organized-events-wrapper {

View File

@ -3,15 +3,31 @@
<nav class="breadcrumb" aria-label="breadcrumbs"> <nav class="breadcrumb" aria-label="breadcrumbs">
<ul> <ul>
<li> <li>
<router-link :to="{ name: RouteName.GROUP }">{{ group.name }}</router-link> <router-link
:to="{
name: RouteName.GROUP,
params: { preferredUsername: usernameWithDomain(group) },
}"
>{{ group.name }}</router-link
>
</li> </li>
<li> <li>
<router-link :to="{ name: RouteName.GROUP_SETTINGS }">{{ $t("Settings") }}</router-link> <router-link
:to="{
name: RouteName.GROUP_SETTINGS,
params: { preferredUsername: usernameWithDomain(group) },
}"
>{{ $t("Settings") }}</router-link
>
</li> </li>
<li class="is-active"> <li class="is-active">
<router-link :to="{ name: RouteName.GROUP_MEMBERS_SETTINGS }">{{ <router-link
$t("Members") :to="{
}}</router-link> name: RouteName.GROUP_MEMBERS_SETTINGS,
params: { preferredUsername: usernameWithDomain(group) },
}"
>{{ $t("Members") }}</router-link
>
</li> </li>
</ul> </ul>
</nav> </nav>
@ -29,26 +45,127 @@
</b-field> </b-field>
</form> </form>
<h1>{{ $t("Group Members") }} ({{ group.members.total }})</h1> <h1>{{ $t("Group Members") }} ({{ group.members.total }})</h1>
<b-field :label="$t('Status')" horizontal>
<b-select v-model="roles">
<option value="">
{{ $t("Everything") }}
</option>
<option :value="MemberRole.ADMINISTRATOR">
{{ $t("Administrator") }}
</option>
<option :value="MemberRole.MODERATOR">
{{ $t("Moderator") }}
</option>
<option :value="MemberRole.MEMBER">
{{ $t("Member") }}
</option>
<option :value="MemberRole.INVITED">
{{ $t("Invited") }}
</option>
<option :value="MemberRole.NOT_APPROVED">
{{ $t("Not approved") }}
</option>
<option :value="MemberRole.REJECTED">
{{ $t("Rejected") }}
</option>
</b-select>
</b-field>
<b-table
:data="group.members.elements"
ref="queueTable"
:loading="this.$apollo.loading"
paginated
backend-pagination
:pagination-simple="true"
:aria-next-label="$t('Next page')"
:aria-previous-label="$t('Previous page')"
:aria-page-label="$t('Page')"
:aria-current-label="$t('Current page')"
:total="group.members.total"
:per-page="MEMBERS_PER_PAGE"
backend-sorting
:default-sort-direction="'desc'"
:default-sort="['insertedAt', 'desc']"
@page-change="(newPage) => (page = newPage)"
@sort="(field, order) => $emit('sort', field, order)"
>
<template slot-scope="props">
<b-table-column field="actor.preferredUsername" :label="$t('Member')">
<article class="media">
<figure class="media-left image is-48x48" v-if="props.row.actor.avatar">
<img class="is-rounded" :src="props.row.actor.avatar.url" alt="" />
</figure>
<b-icon class="media-left" v-else size="is-large" icon="account-circle" />
<div class="media-content">
<div class="content">
<span v-if="props.row.actor.name">{{ props.row.actor.name }}</span
><br />
<span class="is-size-7 has-text-grey"
>@{{ usernameWithDomain(props.row.actor) }}</span
>
</div>
</div>
</article>
</b-table-column>
<b-table-column field="role" :label="$t('Role')">
<b-tag type="is-primary" v-if="props.row.role === MemberRole.ADMINISTRATOR">
{{ $t("Administrator") }}
</b-tag>
<b-tag type="is-primary" v-else-if="props.row.role === MemberRole.MODERATOR">
{{ $t("Moderator") }}
</b-tag>
<b-tag v-else-if="props.row.role === MemberRole.MEMBER">
{{ $t("Member") }}
</b-tag>
<b-tag type="is-warning" v-else-if="props.row.role === MemberRole.NOT_APPROVED">
{{ $t("Not approved") }}
</b-tag>
<b-tag type="is-danger" v-else-if="props.row.role === MemberRole.REJECTED">
{{ $t("Rejected") }}
</b-tag>
<b-tag type="is-danger" v-else-if="props.row.role === MemberRole.INVITED">
{{ $t("Invited") }}
</b-tag>
</b-table-column>
<b-table-column field="insertedAt" :label="$t('Date')">
<span class="has-text-centered">
{{ props.row.insertedAt | formatDateString }}<br />{{
props.row.insertedAt | formatTimeString
}}
</span>
</b-table-column>
</template>
<template slot="empty">
<section class="section">
<div class="content has-text-grey has-text-centered">
<p>{{ $t("No member matches the filters") }}</p>
</div>
</section>
</template>
</b-table>
<pre>{{ group.members }}</pre> <pre>{{ group.members }}</pre>
</section> </section>
</div> </div>
</template> </template>
<script lang="ts"> <script lang="ts">
import { Component, Vue } from "vue-property-decorator"; import { Component, Vue, Watch } from "vue-property-decorator";
import RouteName from "../../router/name"; import RouteName from "../../router/name";
import { FETCH_GROUP } from "../../graphql/actor"; import { INVITE_MEMBER, GROUP_MEMBERS } from "../../graphql/member";
import { INVITE_MEMBER } from "../../graphql/member"; import { IGroup, usernameWithDomain } from "../../types/actor";
import { IGroup } from "../../types/actor"; import { IMember, MemberRole } from "../../types/actor/group.model";
import { IMember } from "../../types/actor/group.model";
@Component({ @Component({
apollo: { apollo: {
group: { group: {
query: FETCH_GROUP, query: GROUP_MEMBERS,
// fetchPolicy: "network-only",
variables() { variables() {
return { return {
name: this.$route.params.preferredUsername, name: this.$route.params.preferredUsername,
page: 1,
limit: this.MEMBERS_PER_PAGE,
roles: this.roles,
}; };
}, },
skip() { skip() {
@ -66,6 +183,23 @@ export default class GroupMembers extends Vue {
newMemberUsername = ""; newMemberUsername = "";
MemberRole = MemberRole;
roles: MemberRole | "" = "";
page = 1;
MEMBERS_PER_PAGE = 10;
usernameWithDomain = usernameWithDomain;
mounted() {
const roleQuery = this.$route.query.role as string;
if (Object.values(MemberRole).includes(roleQuery as MemberRole)) {
this.roles = roleQuery as MemberRole;
}
}
async inviteMember() { async inviteMember() {
await this.$apollo.mutate<{ inviteMember: IMember }>({ await this.$apollo.mutate<{ inviteMember: IMember }>({
mutation: INVITE_MEMBER, mutation: INVITE_MEMBER,
@ -75,5 +209,32 @@ export default class GroupMembers extends Vue {
}, },
}); });
} }
@Watch("page")
loadMoreMembers() {
this.$apollo.queries.event.fetchMore({
// New variables
variables: {
page: this.page,
limit: this.MEMBERS_PER_PAGE,
},
// Transform the previous result with new data
updateQuery: (previousResult, { fetchMoreResult }) => {
const oldMembers = previousResult.group.members;
const newMembers = fetchMoreResult.group.members;
return {
group: {
...previousResult.event,
members: {
elements: [...oldMembers.elements, ...newMembers.elements],
total: newMembers.total,
__typename: oldMembers.__typename,
},
},
};
},
});
}
} }
</script> </script>

View File

@ -0,0 +1,91 @@
<template>
<div>
<nav class="breadcrumb" aria-label="breadcrumbs">
<ul>
<li>
<router-link
:to="{
name: RouteName.GROUP,
params: { preferredUsername: usernameWithDomain(group) },
}"
>{{ group.name }}</router-link
>
</li>
<li>
<router-link
:to="{
name: RouteName.GROUP_SETTINGS,
params: { preferredUsername: usernameWithDomain(group) },
}"
>{{ $t("Settings") }}</router-link
>
</li>
<li class="is-active">
<router-link
:to="{
name: RouteName.GROUP_PUBLIC_SETTINGS,
params: { preferredUsername: usernameWithDomain(group) },
}"
>{{ $t("Group settings") }}</router-link
>
</li>
</ul>
</nav>
<section class="container section">
<form @submit.prevent="updateGroup">
<b-field :label="$t('Group name')">
<b-input v-model="group.name" />
</b-field>
<b-field :label="$t('Group short description')">
<b-input type="textarea" v-model="group.summary"
/></b-field>
<b-button native-type="submit" type="is-primary">{{ $t("Update group") }}</b-button>
</form>
</section>
</div>
</template>
<script lang="ts">
import { Component, Vue } from "vue-property-decorator";
import RouteName from "../../router/name";
import { FETCH_GROUP, UPDATE_GROUP } from "../../graphql/actor";
import { IGroup, usernameWithDomain } from "../../types/actor";
import { IMember, Group } from "../../types/actor/group.model";
import { Paginate } from "../../types/paginate";
@Component({
apollo: {
group: {
query: FETCH_GROUP,
variables() {
return {
name: this.$route.params.preferredUsername,
};
},
skip() {
return !this.$route.params.preferredUsername;
},
},
},
})
export default class GroupSettings extends Vue {
group: IGroup = new Group();
loading = true;
RouteName = RouteName;
newMemberUsername = "";
usernameWithDomain = usernameWithDomain;
async updateGroup() {
await this.$apollo.mutate<{ updateGroup: IGroup }>({
mutation: UPDATE_GROUP,
variables: {
...this.group,
},
});
}
}
</script>

View File

@ -36,7 +36,7 @@ import { ACCEPT_INVITATION } from "../../graphql/member";
InvitationCard, InvitationCard,
}, },
apollo: { apollo: {
paginatedGroups: { membershipsPages: {
query: LOGGED_USER_MEMBERSHIPS, query: LOGGED_USER_MEMBERSHIPS,
fetchPolicy: "network-only", fetchPolicy: "network-only",
variables: { variables: {
@ -57,18 +57,22 @@ import { ACCEPT_INVITATION } from "../../graphql/member";
}, },
}) })
export default class MyEvents extends Vue { export default class MyEvents extends Vue {
paginatedGroups!: Paginate<IMember>; membershipsPages!: Paginate<IMember>;
RouteName = RouteName; RouteName = RouteName;
get invitations() { get invitations() {
if (!this.paginatedGroups) return []; if (!this.membershipsPages) return [];
return this.paginatedGroups.elements.filter((member) => member.role === MemberRole.INVITED); return this.membershipsPages.elements.filter(
(member: IMember) => member.role === MemberRole.INVITED
);
} }
get memberships() { get memberships() {
if (!this.paginatedGroups) return []; if (!this.membershipsPages) return [];
return this.paginatedGroups.elements.filter((member) => member.role !== MemberRole.INVITED); return this.membershipsPages.elements.filter(
(member: IMember) => member.role !== MemberRole.INVITED
);
} }
async acceptInvitation(id: string) { async acceptInvitation(id: string) {

View File

@ -315,7 +315,7 @@ export default class Report extends Vue {
this.$buefy.dialog.confirm({ this.$buefy.dialog.confirm({
title: this.$t("Deleting event") as string, title: this.$t("Deleting event") as string,
message: this.$t( message: this.$t(
"Are you sure you want to <b>delete</b> this event? This action cannot be undone. You may want to engage the conversation with the event creator or edit its event instead." "Are you sure you want to <b>delete</b> this event? This action cannot be undone. You may want to engage the discussion with the event creator or edit its event instead."
) as string, ) as string,
confirmText: this.$t("Delete Event") as string, confirmText: this.$t("Delete Event") as string,
type: "is-danger", type: "is-danger",

217
js/src/views/Posts/Edit.vue Normal file
View File

@ -0,0 +1,217 @@
<template>
<form @submit.prevent="publish(false)">
<div class="container section">
<h1 class="title" v-if="isUpdate === true">
{{ $t("Edit post") }}
</h1>
<h1 class="title" v-else>
{{ $t("Add a new post") }}
</h1>
<subtitle>{{ $t("General information") }}</subtitle>
<picture-upload v-model="pictureFile" :textFallback="$t('Headline picture')" />
<b-field :label="$t('Title')">
<b-input size="is-large" aria-required="true" required v-model="post.title" />
</b-field>
<tag-input v-model="post.tags" :data="tags" path="title" />
<div class="field">
<label class="label">{{ $t("Post") }}</label>
<editor v-model="post.body" />
</div>
<subtitle>{{ $t("Who can view this post") }}</subtitle>
<div class="field">
<b-radio
v-model="post.visibility"
name="postVisibility"
:native-value="PostVisibility.PUBLIC"
>{{ $t("Visible everywhere on the web") }}</b-radio
>
</div>
<div class="field">
<b-radio
v-model="post.visibility"
name="postVisibility"
:native-value="PostVisibility.UNLISTED"
>{{ $t("Only accessible through link") }}</b-radio
>
</div>
<div class="field">
<b-radio
v-model="post.visibility"
name="postVisibility"
:native-value="PostVisibility.PRIVATE"
>{{ $t("Only accessible to members of the group") }}</b-radio
>
</div>
</div>
<nav class="navbar">
<div class="container">
<div class="navbar-menu">
<div class="navbar-end">
<span class="navbar-item">
<b-button type="is-text" @click="$router.go(-1)">{{ $t("Cancel") }}</b-button>
</span>
<span class="navbar-item">
<b-button type="is-danger is-outlined" @click="deletePost">{{
$t("Delete post")
}}</b-button>
</span>
<!-- If an post has been published we can't make it draft anymore -->
<span class="navbar-item" v-if="post.draft === true">
<b-button type="is-primary" outlined @click="publish(true)">{{
$t("Save draft")
}}</b-button>
</span>
<span class="navbar-item">
<b-button type="is-primary" native-type="submit">
<span v-if="isUpdate === false || post.draft === true">{{ $t("Publish") }}</span>
<span v-else>{{ $t("Update post") }}</span>
</b-button>
</span>
</div>
</div>
</div>
</nav>
</form>
</template>
<script lang="ts">
import { Component, Vue, Prop } from "vue-property-decorator";
import { CURRENT_ACTOR_CLIENT, FETCH_GROUP } from "../../graphql/actor";
import { TAGS } from "../../graphql/tags";
import { CONFIG } from "../../graphql/config";
import { FETCH_POST, CREATE_POST, UPDATE_POST, DELETE_POST } from "../../graphql/post";
import { IPost, PostVisibility } from "../../types/post.model";
import Editor from "../../components/Editor.vue";
import { IGroup } from "../../types/actor";
import TagInput from "../../components/Event/TagInput.vue";
import RouteName from "../../router/name";
@Component({
apollo: {
currentActor: CURRENT_ACTOR_CLIENT,
tags: TAGS,
config: CONFIG,
post: {
query: FETCH_POST,
variables() {
return {
slug: this.slug,
};
},
skip() {
return !this.slug;
},
},
group: {
query: FETCH_GROUP,
variables() {
return {
name: this.preferredUsername,
};
},
skip() {
return !this.preferredUsername;
},
},
},
components: {
Editor,
TagInput,
},
metaInfo() {
return {
// eslint-disable-next-line @typescript-eslint/ban-ts-ignore
// @ts-ignore
title: this.isUpdate
? (this.$t("Edit post") as string)
: (this.$t("Add a new post") as string),
// all titles will be injected into this template
titleTemplate: "%s | Mobilizon",
};
},
})
export default class EditPost extends Vue {
@Prop({ required: false, type: String }) slug: undefined | string;
@Prop({ required: false, type: String }) preferredUsername!: string;
@Prop({ type: Boolean, default: false }) isUpdate!: boolean;
post: IPost = {
title: "",
body: "",
local: true,
draft: true,
visibility: PostVisibility.PUBLIC,
tags: [],
};
group!: IGroup;
PostVisibility = PostVisibility;
async publish(draft: boolean) {
if (this.isUpdate) {
const { data } = await this.$apollo.mutate({
mutation: UPDATE_POST,
variables: {
id: this.post.id,
title: this.post.title,
body: this.post.body,
tags: (this.post.tags || []).map(({ title }) => title),
visibility: this.post.visibility,
draft,
},
});
if (data && data.updatePost) {
return this.$router.push({ name: RouteName.POST, params: { slug: data.updatePost.slug } });
}
} else {
const { data } = await this.$apollo.mutate({
mutation: CREATE_POST,
variables: {
...this.post,
tags: (this.post.tags || []).map(({ title }) => title),
attributedToId: this.group.id,
draft,
},
});
if (data && data.createPost) {
return this.$router.push({ name: RouteName.POST, params: { slug: data.createPost.slug } });
}
}
}
async deletePost() {
const { data } = await this.$apollo.mutate({
mutation: DELETE_POST,
variables: {
id: this.post.id,
},
});
if (data && this.post.attributedTo) {
return this.$router.push({
name: RouteName.POSTS,
params: { preferredUsername: this.post.attributedTo.preferredUsername },
});
}
}
}
</script>
<style lang="scss" scoped>
form {
nav.navbar {
position: sticky;
bottom: 0;
min-height: 2rem;
.container {
min-height: 2rem;
}
}
}
</style>

View File

@ -0,0 +1,86 @@
<template>
<div>
<section class="section container">
<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>
<router-link
v-if="group"
:to="{
name: RouteName.GROUP,
params: { preferredUsername: usernameWithDomain(group) },
}"
>{{ group.name || group.preferredUsername }}</router-link
>
<b-skeleton v-else :animated="true"></b-skeleton>
</li>
<li class="is-active">
<router-link
v-if="group"
:to="{
name: RouteName.POSTS,
params: { preferredUsername: usernameWithDomain(group) },
}"
>{{ $t("Posts") }}</router-link
>
<b-skeleton v-else :animated="true"></b-skeleton>
</li>
</ul>
</nav>
<div v-if="group">
<router-link
v-for="post in group.posts.elements"
:key="post.id"
:to="{ name: RouteName.POST, params: { slug: post.slug } }"
>
{{ post.title }}
</router-link>
</div>
<b-skeleton v-else :animated="true"></b-skeleton>
</section>
<pre>{{ group }}</pre>
</div>
</template>
<script lang="ts">
import { Component, Vue, Prop } from "vue-property-decorator";
import { FETCH_GROUP_POSTS } from "../../graphql/post";
import { Paginate } from "../../types/paginate";
import { IPost } from "../../types/post.model";
import { IGroup, usernameWithDomain } from "../../types/actor";
import RouteName from "../../router/name";
@Component({
apollo: {
group: {
query: FETCH_GROUP_POSTS,
variables() {
return {
preferredUsername: this.preferredUsername,
};
},
// update(data) {
// console.log(data);
// return data.group.posts;
// },
skip() {
return !this.preferredUsername;
},
},
},
})
export default class PostList extends Vue {
@Prop({ required: true, type: String }) preferredUsername!: string;
group!: IGroup;
posts!: Paginate<IPost>;
RouteName = RouteName;
usernameWithDomain = usernameWithDomain;
}
</script>

184
js/src/views/Posts/Post.vue Normal file
View File

@ -0,0 +1,184 @@
<template>
<div>
<article class="container" v-if="post">
<section class="heading-section">
<h1 class="title">{{ post.title }}</h1>
<i18n tag="span" path="By {author}" class="authors">
<router-link
slot="author"
:to="{
name: RouteName.GROUP,
params: { preferredUsername: usernameWithDomain(post.attributedTo) },
}"
>{{ post.attributedTo.name }}</router-link
>
</i18n>
<p class="published" v-if="!post.draft">{{ post.publishAt | formatDateTimeString }}</p>
<p class="buttons" v-if="isCurrentActorMember">
<b-tag type="is-warning" size="is-medium" v-if="post.draft">{{ $t("Draft") }}</b-tag>
<router-link
:to="{ name: RouteName.POST_EDIT, params: { slug: post.slug } }"
tag="button"
class="button is-text"
>{{ $t("Edit") }}</router-link
>
</p>
</section>
<section v-html="post.body" class="content" />
<section class="tags">
<router-link
v-for="tag in post.tags"
:key="tag.title"
:to="{ name: RouteName.TAG, params: { tag: tag.title } }"
>
<tag>{{ tag.title }}</tag>
</router-link>
</section>
</article>
</div>
</template>
<script lang="ts">
import { Component, Vue, Prop } from "vue-property-decorator";
import Editor from "@/components/Editor.vue";
import { GraphQLError } from "graphql";
import { CURRENT_ACTOR_CLIENT, PERSON_MEMBERSHIPS, FETCH_GROUP } from "../../graphql/actor";
import { TAGS } from "../../graphql/tags";
import { CONFIG } from "../../graphql/config";
import { FETCH_POST, CREATE_POST } from "../../graphql/post";
import { IPost, PostVisibility } from "../../types/post.model";
import { IGroup, IMember, usernameWithDomain } from "../../types/actor";
import RouteName from "../../router/name";
import Tag from "../../components/Tag.vue";
@Component({
apollo: {
currentActor: CURRENT_ACTOR_CLIENT,
memberships: {
query: PERSON_MEMBERSHIPS,
variables() {
return {
id: this.currentActor.id,
};
},
update: (data) => data.person.memberships.elements,
skip() {
return !this.currentActor || !this.currentActor.id;
},
},
post: {
query: FETCH_POST,
variables() {
return {
slug: this.slug,
};
},
skip() {
return !this.slug;
},
error({ graphQLErrors }) {
this.handleErrors(graphQLErrors);
},
},
},
components: {
Tag,
},
metaInfo() {
return {
// eslint-disable-next-line @typescript-eslint/ban-ts-ignore
// @ts-ignore
title: this.post ? this.post.title : "",
// all titles will be injected into this template
// eslint-disable-next-line @typescript-eslint/ban-ts-ignore
// @ts-ignore
titleTemplate: this.post ? "%s | Mobilizon" : "Mobilizon",
};
},
})
export default class Post extends Vue {
@Prop({ required: true, type: String }) slug!: string;
post!: IPost;
memberships!: IMember[];
RouteName = RouteName;
usernameWithDomain = usernameWithDomain;
get isCurrentActorMember(): boolean {
if (!this.post.attributedTo || !this.memberships) return false;
return this.memberships.map(({ parent: { id } }) => id).includes(this.post.attributedTo.id);
}
async handleErrors(errors: GraphQLError[]) {
if (errors[0].message.includes("No such post")) {
await this.$router.push({ name: RouteName.PAGE_NOT_FOUND });
}
}
}
</script>
<style lang="scss" scoped>
@import "../../variables.scss";
article {
section.heading-section {
text-align: center;
h1.title {
margin: 0 auto;
padding-top: 3rem;
font-size: 3rem;
font-weight: 700;
}
.authors {
margin-top: 2rem;
display: inline-block;
}
.published {
margin-top: 1rem;
color: rgba(0, 0, 0, 0.5);
}
&::after {
height: 0.4rem;
margin-bottom: 2rem;
content: " ";
display: block;
width: 100%;
background-color: $purple-1;
margin-top: 1rem;
}
.buttons {
justify-content: center;
}
}
section.content {
font-size: 1.1rem;
}
section.tags {
padding-bottom: 5rem;
a {
text-decoration: none;
}
span {
&.tag {
margin: 0 2px;
}
}
}
background: $white;
max-width: 700px;
margin: 0 auto;
padding: 0 3rem;
}
</style>

View File

@ -118,7 +118,7 @@
</div> </div>
</transition-group> </transition-group>
</draggable> </draggable>
<div class="content has-text-centered has-text-grey"> <div class="content has-text-centered has-text-grey" v-if="resource.children.total === 0">
<p>{{ $t("No resources in this folder") }}</p> <p>{{ $t("No resources in this folder") }}</p>
</div> </div>
</section> </section>
@ -470,12 +470,12 @@ export default class Resources extends Mixins(ResourceMixin) {
handleRename(resource: IResource) { handleRename(resource: IResource) {
this.renameModal = true; this.renameModal = true;
this.updatedResource = Object.assign({}, resource); this.updatedResource = { ...resource };
} }
handleMove(resource: IResource) { handleMove(resource: IResource) {
this.moveModal = true; this.moveModal = true;
this.updatedResource = Object.assign({}, resource); this.updatedResource = { ...resource };
} }
async moveResource(resource: IResource, oldParent: IResource | undefined) { async moveResource(resource: IResource, oldParent: IResource | undefined) {

File diff suppressed because it is too large Load Diff

View File

@ -13,41 +13,38 @@ defmodule Mobilizon.Federation.ActivityPub do
alias Mobilizon.{ alias Mobilizon.{
Actors, Actors,
Config, Config,
Conversations, Discussions,
Events, Events,
Reports,
Resources, Resources,
Share, Share,
Todos,
Users Users
} }
alias Mobilizon.Actors.{Actor, Follower, Member} alias Mobilizon.Actors.{Actor, Follower, Member}
alias Mobilizon.Conversations.Comment alias Mobilizon.Discussions.Comment
alias Mobilizon.Events.{Event, Participant} alias Mobilizon.Events.{Event, Participant}
alias Mobilizon.Reports.Report
alias Mobilizon.Resources.Resource
alias Mobilizon.Todos.{Todo, TodoList}
alias Mobilizon.Tombstone alias Mobilizon.Tombstone
alias Mobilizon.Federation.ActivityPub.{ alias Mobilizon.Federation.ActivityPub.{
Activity, Activity,
Audience, Audience,
Federator, Federator,
Fetcher,
Preloader,
Relay, Relay,
Transmogrifier, Transmogrifier,
Types,
Visibility Visibility
} }
alias Mobilizon.Federation.ActivityPub.Types.{Managable, Ownable}
alias Mobilizon.Federation.ActivityStream.{Converter, Convertible} alias Mobilizon.Federation.ActivityStream.{Converter, Convertible}
alias Mobilizon.Federation.ActivityStream.Converter.Utils, as: ConverterUtils
alias Mobilizon.Federation.HTTPSignatures.Signature alias Mobilizon.Federation.HTTPSignatures.Signature
alias Mobilizon.Federation.WebFinger alias Mobilizon.Federation.WebFinger
alias Mobilizon.GraphQL.API.Utils, as: APIUtils
alias Mobilizon.Service.Formatter.HTML
alias Mobilizon.Service.Notifications.Scheduler alias Mobilizon.Service.Notifications.Scheduler
alias Mobilizon.Service.RichMedia.Parser alias Mobilizon.Storage.Page
alias Mobilizon.Web.Endpoint alias Mobilizon.Web.Endpoint
alias Mobilizon.Web.Email.{Admin, Mailer} alias Mobilizon.Web.Email.{Admin, Mailer}
@ -74,75 +71,44 @@ defmodule Mobilizon.Federation.ActivityPub do
Fetch an object from an URL, from our local database of events and comments, then eventually remote Fetch an object from an URL, from our local database of events and comments, then eventually remote
""" """
# TODO: Make database calls parallel # TODO: Make database calls parallel
@spec fetch_object_from_url(String.t()) :: {:ok, %Event{}} | {:ok, %Comment{}} | {:error, any()} @spec fetch_object_from_url(String.t(), Keyword.t()) ::
def fetch_object_from_url(url) do {:ok, struct()} | {:error, any()}
def fetch_object_from_url(url, options \\ []) do
Logger.info("Fetching object from url #{url}") Logger.info("Fetching object from url #{url}")
force_fetch = Keyword.get(options, :force, false)
with {:not_http, true} <- {:not_http, String.starts_with?(url, "http")}, with {:not_http, true} <- {:not_http, String.starts_with?(url, "http")},
{:existing_event, nil} <- {:existing_event, Events.get_event_by_url(url)}, {:existing, nil} <-
{:existing_comment, nil} <- {:existing_comment, Conversations.get_comment_from_url(url)}, {:existing, Tombstone.find_tombstone(url)},
{:existing_resource, nil} <- {:existing_resource, Resources.get_resource_by_url(url)}, {:existing, nil} <- {:existing, Events.get_event_by_url(url)},
{:existing_actor, {:error, :actor_not_found}} <- {:existing, nil} <-
{:existing_actor, Actors.get_actor_by_url(url)}, {:existing, Discussions.get_discussion_by_url(url)},
date <- Signature.generate_date_header(), {:existing, nil} <- {:existing, Discussions.get_comment_from_url(url)},
headers <- {:existing, nil} <- {:existing, Resources.get_resource_by_url(url)},
[{:Accept, "application/activity+json"}] {:existing, nil} <-
|> maybe_date_fetch(date) {:existing, Actors.get_actor_by_url_2(url)},
|> sign_fetch_relay(url, date), :ok <- Logger.info("Data for URL not found anywhere, going to fetch it"),
{:ok, %HTTPoison.Response{body: body, status_code: code}} when code in 200..299 <- {:ok, _activity, entity} <- Fetcher.fetch_and_create(url, options) do
HTTPoison.get( Logger.debug("Going to preload the new entity")
url, Preloader.maybe_preload(entity)
headers,
follow_redirect: true,
timeout: 10_000,
recv_timeout: 20_000,
ssl: [{:versions, [:"tlsv1.2"]}]
),
{:ok, data} <- Jason.decode(body),
{:origin_check, true} <- {:origin_check, origin_check?(url, data)},
params <- %{
"type" => "Create",
"to" => data["to"],
"cc" => data["cc"],
"actor" => data["attributedTo"],
"object" => data
},
{:ok, _activity, %{url: object_url} = _object} <- Transmogrifier.handle_incoming(params) do
case data["type"] do
"Event" ->
{:ok, Events.get_public_event_by_url_with_preload!(object_url)}
"Note" ->
{:ok, Conversations.get_comment_from_url_with_preload!(object_url)}
"Document" ->
{:ok, Resources.get_resource_by_url_with_preloads(object_url)}
"ResourceCollection" ->
{:ok, Resources.get_resource_by_url_with_preloads(object_url)}
"Actor" ->
{:ok, Actors.get_actor_by_url!(object_url, true)}
other ->
{:error, other}
end
else else
{:existing_event, %Event{url: event_url}} -> {:existing, entity} ->
{:ok, Events.get_public_event_by_url_with_preload!(event_url)} Logger.debug("Entity is already existing")
{:existing_comment, %Comment{url: comment_url}} -> entity =
{:ok, Conversations.get_comment_from_url_with_preload!(comment_url)} if force_fetch and not compare_origins?(url, Endpoint.url()) do
Logger.debug("Entity is external and we want a force fetch")
{:existing_resource, %Resource{url: resource_url}} -> with {:ok, _activity, entity} <- Fetcher.fetch_and_update(url, options) do
{:ok, Resources.get_resource_by_url_with_preloads(resource_url)} entity
end
else
entity
end
{:existing_actor, {:ok, %Actor{url: actor_url}}} -> Logger.debug("Going to preload an existing entity")
{:ok, Actors.get_actor_by_url!(actor_url, true)}
{:origin_check, false} -> Preloader.maybe_preload(entity)
Logger.warn("Object origin check failed")
{:error, "Object origin check failed"}
e -> e ->
Logger.warn("Something failed while fetching url #{inspect(e)}") Logger.warn("Something failed while fetching url #{inspect(e)}")
@ -201,15 +167,18 @@ defmodule Mobilizon.Federation.ActivityPub do
with {:tombstone, nil} <- {:tombstone, check_for_tombstones(args)}, with {:tombstone, nil} <- {:tombstone, check_for_tombstones(args)},
{:ok, entity, create_data} <- {:ok, entity, create_data} <-
(case type do (case type do
:event -> create_event(args, additional) :event -> Types.Events.create(args, additional)
:comment -> create_comment(args, additional) :comment -> Types.Comments.create(args, additional)
:group -> create_group(args, additional) :discussion -> Types.Discussions.create(args, additional)
:todo_list -> create_todo_list(args, additional) :actor -> Types.Actors.create(args, additional)
:todo -> create_todo(args, additional) :todo_list -> Types.TodoLists.create(args, additional)
:resource -> create_resource(args, additional) :todo -> Types.Todos.create(args, additional)
:resource -> Types.Resources.create(args, additional)
:post -> Types.Posts.create(args, additional)
end), end),
{:ok, activity} <- create_activity(create_data, local), {:ok, activity} <- create_activity(create_data, local),
:ok <- maybe_federate(activity) do :ok <- maybe_federate(activity),
:ok <- maybe_relay_if_group_activity(activity) do
{:ok, activity, entity} {:ok, activity, entity}
else else
err -> err ->
@ -227,21 +196,15 @@ defmodule Mobilizon.Federation.ActivityPub do
* Federates (asynchronously) the activity * Federates (asynchronously) the activity
* Returns the activity * Returns the activity
""" """
@spec update(atom(), struct(), map(), boolean, map()) :: {:ok, Activity.t(), struct()} | any() @spec update(struct(), map(), boolean, map()) :: {:ok, Activity.t(), struct()} | any()
def update(type, old_entity, args, local \\ false, additional \\ %{}) do def update(old_entity, args, local \\ false, additional \\ %{}) do
Logger.debug("updating an activity") Logger.debug("updating an activity")
Logger.debug(inspect(args)) Logger.debug(inspect(args))
with {:ok, entity, update_data} <- with {:ok, entity, update_data} <- Managable.update(old_entity, args, additional),
(case type do
:event -> update_event(old_entity, args, additional)
: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, activity} <- create_activity(update_data, local),
:ok <- maybe_federate(activity) do :ok <- maybe_federate(activity),
:ok <- maybe_relay_if_group_activity(activity) do
{:ok, activity, entity} {:ok, activity, entity}
else else
err -> err ->
@ -366,182 +329,48 @@ defmodule Mobilizon.Federation.ActivityPub do
end end
end end
def delete(object, local \\ true) def delete(object, actor, local \\ true) do
with {:ok, activity_data, actor, object} <-
@spec delete(Event.t(), boolean) :: {:ok, Activity.t(), Event.t()} Managable.delete(object, actor, local),
def delete(%Event{url: url, organizer_actor: actor} = event, local) do group <- Ownable.group_actor(object),
data = %{
"type" => "Delete",
"actor" => actor.url,
"object" => url,
"to" => [actor.url <> "/followers", "https://www.w3.org/ns/activitystreams#Public"],
"id" => url <> "/delete"
}
with audience <-
Audience.calculate_to_and_cc_from_mentions(event),
{:ok, %Event{} = event} <- Events.delete_event(event),
{:ok, true} <- Cachex.del(:activity_pub, "event_#{event.uuid}"),
{:ok, %Tombstone{} = _tombstone} <-
Tombstone.create_tombstone(%{uri: event.url, actor_id: actor.id}),
Share.delete_all_by_uri(event.url),
:ok <- check_for_actor_key_rotation(actor), :ok <- check_for_actor_key_rotation(actor),
{:ok, activity} <- create_activity(Map.merge(data, audience), local), {:ok, activity} <- create_activity(activity_data, local),
:ok <- maybe_federate(activity) do :ok <- maybe_federate(activity),
{:ok, activity, event} :ok <- maybe_relay_if_group_activity(activity, group) do
{:ok, activity, object}
end end
end end
@spec delete(Comment.t(), boolean) :: {:ok, Activity.t(), Comment.t()} def join(%Event{} = event, %Actor{} = actor, local \\ true, additional \\ %{}) do
def delete(%Comment{url: url, actor: actor} = comment, local) do with {:ok, activity_data, participant} <- Types.Events.join(event, actor, local, additional),
data = %{ {:ok, activity} <- create_activity(activity_data, local),
"type" => "Delete",
"actor" => actor.url,
"object" => url,
"id" => url <> "/delete",
"to" => [actor.url <> "/followers", "https://www.w3.org/ns/activitystreams#Public"]
}
with audience <-
Audience.calculate_to_and_cc_from_mentions(comment),
{:ok, %Comment{} = comment} <- Conversations.delete_comment(comment),
{:ok, true} <- Cachex.del(:activity_pub, "comment_#{comment.uuid}"),
{:ok, %Tombstone{} = _tombstone} <-
Tombstone.create_tombstone(%{uri: comment.url, actor_id: actor.id}),
Share.delete_all_by_uri(comment.url),
:ok <- check_for_actor_key_rotation(actor),
{:ok, activity} <- create_activity(Map.merge(data, audience), local),
:ok <- maybe_federate(activity) do :ok <- maybe_federate(activity) do
{:ok, activity, comment} {:ok, activity, participant}
end
end
def delete(%Actor{url: url} = actor, local) do
data = %{
"type" => "Delete",
"actor" => url,
"object" => url,
"id" => url <> "/delete",
"to" => [url <> "/followers", "https://www.w3.org/ns/activitystreams#Public"]
}
# We completely delete the actor if activity is remote
with {:ok, %Oban.Job{}} <- Actors.delete_actor(actor, reserve_username: local),
{:ok, activity} <- create_activity(data, local),
:ok <- maybe_federate(activity) do
{:ok, activity, actor}
end
end
def delete(
%Resource{url: url, actor: %Actor{url: actor_url}} = resource,
local
) do
Logger.debug("Building Delete Resource activity")
data = %{
"actor" => actor_url,
"type" => "Delete",
"object" => url,
"id" => url <> "/delete",
"to" => [actor_url]
}
Logger.debug(inspect(data))
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}} <-
{:create_report, Reports.create_report(args)},
report_as_data <- Convertible.model_to_as(report),
cc <- if(local, do: [report.reported.url], else: []),
report_as_data <- Map.merge(report_as_data, %{"to" => [], "cc" => cc}),
{:ok, activity} <- create_activity(report_as_data, local),
:ok <- maybe_federate(activity) do
Enum.each(Users.list_moderators(), fn moderator ->
moderator
|> Admin.report(report)
|> Mailer.deliver_later()
end)
{:ok, activity, report}
else else
err -> {:maximum_attendee_capacity, err} ->
Logger.error("Something went wrong while creating an activity") {:maximum_attendee_capacity, err}
Logger.debug(inspect(err))
err {:accept, accept} ->
accept
end end
end end
def join(object, actor, local \\ true, additional \\ %{}) def join_group(
%{parent_id: parent_id, actor_id: actor_id, role: role},
def join(%Event{} = event, %Actor{} = actor, local, additional) do local \\ true,
# TODO Refactor me for federation additional \\ %{}
with {:maximum_attendee_capacity, true} <- ) do
{:maximum_attendee_capacity, check_attendee_capacity(event)}, with {:ok, %Member{} = member} <-
role <- Mobilizon.Actors.create_member(%{
additional parent_id: parent_id,
|> Map.get(:metadata, %{}) actor_id: actor_id,
|> Map.get(:role, Mobilizon.Events.get_default_participant_role(event)), role: role
{:ok, %Participant{} = participant} <-
Mobilizon.Events.create_participant(%{
role: role,
event_id: event.id,
actor_id: actor.id,
url: Map.get(additional, :url),
metadata:
additional
|> Map.get(:metadata, %{})
|> Map.update(:message, nil, &String.trim(HTML.strip_tags(&1)))
}), }),
join_data <- Convertible.model_to_as(participant), activity_data when is_map(activity_data) <-
audience <- Convertible.model_to_as(member),
Audience.calculate_to_and_cc_from_mentions(participant), {:ok, activity} <- create_activity(Map.merge(activity_data, additional), local),
{:ok, activity} <- create_activity(Map.merge(join_data, audience), local),
:ok <- maybe_federate(activity) do :ok <- maybe_federate(activity) do
if event.local do {:ok, activity, member}
cond do
Mobilizon.Events.get_default_participant_role(event) === :participant &&
role == :participant ->
accept(
:join,
participant,
true,
%{"actor" => event.organizer_actor.url}
)
Mobilizon.Events.get_default_participant_role(event) === :not_approved &&
role == :not_approved ->
Scheduler.pending_participation_notification(event)
{:ok, activity, participant}
true ->
{:ok, activity, participant}
end
else
{:ok, activity, participant}
end
end
end
# TODO: Implement me
def join(%Actor{type: :Group} = _group, %Actor{} = _actor, _local, _additional) do
:error
end
defp check_attendee_capacity(%Event{options: options} = event) do
with maximum_attendee_capacity <-
Map.get(options, :maximum_attendee_capacity) || 0 do
maximum_attendee_capacity == 0 ||
Mobilizon.Events.count_participant_participants(event.id) < maximum_attendee_capacity
end end
end end
@ -640,7 +469,7 @@ defmodule Mobilizon.Federation.ActivityPub do
with {:ok, entity, update_data} <- with {:ok, entity, update_data} <-
(case type do (case type do
:resource -> move_resource(old_entity, args, additional) :resource -> Types.Resources.move(old_entity, args, additional)
end), end),
{:ok, activity} <- create_activity(update_data, local), {:ok, activity} <- create_activity(update_data, local),
:ok <- maybe_federate(activity) do :ok <- maybe_federate(activity) do
@ -653,6 +482,25 @@ defmodule Mobilizon.Federation.ActivityPub do
end end
end end
def flag(args, local \\ false, additional \\ %{}) do
with {report, report_as_data} <- Types.Reports.flag(args, local, additional),
{:ok, activity} <- create_activity(report_as_data, local),
:ok <- maybe_federate(activity) do
Enum.each(Users.list_moderators(), fn moderator ->
moderator
|> Admin.report(report)
|> Mailer.deliver_later()
end)
{:ok, activity, report}
else
err ->
Logger.error("Something went wrong while creating an activity")
Logger.debug(inspect(err))
err
end
end
@doc """ @doc """
Create an actor locally by its URL (AP ID) Create an actor locally by its URL (AP ID)
""" """
@ -711,9 +559,29 @@ defmodule Mobilizon.Federation.ActivityPub do
defp is_create_activity?(%Activity{data: %{"type" => "Create"}}), do: true defp is_create_activity?(%Activity{data: %{"type" => "Create"}}), do: true
defp is_create_activity?(_), do: false defp is_create_activity?(_), do: false
@spec is_announce_activity?(Activity.t()) :: boolean @spec convert_members_in_recipients(list(String.t())) :: {list(String.t()), list(Actor.t())}
defp is_announce_activity?(%Activity{data: %{"type" => "Announce"}}), do: true defp convert_members_in_recipients(recipients) do
defp is_announce_activity?(_), do: false Enum.reduce(recipients, {recipients, []}, fn recipient, {recipients, member_actors} = acc ->
case Actors.get_group_by_members_url(recipient) do
# If the group is local just add external members
%Actor{domain: domain} = group when is_nil(domain) ->
{Enum.filter(recipients, fn recipient -> recipient != group.members_url end),
member_actors ++ Actors.list_external_actors_members_for_group(group)}
# If it's remote add the remote group actor as well
%Actor{} = group ->
{Enum.filter(recipients, fn recipient -> recipient != group.members_url end),
member_actors ++ Actors.list_external_actors_members_for_group(group) ++ [group]}
_ ->
acc
end
end)
end
# @spec is_announce_activity?(Activity.t()) :: boolean
# defp is_announce_activity?(%Activity{data: %{"type" => "Announce"}}), do: true
# defp is_announce_activity?(_), do: false
@doc """ @doc """
Publish an activity to all appropriated audiences inboxes Publish an activity to all appropriated audiences inboxes
@ -741,19 +609,11 @@ defmodule Mobilizon.Federation.ActivityPub do
{recipients, []} {recipients, []}
end end
# If we want to send to all members of the group, because this server is the one the group is on {recipients, members} = convert_members_in_recipients(recipients)
{recipients, members} =
if is_announce_activity?(activity) and actor.type == :Group and
actor.members_url in activity.recipients and is_nil(actor.domain) do
{Enum.filter(recipients, fn recipient -> recipient != actor.members_url end),
Actors.list_external_members_for_group(actor)}
else
{recipients, []}
end
remote_inboxes = remote_inboxes =
(remote_actors(recipients) ++ followers ++ members) (remote_actors(recipients) ++ followers ++ members)
|> Enum.map(fn follower -> follower.shared_inbox_url || follower.inbox_url end) |> Enum.map(fn actor -> actor.shared_inbox_url || actor.inbox_url end)
|> Enum.uniq() |> Enum.uniq()
{:ok, data} = Transmogrifier.prepare_outgoing(activity.data) {:ok, data} = Transmogrifier.prepare_outgoing(activity.data)
@ -791,16 +651,15 @@ defmodule Mobilizon.Federation.ActivityPub do
date: date date: date
}) })
HTTPoison.post( Tesla.post(
inbox, inbox,
json, json,
[ headers: [
{"Content-Type", "application/activity+json"}, {"Content-Type", "application/activity+json"},
{"signature", signature}, {"signature", signature},
{"digest", digest}, {"digest", digest},
{"date", date} {"date", date}
], ]
hackney: [pool: :default]
) )
end end
@ -811,18 +670,15 @@ defmodule Mobilizon.Federation.ActivityPub do
Logger.debug(inspect(url)) Logger.debug(inspect(url))
res = res =
with {:ok, %HTTPoison.Response{status_code: 200, body: body}} <- with {:ok, %{status: 200, body: body}} <-
HTTPoison.get(url, [Accept: "application/activity+json"], Tesla.get(url, headers: [{"Accept", "application/activity+json"}]),
follow_redirect: true,
ssl: [{:versions, [:"tlsv1.2"]}]
),
:ok <- Logger.debug("response okay, now decoding json"), :ok <- Logger.debug("response okay, now decoding json"),
{:ok, data} <- Jason.decode(body) do {:ok, data} <- Jason.decode(body) do
Logger.debug("Got activity+json response at actor's endpoint, now converting data") Logger.debug("Got activity+json response at actor's endpoint, now converting data")
{:ok, Converter.Actor.as_to_model_data(data)} {:ok, Converter.Actor.as_to_model_data(data)}
else else
# Actor is gone, probably deleted # Actor is gone, probably deleted
{:ok, %HTTPoison.Response{status_code: 410}} -> {:ok, %{status: 410}} ->
Logger.info("Response HTTP 410") Logger.info("Response HTTP 410")
{:error, :actor_deleted} {:error, :actor_deleted}
@ -839,10 +695,11 @@ defmodule Mobilizon.Federation.ActivityPub do
""" """
@spec fetch_public_activities_for_actor(Actor.t(), integer(), integer()) :: map() @spec fetch_public_activities_for_actor(Actor.t(), integer(), integer()) :: map()
def fetch_public_activities_for_actor(%Actor{} = actor, page \\ 1, limit \\ 10) do def fetch_public_activities_for_actor(%Actor{} = actor, page \\ 1, limit \\ 10) do
{:ok, events, total_events} = Events.list_public_events_for_actor(actor, page, limit) %Page{total: total_events, elements: events} =
Events.list_public_events_for_actor(actor, page, limit)
{:ok, comments, total_comments} = %Page{total: total_comments, elements: comments} =
Conversations.list_public_comments_for_actor(actor, page, limit) Discussions.list_public_comments_for_actor(actor, page, limit)
event_activities = Enum.map(events, &event_to_activity/1) event_activities = Enum.map(events, &event_to_activity/1)
comment_activities = Enum.map(comments, &comment_to_activity/1) comment_activities = Enum.map(comments, &comment_to_activity/1)
@ -879,252 +736,10 @@ defmodule Mobilizon.Federation.ActivityPub do
Map.get(data, "to", []) ++ Map.get(data, "cc", []) Map.get(data, "to", []) ++ Map.get(data, "cc", [])
end end
@spec create_event(map(), map()) :: {:ok, map()}
defp create_event(args, additional) do
with args <- prepare_args_for_event(args),
{:ok, %Event{} = event} <- Events.create_event(args),
event_as_data <- Convertible.model_to_as(event),
audience <-
Audience.calculate_to_and_cc_from_mentions(event),
create_data <-
make_create_data(event_as_data, Map.merge(audience, additional)) do
{:ok, event, create_data}
end
end
@spec create_comment(map(), map()) :: {:ok, map()}
defp create_comment(args, additional) do
with args <- prepare_args_for_comment(args),
{:ok, %Comment{} = comment} <- Conversations.create_comment(args),
comment_as_data <- Convertible.model_to_as(comment),
audience <-
Audience.calculate_to_and_cc_from_mentions(comment),
create_data <-
make_create_data(comment_as_data, Map.merge(audience, additional)) do
{:ok, comment, create_data}
end
end
@spec create_group(map(), map()) :: {:ok, map()}
defp create_group(args, additional) do
with args <- prepare_args_for_group(args),
{:ok, %Actor{type: :Group} = group} <- Actors.create_group(args),
group_as_data <- Convertible.model_to_as(group),
audience <- %{"to" => ["https://www.w3.org/ns/activitystreams#Public"], "cc" => []},
create_data <-
make_create_data(group_as_data, Map.merge(audience, additional)) do
{:ok, group, create_data}
end
end
@spec create_todo_list(map(), map()) :: {:ok, map()}
defp create_todo_list(args, additional) do
with {:ok, %TodoList{actor_id: group_id} = todo_list} <- Todos.create_todo_list(args),
{:ok, %Actor{} = group} <- Actors.get_group_by_actor_id(group_id),
todo_list_as_data <- Convertible.model_to_as(%{todo_list | actor: group}),
audience <- %{"to" => [group.url], "cc" => []},
create_data <-
make_create_data(todo_list_as_data, Map.merge(audience, additional)) do
{:ok, todo_list, create_data}
end
end
@spec create_todo(map(), map()) :: {:ok, map()}
defp create_todo(args, additional) do
with {:ok, %Todo{todo_list_id: todo_list_id, creator_id: creator_id} = todo} <-
Todos.create_todo(args),
%TodoList{actor_id: group_id} = todo_list <- Todos.get_todo_list(todo_list_id),
%Actor{} = creator <- Actors.get_actor(creator_id),
{:ok, %Actor{} = group} <- Actors.get_group_by_actor_id(group_id),
todo <- %{todo | todo_list: %{todo_list | actor: group}, creator: creator},
todo_as_data <-
Convertible.model_to_as(todo),
audience <- %{"to" => [group.url], "cc" => []},
create_data <-
make_create_data(todo_as_data, Map.merge(audience, additional)) do
{:ok, todo, create_data}
end
end
defp create_resource(%{type: type} = args, additional) do
args =
case type do
:folder ->
args
_ ->
case Parser.parse(Map.get(args, :resource_url)) do
{:ok, metadata} ->
Map.put(args, :metadata, metadata)
_ ->
args
end
end
with {:ok,
%Resource{actor_id: group_id, creator_id: creator_id, parent_id: parent_id} = resource} <-
Resources.create_resource(args),
{:ok, %Actor{} = group} <- Actors.get_group_by_actor_id(group_id),
%Actor{url: creator_url} = creator <- Actors.get_actor(creator_id),
resource_as_data <-
Convertible.model_to_as(%{resource | actor: group, creator: creator}),
audience <- %{
"to" => [group.url],
"cc" => [],
"actor" => creator_url,
"attributedTo" => [creator_url]
} do
create_data =
case parent_id do
nil ->
make_create_data(resource_as_data, Map.merge(audience, additional))
parent_id ->
# In case the resource has a parent we don't `Create` the resource but `Add` it to an existing resource
parent = Resources.get_resource(parent_id)
make_add_data(resource_as_data, parent, Map.merge(audience, additional))
end
{:ok, resource, create_data}
else
err ->
Logger.error(inspect(err))
err
end
end
@spec check_for_tombstones(map()) :: Tombstone.t() | nil @spec check_for_tombstones(map()) :: Tombstone.t() | nil
defp check_for_tombstones(%{url: url}), do: Tombstone.find_tombstone(url) defp check_for_tombstones(%{url: url}), do: Tombstone.find_tombstone(url)
defp check_for_tombstones(_), do: nil defp check_for_tombstones(_), do: nil
@spec update_event(Event.t(), map(), map()) :: {:ok, Event.t(), Activity.t()} | any()
defp update_event(%Event{} = old_event, args, additional) do
with args <- prepare_args_for_event(args),
{:ok, %Event{} = new_event} <- Events.update_event(old_event, args),
{:ok, true} <- Cachex.del(:activity_pub, "event_#{new_event.uuid}"),
event_as_data <- Convertible.model_to_as(new_event),
audience <-
Audience.calculate_to_and_cc_from_mentions(new_event),
update_data <- make_update_data(event_as_data, Map.merge(audience, additional)) do
{:ok, new_event, update_data}
else
err ->
Logger.error("Something went wrong while creating an update activity")
Logger.debug(inspect(err))
err
end
end
@spec update_comment(Comment.t(), map(), map()) :: {:ok, Comment.t(), Activity.t()} | any()
defp update_comment(%Comment{} = old_comment, args, additional) do
with args <- prepare_args_for_comment(args),
{:ok, %Comment{} = new_comment} <- Conversations.update_comment(old_comment, args),
{:ok, true} <- Cachex.del(:activity_pub, "comment_#{new_comment.uuid}"),
comment_as_data <- Convertible.model_to_as(new_comment),
audience <-
Audience.calculate_to_and_cc_from_mentions(new_comment),
update_data <- make_update_data(comment_as_data, Map.merge(audience, additional)) do
{:ok, new_comment, update_data}
else
err ->
Logger.error("Something went wrong while creating an update activity")
Logger.debug(inspect(err))
err
end
end
@spec update_actor(Actor.t(), map, map) :: {:ok, Actor.t(), Activity.t()} | any
defp update_actor(%Actor{} = old_actor, args, additional) do
with {:ok, %Actor{} = new_actor} <- Actors.update_actor(old_actor, args),
actor_as_data <- Convertible.model_to_as(new_actor),
{:ok, true} <- Cachex.del(:activity_pub, "actor_#{new_actor.preferred_username}"),
audience <-
Audience.calculate_to_and_cc_from_mentions(new_actor),
additional <- Map.merge(additional, %{"actor" => old_actor.url}),
update_data <- make_update_data(actor_as_data, Map.merge(audience, additional)) do
{:ok, new_actor, update_data}
end
end
@spec update_todo(Todo.t(), map, map) :: {:ok, Todo.t(), Activity.t()} | any
defp update_todo(%Todo{} = old_todo, args, additional) do
with {:ok, %Todo{todo_list_id: todo_list_id} = todo} <- Todos.update_todo(old_todo, args),
%TodoList{actor_id: group_id} = todo_list <- Todos.get_todo_list(todo_list_id),
{:ok, %Actor{} = group} <- Actors.get_group_by_actor_id(group_id),
todo_as_data <-
Convertible.model_to_as(%{todo | todo_list: %{todo_list | actor: group}}),
audience <- %{"to" => [group.url], "cc" => []},
update_data <-
make_update_data(todo_as_data, Map.merge(audience, additional)) do
{:ok, todo, update_data}
end
end
defp update_resource(%Resource{} = old_resource, %{parent_id: _parent_id} = args, additional) do
move_resource(old_resource, args, additional)
end
# Simple rename
defp update_resource(%Resource{} = old_resource, %{title: title} = _args, additional) do
with {:ok, %Resource{actor_id: group_id, creator_id: creator_id} = resource} <-
Resources.update_resource(old_resource, %{title: title}),
{:ok, %Actor{} = group} <- Actors.get_group_by_actor_id(group_id),
%Actor{url: creator_url} <- Actors.get_actor(creator_id),
resource_as_data <-
Convertible.model_to_as(%{resource | actor: group}),
audience <- %{
"to" => [group.url],
"cc" => [],
"actor" => creator_url,
"attributedTo" => [creator_url]
},
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
defp move_resource(
%Resource{parent_id: old_parent_id} = old_resource,
%{parent_id: _new_parent_id} = args,
additional
) do
with {:ok,
%Resource{actor_id: group_id, creator_id: creator_id, parent_id: new_parent_id} =
resource} <-
Resources.update_resource(old_resource, args),
old_parent <- Resources.get_resource(old_parent_id),
new_parent <- Resources.get_resource(new_parent_id),
{:ok, %Actor{} = group} <- Actors.get_group_by_actor_id(group_id),
%Actor{url: creator_url} <- Actors.get_actor(creator_id),
resource_as_data <-
Convertible.model_to_as(%{resource | actor: group}),
audience <- %{
"to" => [group.url],
"cc" => [],
"actor" => creator_url,
"attributedTo" => [creator_url]
},
move_data <-
make_move_data(
resource_as_data,
old_parent,
new_parent,
Map.merge(audience, additional)
) do
{:ok, resource, move_data}
else
err ->
Logger.error(inspect(err))
err
end
end
@spec accept_follow(Follower.t(), map) :: {:ok, Follower.t(), Activity.t()} | any @spec accept_follow(Follower.t(), map) :: {:ok, Follower.t(), Activity.t()} | any
defp accept_follow(%Follower{} = follower, additional) do defp accept_follow(%Follower{} = follower, additional) do
with {:ok, %Follower{} = follower} <- Actors.update_follower(follower, %{approved: true}), with {:ok, %Follower{} = follower} <- Actors.update_follower(follower, %{approved: true}),
@ -1254,138 +869,4 @@ defmodule Mobilizon.Federation.ActivityPub do
err err
end end
end end
# Prepare and sanitize arguments for events
defp prepare_args_for_event(args) do
# If title is not set: we are not updating it
args =
if Map.has_key?(args, :title) && !is_nil(args.title),
do: Map.update(args, :title, "", &String.trim/1),
else: args
# If we've been given a description (we might not get one if updating)
# sanitize it, HTML it, and extract tags & mentions from it
args =
if Map.has_key?(args, :description) && !is_nil(args.description) do
{description, mentions, tags} =
APIUtils.make_content_html(
String.trim(args.description),
Map.get(args, :tags, []),
"text/html"
)
mentions = ConverterUtils.fetch_mentions(Map.get(args, :mentions, []) ++ mentions)
Map.merge(args, %{
description: description,
mentions: mentions,
tags: tags
})
else
args
end
# Check that we can only allow anonymous participation if our instance allows it
{_, options} =
Map.get_and_update(
Map.get(args, :options, %{anonymous_participation: false}),
:anonymous_participation,
fn value ->
{value, value && Mobilizon.Config.anonymous_participation?()}
end
)
args = Map.put(args, :options, options)
Map.update(args, :tags, [], &ConverterUtils.fetch_tags/1)
end
# Prepare and sanitize arguments for comments
defp prepare_args_for_comment(args) do
with in_reply_to_comment <-
args |> Map.get(:in_reply_to_comment_id) |> Conversations.get_comment_with_preload(),
event <- args |> Map.get(:event_id) |> handle_event_for_comment(),
args <- Map.update(args, :visibility, :public, & &1),
{text, mentions, tags} <-
APIUtils.make_content_html(
args |> Map.get(:text, "") |> String.trim(),
# Can't put additional tags on a comment
[],
"text/html"
),
tags <- ConverterUtils.fetch_tags(tags),
mentions <- Map.get(args, :mentions, []) ++ ConverterUtils.fetch_mentions(mentions),
args <-
Map.merge(args, %{
actor_id: Map.get(args, :actor_id),
text: text,
mentions: mentions,
tags: tags,
event: event,
in_reply_to_comment: in_reply_to_comment,
in_reply_to_comment_id:
if(is_nil(in_reply_to_comment), do: nil, else: Map.get(in_reply_to_comment, :id)),
origin_comment_id:
if(is_nil(in_reply_to_comment),
do: nil,
else: Comment.get_thread_id(in_reply_to_comment)
)
}) do
args
end
end
@spec handle_event_for_comment(String.t() | integer() | nil) :: Event.t() | nil
defp handle_event_for_comment(event_id) when not is_nil(event_id) do
case Events.get_event_with_preload(event_id) do
{:ok, %Event{} = event} -> event
{:error, :event_not_found} -> nil
end
end
defp handle_event_for_comment(nil), do: nil
defp prepare_args_for_group(args) do
with preferred_username <-
args |> Map.get(:preferred_username) |> HTML.strip_tags() |> String.trim(),
summary <- args |> Map.get(:summary, "") |> String.trim(),
{summary, _mentions, _tags} <-
summary |> String.trim() |> APIUtils.make_content_html([], "text/html") do
%{args | preferred_username: preferred_username, summary: summary}
end
end
defp prepare_args_for_report(args) do
with {:reporter, %Actor{} = reporter_actor} <-
{:reporter, Actors.get_actor!(args.reporter_id)},
{:reported, %Actor{} = reported_actor} <-
{:reported, Actors.get_actor!(args.reported_id)},
content <- HTML.strip_tags(args.content),
event <- Conversations.get_comment(Map.get(args, :event_id)),
{:get_report_comments, comments} <-
{:get_report_comments,
Conversations.list_comments_by_actor_and_ids(
reported_actor.id,
Map.get(args, :comments_ids, [])
)} do
Map.merge(args, %{
reporter: reporter_actor,
reported: reported_actor,
content: content,
event: event,
comments: comments
})
end
end
defp check_for_actor_key_rotation(%Actor{} = actor) do
if Actors.should_rotate_actor_key(actor) do
Actors.schedule_key_rotation(
actor,
Application.get_env(:mobilizon, :activitypub)[:actor_key_rotation_delay]
)
end
:ok
end
end end

View File

@ -5,7 +5,7 @@ defmodule Mobilizon.Federation.ActivityPub.Audience do
alias Mobilizon.Actors alias Mobilizon.Actors
alias Mobilizon.Actors.Actor alias Mobilizon.Actors.Actor
alias Mobilizon.Conversations.Comment alias Mobilizon.Discussions.{Comment, Discussion}
alias Mobilizon.Events.{Event, Participant} alias Mobilizon.Events.{Event, Participant}
alias Mobilizon.Share alias Mobilizon.Share
alias Mobilizon.Storage.Repo alias Mobilizon.Storage.Repo
@ -79,6 +79,14 @@ defmodule Mobilizon.Federation.ActivityPub.Audience do
def get_addressed_actors(mentioned_users, _), do: mentioned_users def get_addressed_actors(mentioned_users, _), do: mentioned_users
def calculate_to_and_cc_from_mentions(
%Comment{discussion: %Discussion{actor_id: actor_id}} = _comment
) do
with %Actor{type: :Group, members_url: members_url} <- Actors.get_actor(actor_id) do
%{"to" => [members_url], "cc" => []}
end
end
def calculate_to_and_cc_from_mentions(%Comment{} = comment) do def calculate_to_and_cc_from_mentions(%Comment{} = comment) do
with mentioned_actors <- Enum.map(comment.mentions, &process_mention/1), with mentioned_actors <- Enum.map(comment.mentions, &process_mention/1),
addressed_actors <- get_addressed_actors(mentioned_actors, nil), addressed_actors <- get_addressed_actors(mentioned_actors, nil),
@ -96,6 +104,28 @@ defmodule Mobilizon.Federation.ActivityPub.Audience do
end end
end end
def calculate_to_and_cc_from_mentions(%Discussion{actor_id: actor_id}) do
with %Actor{type: :Group, members_url: members_url} <- Actors.get_actor(actor_id) do
%{"to" => [members_url], "cc" => []}
end
end
def calculate_to_and_cc_from_mentions(%Event{
attributed_to: %Actor{members_url: members_url},
visibility: visibility
}) do
case visibility do
:public ->
%{"to" => [members_url, @ap_public], "cc" => []}
:unlisted ->
%{"to" => [members_url], "cc" => [@ap_public]}
:private ->
%{"to" => [members_url], "cc" => []}
end
end
def calculate_to_and_cc_from_mentions(%Event{} = event) do def calculate_to_and_cc_from_mentions(%Event{} = event) do
with mentioned_actors <- Enum.map(event.mentions, &process_mention/1), with mentioned_actors <- Enum.map(event.mentions, &process_mention/1),
addressed_actors <- get_addressed_actors(mentioned_actors, nil), addressed_actors <- get_addressed_actors(mentioned_actors, nil),

View File

@ -0,0 +1,74 @@
defmodule Mobilizon.Federation.ActivityPub.Fetcher do
@moduledoc """
Module to handle direct URL ActivityPub fetches to remote content
If you need to first get cached data, see `Mobilizon.Federation.ActivityPub.fetch_object_from_url/2`
"""
require Logger
alias Mobilizon.Federation.HTTPSignatures.Signature
alias Mobilizon.Federation.ActivityPub.{Relay, Transmogrifier}
alias Mobilizon.Service.HTTP.ActivityPub, as: ActivityPubClient
import Mobilizon.Federation.ActivityPub.Utils,
only: [maybe_date_fetch: 2, sign_fetch: 4, origin_check?: 2]
@spec fetch(String.t(), Keyword.t()) :: {:ok, map()}
def fetch(url, options \\ []) do
on_behalf_of = Keyword.get(options, :on_behalf_of, Relay.get_actor())
with date <- Signature.generate_date_header(),
headers <-
[{:Accept, "application/activity+json"}]
|> maybe_date_fetch(date)
|> sign_fetch(on_behalf_of, url, date),
client <-
ActivityPubClient.client(headers: headers),
{:ok, %Tesla.Env{body: data, status: code}} when code in 200..299 <-
ActivityPubClient.get(client, url) do
{:ok, data}
end
end
@spec fetch_and_create(String.t(), Keyword.t()) :: {:ok, map(), struct()}
def fetch_and_create(url, options \\ []) do
with {:ok, data} when is_map(data) <- fetch(url, options),
:ok <- Logger.debug("inspect body from fetch_object_from_url #{url}"),
:ok <- Logger.debug(inspect(data)),
{:origin_check, true} <- {:origin_check, origin_check?(url, data)},
params <- %{
"type" => "Create",
"to" => data["to"],
"cc" => data["cc"],
"actor" => data["attributedTo"] || data["actor"],
"object" => data
} do
Transmogrifier.handle_incoming(params)
else
{:origin_check, false} ->
Logger.warn("Object origin check failed")
{:error, "Object origin check failed"}
end
end
@spec fetch_and_update(String.t(), Keyword.t()) :: {:ok, map(), struct()}
def fetch_and_update(url, options \\ []) do
with {:ok, data} when is_map(data) <- fetch(url, options),
:ok <- Logger.debug("inspect body from fetch_object_from_url #{url}"),
:ok <- Logger.debug(inspect(data)),
{:origin_check, true} <- {:origin_check, origin_check?(url, data)},
params <- %{
"type" => "Update",
"to" => data["to"],
"cc" => data["cc"],
"actor" => data["attributedTo"] || data["actor"],
"object" => data
} do
Transmogrifier.handle_incoming(params)
else
{:origin_check, false} ->
Logger.warn("Object origin check failed")
{:error, "Object origin check failed"}
end
end
end

View File

@ -0,0 +1,30 @@
defmodule Mobilizon.Federation.ActivityPub.Preloader do
@moduledoc """
Module to ensure entities are correctly preloaded
"""
# TODO: Move me in a more appropriate place
alias Mobilizon.{Actors, Discussions, Events, Resources}
alias Mobilizon.Actors.Actor
alias Mobilizon.Discussions.{Comment, Discussion}
alias Mobilizon.Events.Event
alias Mobilizon.Resources.Resource
alias Mobilizon.Tombstone
def maybe_preload(%Event{url: url}),
do: {:ok, Events.get_public_event_by_url_with_preload!(url)}
def maybe_preload(%Comment{url: url}),
do: {:ok, Discussions.get_comment_from_url_with_preload!(url)}
def maybe_preload(%Discussion{} = discussion), do: {:ok, discussion}
def maybe_preload(%Resource{url: url}),
do: {:ok, Resources.get_resource_by_url_with_preloads(url)}
def maybe_preload(%Actor{url: url}), do: {:ok, Actors.get_actor_by_url!(url, true)}
def maybe_preload(%Tombstone{uri: _uri} = tombstone), do: {:ok, tombstone}
def maybe_preload(other), do: {:error, other}
end

View File

@ -3,24 +3,31 @@ defmodule Mobilizon.Federation.ActivityPub.Refresher do
Module that provides functions to explore and fetch collections on a group Module that provides functions to explore and fetch collections on a group
""" """
alias Mobilizon.Actors
alias Mobilizon.Actors.Actor alias Mobilizon.Actors.Actor
alias Mobilizon.Federation.ActivityPub alias Mobilizon.Federation.ActivityPub
alias Mobilizon.Federation.ActivityStream.Converter.Member, as: MemberConverter alias Mobilizon.Federation.ActivityPub.{Fetcher, Transmogrifier}
alias Mobilizon.Federation.ActivityStream.Converter.Resource, as: ResourceConverter
alias Mobilizon.Federation.HTTPSignatures.Signature
alias Mobilizon.Resources
require Logger require Logger
import Mobilizon.Federation.ActivityPub.Utils,
only: [maybe_date_fetch: 2, sign_fetch: 4]
@spec fetch_group(String.t(), Actor.t()) :: :ok @spec fetch_group(String.t(), Actor.t()) :: :ok
def fetch_group(group_url, %Actor{} = on_behalf_of) do def fetch_group(group_url, %Actor{} = on_behalf_of) do
with {:ok, %Actor{resources_url: resources_url, members_url: members_url}} <- with {:ok,
%Actor{
outbox_url: outbox_url,
resources_url: resources_url,
members_url: members_url,
posts_url: posts_url,
todos_url: todos_url,
discussions_url: discussions_url,
events_url: events_url
}} <-
ActivityPub.get_or_fetch_actor_by_url(group_url) do ActivityPub.get_or_fetch_actor_by_url(group_url) do
fetch_collection(outbox_url, on_behalf_of)
fetch_collection(members_url, on_behalf_of) fetch_collection(members_url, on_behalf_of)
fetch_collection(resources_url, on_behalf_of) fetch_collection(resources_url, on_behalf_of)
fetch_collection(posts_url, on_behalf_of)
fetch_collection(todos_url, on_behalf_of)
fetch_collection(discussions_url, on_behalf_of)
fetch_collection(events_url, on_behalf_of)
end end
end end
@ -30,12 +37,28 @@ defmodule Mobilizon.Federation.ActivityPub.Refresher do
Logger.debug("Fetching and preparing collection from url") Logger.debug("Fetching and preparing collection from url")
Logger.debug(inspect(collection_url)) Logger.debug(inspect(collection_url))
with {:ok, data} <- fetch(collection_url, on_behalf_of) do with {:ok, data} <- Fetcher.fetch(collection_url, on_behalf_of: on_behalf_of) do
Logger.debug("Fetch ok, passing to process_collection") Logger.debug("Fetch ok, passing to process_collection")
process_collection(data, on_behalf_of) process_collection(data, on_behalf_of)
end end
end end
@spec fetch_element(String.t(), Actor.t()) :: any()
def fetch_element(url, %Actor{} = on_behalf_of) do
with {:ok, data} <- Fetcher.fetch(url, on_behalf_of: on_behalf_of) do
case handling_element(data) do
{:ok, _activity, entity} ->
{:ok, entity}
{:ok, entity} ->
{:ok, entity}
err ->
{:error, err}
end
end
end
defp process_collection(%{"type" => type, "orderedItems" => items}, _on_behalf_of) defp process_collection(%{"type" => type, "orderedItems" => items}, _on_behalf_of)
when type in ["OrderedCollection", "OrderedCollectionPage"] do when type in ["OrderedCollection", "OrderedCollectionPage"] do
Logger.debug( Logger.debug(
@ -55,55 +78,26 @@ defmodule Mobilizon.Federation.ActivityPub.Refresher do
when is_bitstring(first) do when is_bitstring(first) do
Logger.debug("OrderedCollection has a first property pointing to an URI") Logger.debug("OrderedCollection has a first property pointing to an URI")
with {:ok, data} <- fetch(first, on_behalf_of) do with {:ok, data} <- Fetcher.fetch(first, on_behalf_of: on_behalf_of) do
Logger.debug("Fetched the collection for first property") Logger.debug("Fetched the collection for first property")
process_collection(data, on_behalf_of) process_collection(data, on_behalf_of)
end end
end end
defp handling_element(%{"type" => "Member"} = data) do defp handling_element(data) when is_map(data) do
Logger.debug("Handling Member element") activity = %{
"type" => "Create",
"to" => data["to"],
"cc" => data["cc"],
"actor" => data["actor"],
"attributedTo" => data["attributedTo"],
"object" => data
}
data Transmogrifier.handle_incoming(activity)
|> MemberConverter.as_to_model_data()
|> Actors.create_member()
end end
defp handling_element(%{"type" => type} = data) defp handling_element(uri) when is_binary(uri) do
when type in ["Document", "ResourceCollection"] do ActivityPub.fetch_object_from_url(uri)
Logger.debug("Handling Resource element")
data
|> ResourceConverter.as_to_model_data()
|> Resources.create_resource()
end
defp fetch(url, %Actor{} = on_behalf_of) do
with date <- Signature.generate_date_header(),
headers <-
[{:Accept, "application/activity+json"}]
|> maybe_date_fetch(date)
|> sign_fetch(on_behalf_of, url, date),
%HTTPoison.Response{status_code: 200, body: body} <-
HTTPoison.get!(url, headers,
follow_redirect: true,
ssl: [{:versions, [:"tlsv1.2"]}]
),
{:ok, data} <-
Jason.decode(body) do
{:ok, data}
else
# Actor is gone, probably deleted
{:ok, %HTTPoison.Response{status_code: 410}} ->
Logger.info("Response HTTP 410")
{:error, :actor_deleted}
{:origin_check, false} ->
{:error, "Origin check failed"}
e ->
Logger.warn("Could not decode actor at fetch #{url}, #{inspect(e)}")
{:error, e}
end
end end
end end

View File

@ -8,17 +8,19 @@ defmodule Mobilizon.Federation.ActivityPub.Transmogrifier do
A module to handle coding from internal to wire ActivityPub and back. A module to handle coding from internal to wire ActivityPub and back.
""" """
alias Mobilizon.{Actors, Conversations, Events, Resources, Todos} alias Mobilizon.{Actors, Discussions, Events, Posts, Resources, Todos}
alias Mobilizon.Actors.{Actor, Follower, Member} alias Mobilizon.Actors.{Actor, Follower, Member}
alias Mobilizon.Conversations.Comment alias Mobilizon.Discussions.Comment
alias Mobilizon.Events.{Event, Participant} alias Mobilizon.Events.{Event, Participant}
alias Mobilizon.Posts.Post
alias Mobilizon.Resources.Resource alias Mobilizon.Resources.Resource
alias Mobilizon.Todos.{Todo, TodoList} alias Mobilizon.Todos.{Todo, TodoList}
alias Mobilizon.Federation.ActivityPub alias Mobilizon.Federation.ActivityPub
alias Mobilizon.Federation.ActivityPub.{Activity, Utils} alias Mobilizon.Federation.ActivityPub.{Activity, Relay, Utils}
alias Mobilizon.Federation.ActivityPub.Types.Ownable
alias Mobilizon.Federation.ActivityStream.{Converter, Convertible} alias Mobilizon.Federation.ActivityStream.{Converter, Convertible}
alias Mobilizon.Tombstone
alias Mobilizon.Web.Email.{Group, Participation} alias Mobilizon.Web.Email.{Group, Participation}
require Logger require Logger
@ -62,10 +64,20 @@ defmodule Mobilizon.Federation.ActivityPub.Transmogrifier do
with object_data when is_map(object_data) <- with object_data when is_map(object_data) <-
object |> Converter.Comment.as_to_model_data(), object |> Converter.Comment.as_to_model_data(),
{:existing_comment, {:error, :comment_not_found}} <- {:existing_comment, {:error, :comment_not_found}} <-
{:existing_comment, Conversations.get_comment_from_url_with_preload(object_data.url)}, {:existing_comment, Discussions.get_comment_from_url_with_preload(object_data.url)},
{:ok, %Activity{} = activity, %Comment{} = comment} <- object_data <- transform_object_data_for_discussion(object_data) do
ActivityPub.create(:comment, object_data, false) do # Check should be better
{:ok, activity, comment}
{:ok, %Activity{} = activity, entity} =
if is_data_for_comment_or_discussion?(object_data) do
Logger.debug("Chosing to create a regular comment")
ActivityPub.create(:comment, object_data, false)
else
Logger.debug("Chosing to initialize or add a comment to a conversation")
ActivityPub.create(:discussion, object_data, false)
end
{:ok, activity, entity}
else else
{:existing_comment, {:ok, %Comment{} = comment}} -> {:existing_comment, {:ok, %Comment{} = comment}} ->
{:ok, nil, comment} {:ok, nil, comment}
@ -100,6 +112,77 @@ defmodule Mobilizon.Federation.ActivityPub.Transmogrifier do
end end
end end
def handle_incoming(%{
"type" => "Create",
"object" => %{"type" => "Group", "id" => group_url} = _object
}) do
Logger.info("Handle incoming to create a group")
with {:ok, %Actor{} = group} <- ActivityPub.get_or_fetch_actor_by_url(group_url) do
{:ok, nil, group}
end
end
def handle_incoming(%{
"type" => "Create",
"object" => %{"type" => "Member"} = object
}) do
Logger.info("Handle incoming to create a member")
with object_data when is_map(object_data) <-
object |> Converter.Member.as_to_model_data(),
{:existing_member, nil} <-
{:existing_member, Actors.get_member_by_url(object_data.url)},
{:ok, %Activity{} = activity, %Member{} = member} <-
ActivityPub.join_group(object_data, false) do
{:ok, activity, member}
else
{:existing_member, %Member{} = member} ->
{:ok, nil, member}
end
end
def handle_incoming(%{
"type" => "Create",
"object" =>
%{"type" => "Article", "actor" => _actor, "attributedTo" => _attributed_to} = object
}) do
Logger.info("Handle incoming to create articles")
with object_data when is_map(object_data) <-
object |> Converter.Post.as_to_model_data(),
{:existing_post, nil} <-
{:existing_post, Posts.get_post_by_url(object_data.url)},
{:ok, %Activity{} = activity, %Post{} = post} <-
ActivityPub.create(:post, object_data, false) do
{:ok, activity, post}
else
{:existing_post, %Post{} = post} ->
{:ok, nil, post}
end
end
# This is a hack to handle Tombstones fetched by AP
def handle_incoming(%{
"type" => "Create",
"object" => %{"type" => "Tombstone", "id" => object_url} = _object
}) do
Logger.info("Handle incoming to create a tombstone")
case ActivityPub.fetch_object_from_url(object_url, force: true) do
# We already have the tombstone, object is probably already deleted
{:ok, %Tombstone{} = tombstone} ->
{:ok, nil, tombstone}
# Hack because deleted comments
{:ok, %Comment{deleted_at: deleted_at} = comment} when not is_nil(deleted_at) ->
{:ok, nil, comment}
{:ok, entity} ->
ActivityPub.delete(entity, Relay.get_actor(), false)
end
end
def handle_incoming( def handle_incoming(
%{"type" => "Follow", "object" => followed, "actor" => follower, "id" => id} = _data %{"type" => "Follow", "object" => followed, "actor" => follower, "id" => id} = _data
) do ) do
@ -165,7 +248,7 @@ defmodule Mobilizon.Federation.ActivityPub.Transmogrifier do
Logger.info("Handle incoming to create a resource") Logger.info("Handle incoming to create a resource")
Logger.debug(inspect(data)) Logger.debug(inspect(data))
group_url = hd(to) group_url = if is_list(to) and not is_nil(to), do: hd(to), else: to
with {:existing_resource, nil} <- with {:existing_resource, nil} <-
{:existing_resource, Resources.get_resource_by_url(object_url)}, {:existing_resource, Resources.get_resource_by_url(object_url)},
@ -175,8 +258,8 @@ defmodule Mobilizon.Federation.ActivityPub.Transmogrifier do
{:member, Actors.is_member?(object_data.creator_id, object_data.actor_id)}, {:member, Actors.is_member?(object_data.creator_id, object_data.actor_id)},
{:ok, %Activity{} = activity, %Resource{} = resource} <- {:ok, %Activity{} = activity, %Resource{} = resource} <-
ActivityPub.create(:resource, object_data, false), ActivityPub.create(:resource, object_data, false),
{:ok, %Actor{type: :Group, id: group_id} = group} <- %Actor{type: :Group, id: group_id} = group <-
ActivityPub.get_or_fetch_actor_by_url(group_url), Actors.get_group_by_members_url(group_url),
announce_id <- "#{object_url}/announces/#{group_id}", announce_id <- "#{object_url}/announces/#{group_id}",
{:ok, _activity, _resource} <- ActivityPub.announce(group, object, announce_id) do {:ok, _activity, _resource} <- ActivityPub.announce(group, object, announce_id) do
{:ok, activity, resource} {:ok, activity, resource}
@ -190,7 +273,7 @@ defmodule Mobilizon.Federation.ActivityPub.Transmogrifier do
:error :error
{:error, e} -> {:error, e} ->
Logger.error(inspect(e)) Logger.debug(inspect(e))
:error :error
end end
end end
@ -261,23 +344,14 @@ defmodule Mobilizon.Federation.ActivityPub.Transmogrifier do
def handle_incoming( def handle_incoming(
%{"type" => "Announce", "object" => object, "actor" => _actor, "id" => _id} = data %{"type" => "Announce", "object" => object, "actor" => _actor, "id" => _id} = data
) do ) do
with actor <- Utils.get_actor(data), with actor_url <- Utils.get_actor(data),
# TODO: Is the following line useful? {:ok, %Actor{id: actor_id, suspended: false} = actor} <-
{:ok, %Actor{id: actor_id, suspended: false} = _actor} <- ActivityPub.get_or_fetch_actor_by_url(actor_url),
ActivityPub.get_or_fetch_actor_by_url(actor),
:ok <- Logger.debug("Fetching contained object"), :ok <- Logger.debug("Fetching contained object"),
{:ok, object} <- fetch_obj_helper_as_activity_streams(object), {:ok, entity} <-
:ok <- Logger.debug("Handling contained object"), object |> Utils.get_url() |> fetch_object_optionnally_authenticated(actor),
create_data <- Utils.make_create_data(object), :ok <- eventually_create_share(object, entity, actor_id) do
:ok <- Logger.debug(inspect(object)), {:ok, nil, entity}
{:ok, _activity, entity} <- handle_incoming(create_data),
:ok <- Logger.debug("Finished processing contained object"),
{:ok, activity} <- ActivityPub.create_activity(data, false),
{:ok, %Actor{id: object_owner_actor_id}} <-
ActivityPub.get_or_fetch_actor_by_url(object["actor"]),
{:ok, %Mobilizon.Share{} = _share} <-
Mobilizon.Share.create(object["id"], actor_id, object_owner_actor_id) do
{:ok, activity, entity}
else else
e -> e ->
Logger.debug(inspect(e)) Logger.debug(inspect(e))
@ -296,7 +370,7 @@ defmodule Mobilizon.Federation.ActivityPub.Transmogrifier do
object_data <- object_data <-
object |> Converter.Actor.as_to_model_data(), object |> Converter.Actor.as_to_model_data(),
{:ok, %Activity{} = activity, %Actor{} = new_actor} <- {:ok, %Activity{} = activity, %Actor{} = new_actor} <-
ActivityPub.update(:actor, old_actor, object_data, false) do ActivityPub.update(old_actor, object_data, false) do
{:ok, activity, new_actor} {:ok, activity, new_actor}
else else
e -> e ->
@ -317,7 +391,7 @@ defmodule Mobilizon.Federation.ActivityPub.Transmogrifier do
object_data <- Converter.Event.as_to_model_data(object), object_data <- Converter.Event.as_to_model_data(object),
{:origin_check, true} <- {:origin_check, Utils.origin_check?(actor_url, update_data)}, {:origin_check, true} <- {:origin_check, Utils.origin_check?(actor_url, update_data)},
{:ok, %Activity{} = activity, %Event{} = new_event} <- {:ok, %Activity{} = activity, %Event{} = new_event} <-
ActivityPub.update(:event, old_event, object_data, false) do ActivityPub.update(old_event, object_data, false) do
{:ok, activity, new_event} {:ok, activity, new_event}
else else
_e -> _e ->
@ -325,6 +399,42 @@ defmodule Mobilizon.Federation.ActivityPub.Transmogrifier do
end end
end end
def handle_incoming(
%{"type" => "Update", "object" => %{"type" => "Note"} = object, "actor" => _actor} =
update_data
) do
with actor <- Utils.get_actor(update_data),
{:ok, %Actor{url: actor_url, suspended: false}} <-
ActivityPub.get_or_fetch_actor_by_url(actor),
{:origin_check, true} <- {:origin_check, Utils.origin_check?(actor_url, update_data)},
object_data <- Converter.Comment.as_to_model_data(object),
{:ok, old_entity} <- object |> Utils.get_url() |> ActivityPub.fetch_object_from_url(),
object_data <- transform_object_data_for_discussion(object_data),
{:ok, %Activity{} = activity, new_entity} <-
ActivityPub.update(old_entity, object_data, false) do
{:ok, activity, new_entity}
else
_e ->
:error
end
end
def handle_incoming(%{
"type" => "Update",
"object" => %{"type" => "Tombstone"} = object,
"actor" => _actor
}) do
Logger.info("Handle incoming to update a tombstone")
with object_url <- Utils.get_url(object),
{:ok, entity} <- ActivityPub.fetch_object_from_url(object_url) do
ActivityPub.delete(entity, Relay.get_actor(), false)
else
{:ok, %Tombstone{} = tombstone} ->
{:ok, nil, tombstone}
end
end
def handle_incoming( def handle_incoming(
%{ %{
"type" => "Undo", "type" => "Undo",
@ -367,21 +477,20 @@ defmodule Mobilizon.Federation.ActivityPub.Transmogrifier do
end end
end end
# TODO: We presently assume that any actor on the same origin domain as the object being # We assume everyone on the same instance as the object
# deleted has the rights to delete that object. A better way to validate whether or not # or who is member of a group has the right to delete the object
# the object should be deleted is to refetch the object URI, which should return either
# an error or a tombstone. This would allow us to verify that a deletion actually took
# place.
def handle_incoming( def handle_incoming(
%{"type" => "Delete", "object" => object, "actor" => _actor, "id" => _id} = data %{"type" => "Delete", "object" => object, "actor" => _actor, "id" => _id} = data
) do ) do
with actor <- Utils.get_actor(data), with actor_url <- Utils.get_actor(data),
{:ok, %Actor{url: actor_url}} <- ActivityPub.get_or_fetch_actor_by_url(actor), {:ok, %Actor{} = actor} <- ActivityPub.get_or_fetch_actor_by_url(actor_url),
object_id <- Utils.get_url(object), object_id <- Utils.get_url(object),
{:origin_check, true} <-
{:origin_check, Utils.origin_check_from_id?(actor_url, object_id)},
{:ok, object} <- ActivityPub.fetch_object_from_url(object_id), {:ok, object} <- ActivityPub.fetch_object_from_url(object_id),
{:ok, activity, object} <- ActivityPub.delete(object, false) do {:origin_check, true} <-
{:origin_check,
Utils.origin_check_from_id?(actor_url, object_id) ||
Utils.activity_actor_is_group_member?(actor, object)},
{:ok, activity, object} <- ActivityPub.delete(object, actor, false) do
{:ok, activity, object} {:ok, activity, object}
else else
{:origin_check, false} -> {:origin_check, false} ->
@ -449,6 +558,8 @@ defmodule Mobilizon.Federation.ActivityPub.Transmogrifier do
"target" => target "target" => target
} = data } = data
) do ) do
Logger.info("Handle incoming to invite someone")
with {:ok, %Actor{} = actor} <- with {:ok, %Actor{} = actor} <-
data |> Utils.get_actor() |> ActivityPub.get_or_fetch_actor_by_url(), data |> Utils.get_actor() |> ActivityPub.get_or_fetch_actor_by_url(),
{:ok, object} <- object |> Utils.get_url() |> ActivityPub.fetch_object_from_url(), {:ok, object} <- object |> Utils.get_url() |> ActivityPub.fetch_object_from_url(),
@ -485,7 +596,7 @@ defmodule Mobilizon.Federation.ActivityPub.Transmogrifier do
# end # end
def handle_incoming(object) do def handle_incoming(object) do
Logger.info("Handing something not supported") Logger.info("Handing something with type #{object["type"]} not supported")
Logger.debug(inspect(object)) Logger.debug(inspect(object))
{:error, :not_supported} {:error, :not_supported}
end end
@ -657,6 +768,52 @@ defmodule Mobilizon.Federation.ActivityPub.Transmogrifier do
end end
end end
# If the object has been announced by a group let's use one of our members to fetch it
@spec fetch_object_optionnally_authenticated(String.t(), Actor.t() | any()) ::
{:ok, struct()} | {:error, any()}
defp fetch_object_optionnally_authenticated(url, %Actor{type: :Group, id: group_id}) do
case Actors.get_single_group_member_actor(group_id) do
%Actor{} = actor ->
ActivityPub.fetch_object_from_url(url, on_behalf_of: actor, force: true)
_err ->
fetch_object_optionnally_authenticated(url, nil)
end
end
defp fetch_object_optionnally_authenticated(url, _),
do: ActivityPub.fetch_object_from_url(url, force: true)
defp eventually_create_share(object, entity, actor_id) do
with object_id <- object |> Utils.get_url(),
%Actor{id: object_owner_actor_id} <- Ownable.actor(entity) do
{:ok, %Mobilizon.Share{} = _share} =
Mobilizon.Share.create(object_id, actor_id, object_owner_actor_id)
end
:ok
end
@spec is_data_for_comment_or_discussion?(map()) :: boolean()
defp is_data_for_comment_or_discussion?(object_data) do
(not Map.has_key?(object_data, :title) or
is_nil(object_data.title) or object_data.title == "") and
is_nil(object_data.discussion_id)
end
# Comment and conversations have different attributes for actor and groups
defp transform_object_data_for_discussion(object_data) do
# Basic comment
if is_data_for_comment_or_discussion?(object_data) do
object_data
else
# Conversation
object_data
|> Map.put(:creator_id, object_data.actor_id)
|> Map.put(:actor_id, object_data.attributed_to_id)
end
end
defp get_follow(follow_object) do defp get_follow(follow_object) do
with follow_object_id when not is_nil(follow_object_id) <- Utils.get_url(follow_object), with follow_object_id when not is_nil(follow_object_id) <- Utils.get_url(follow_object),
{:not_found, %Follower{} = follow} <- {:not_found, %Follower{} = follow} <-

View File

@ -0,0 +1,74 @@
defmodule Mobilizon.Federation.ActivityPub.Types.Actors do
@moduledoc false
alias Mobilizon.Actors
alias Mobilizon.Actors.Actor
alias Mobilizon.Federation.ActivityPub.Audience
alias Mobilizon.Federation.ActivityPub.Types.Entity
alias Mobilizon.Federation.ActivityStream.Convertible
alias Mobilizon.GraphQL.API.Utils, as: APIUtils
alias Mobilizon.Service.Formatter.HTML
import Mobilizon.Federation.ActivityPub.Utils, only: [make_create_data: 2, make_update_data: 2]
@behaviour Entity
@impl Entity
@spec create(map(), map()) :: {:ok, map()}
def create(args, additional) do
with args <- prepare_args_for_actor(args),
{:ok, %Actor{} = actor} <- Actors.create_actor(args),
actor_as_data <- Convertible.model_to_as(actor),
audience <- %{"to" => ["https://www.w3.org/ns/activitystreams#Public"], "cc" => []},
create_data <-
make_create_data(actor_as_data, Map.merge(audience, additional)) do
{:ok, actor, create_data}
end
end
@impl Entity
@spec update(Actor.t(), map, map) :: {:ok, Actor.t(), Activity.t()} | any
def update(%Actor{} = old_actor, args, additional) do
with {:ok, %Actor{} = new_actor} <- Actors.update_actor(old_actor, args),
actor_as_data <- Convertible.model_to_as(new_actor),
{:ok, true} <- Cachex.del(:activity_pub, "actor_#{new_actor.preferred_username}"),
audience <-
Audience.calculate_to_and_cc_from_mentions(new_actor),
additional <- Map.merge(additional, %{"actor" => old_actor.url}),
update_data <- make_update_data(actor_as_data, Map.merge(audience, additional)) do
{:ok, new_actor, update_data}
end
end
@impl Entity
def delete(
%Actor{followers_url: followers_url, url: target_actor_url} = target_actor,
%Actor{url: actor_url} = actor,
local
) do
activity_data = %{
"type" => "Delete",
"actor" => actor_url,
"object" => Convertible.model_to_as(target_actor),
"id" => target_actor_url <> "/delete",
"to" => [followers_url, "https://www.w3.org/ns/activitystreams#Public"]
}
# We completely delete the actor if activity is remote
with {:ok, %Oban.Job{}} <- Actors.delete_actor(target_actor, reserve_username: local) do
{:ok, activity_data, actor, target_actor}
end
end
def actor(%Actor{} = actor), do: actor
def group_actor(%Actor{} = _actor), do: nil
defp prepare_args_for_actor(args) do
with preferred_username <-
args |> Map.get(:preferred_username) |> HTML.strip_tags() |> String.trim(),
summary <- args |> Map.get(:summary, "") |> String.trim(),
{summary, _mentions, _tags} <-
summary |> String.trim() |> APIUtils.make_content_html([], "text/html") do
%{args | preferred_username: preferred_username, summary: summary}
end
end
end

View File

@ -0,0 +1,149 @@
defmodule Mobilizon.Federation.ActivityPub.Types.Comments do
@moduledoc false
alias Mobilizon.{Actors, Discussions, Events}
alias Mobilizon.Actors.Actor
alias Mobilizon.Discussions.{Comment, Discussion}
alias Mobilizon.Events.Event
alias Mobilizon.Federation.ActivityPub.Audience
alias Mobilizon.Federation.ActivityPub.Types.Entity
alias Mobilizon.Federation.ActivityStream.Converter.Utils, as: ConverterUtils
alias Mobilizon.Federation.ActivityStream.Convertible
alias Mobilizon.GraphQL.API.Utils, as: APIUtils
alias Mobilizon.Share
alias Mobilizon.Tombstone
alias Mobilizon.Web.Endpoint
import Mobilizon.Federation.ActivityPub.Utils, only: [make_create_data: 2, make_update_data: 2]
require Logger
@behaviour Entity
@impl Entity
@spec create(map(), map()) :: {:ok, map()}
def create(args, additional) do
with args <- prepare_args_for_comment(args),
{:ok, %Comment{discussion_id: discussion_id} = comment} <-
Discussions.create_comment(args),
:ok <- maybe_publish_graphql_subscription(discussion_id),
comment_as_data <- Convertible.model_to_as(comment),
audience <-
Audience.calculate_to_and_cc_from_mentions(comment),
create_data <-
make_create_data(comment_as_data, Map.merge(audience, additional)) do
{:ok, comment, create_data}
end
end
@impl Entity
@spec update(Comment.t(), map(), map()) :: {:ok, Comment.t(), Activity.t()} | any()
def update(%Comment{} = old_comment, args, additional) do
with args <- prepare_args_for_comment(args),
{:ok, %Comment{} = new_comment} <- Discussions.update_comment(old_comment, args),
{:ok, true} <- Cachex.del(:activity_pub, "comment_#{new_comment.uuid}"),
comment_as_data <- Convertible.model_to_as(new_comment),
audience <-
Audience.calculate_to_and_cc_from_mentions(new_comment),
update_data <- make_update_data(comment_as_data, Map.merge(audience, additional)) do
{:ok, new_comment, update_data}
else
err ->
Logger.error("Something went wrong while creating an update activity")
Logger.debug(inspect(err))
err
end
end
@impl Entity
@spec delete(Comment.t(), Actor.t(), boolean) :: {:ok, Comment.t()}
def delete(%Comment{url: url} = comment, %Actor{} = actor, _local) do
activity_data = %{
"type" => "Delete",
"actor" => actor.url,
"object" => Convertible.model_to_as(comment),
"id" => url <> "/delete",
"to" => ["https://www.w3.org/ns/activitystreams#Public"]
}
with audience <-
Audience.calculate_to_and_cc_from_mentions(comment),
{:ok, %Comment{} = comment} <- Discussions.delete_comment(comment),
# Preload to be sure
%Comment{} = comment <- Discussions.get_comment_with_preload(comment.id),
{:ok, true} <- Cachex.del(:activity_pub, "comment_#{comment.uuid}"),
{:ok, %Tombstone{} = _tombstone} <-
Tombstone.create_tombstone(%{uri: comment.url, actor_id: actor.id}) do
Share.delete_all_by_uri(comment.url)
{:ok, Map.merge(activity_data, audience), actor, comment}
end
end
def actor(%Comment{actor: %Actor{} = actor}), do: actor
def actor(%Comment{actor_id: actor_id}) when not is_nil(actor_id),
do: Actors.get_actor(actor_id)
def actor(_), do: nil
def group_actor(%Comment{attributed_to: %Actor{} = group}), do: group
def group_actor(%Comment{attributed_to_id: attributed_to_id}) when not is_nil(attributed_to_id),
do: Actors.get_actor(attributed_to_id)
def group_actor(_), do: nil
# Prepare and sanitize arguments for comments
defp prepare_args_for_comment(args) do
with in_reply_to_comment <-
args |> Map.get(:in_reply_to_comment_id) |> Discussions.get_comment_with_preload(),
event <- args |> Map.get(:event_id) |> handle_event_for_comment(),
args <- Map.update(args, :visibility, :public, & &1),
{text, mentions, tags} <-
APIUtils.make_content_html(
args |> Map.get(:text, "") |> String.trim(),
# Can't put additional tags on a comment
[],
"text/html"
),
tags <- ConverterUtils.fetch_tags(tags),
mentions <- Map.get(args, :mentions, []) ++ ConverterUtils.fetch_mentions(mentions),
args <-
Map.merge(args, %{
actor_id: Map.get(args, :actor_id),
text: text,
mentions: mentions,
tags: tags,
event: event,
in_reply_to_comment: in_reply_to_comment,
in_reply_to_comment_id:
if(is_nil(in_reply_to_comment), do: nil, else: Map.get(in_reply_to_comment, :id)),
origin_comment_id:
if(is_nil(in_reply_to_comment),
do: nil,
else: Comment.get_thread_id(in_reply_to_comment)
)
}) do
args
end
end
@spec handle_event_for_comment(String.t() | integer() | nil) :: Event.t() | nil
defp handle_event_for_comment(event_id) when not is_nil(event_id) do
case Events.get_event_with_preload(event_id) do
{:ok, %Event{} = event} -> event
{:error, :event_not_found} -> nil
end
end
defp handle_event_for_comment(nil), do: nil
defp maybe_publish_graphql_subscription(nil), do: :ok
defp maybe_publish_graphql_subscription(discussion_id) do
with %Discussion{} = discussion <- Discussions.get_discussion(discussion_id) do
Absinthe.Subscription.publish(Endpoint, discussion,
discussion_comment_changed: discussion.slug
)
:ok
end
end
end

View File

@ -0,0 +1,115 @@
defmodule Mobilizon.Federation.ActivityPub.Types.Discussions do
@moduledoc false
alias Mobilizon.{Actors, Discussions}
alias Mobilizon.Actors.Actor
alias Mobilizon.Discussions.{Comment, Discussion}
alias Mobilizon.Federation.ActivityPub.Audience
alias Mobilizon.Federation.ActivityPub.Types.{Comments, Entity}
alias Mobilizon.Federation.ActivityStream.Convertible
alias Mobilizon.Storage.Repo
alias Mobilizon.Web.Endpoint
import Mobilizon.Federation.ActivityPub.Utils, only: [make_create_data: 2, make_update_data: 2]
require Logger
@behaviour Entity
@impl Entity
@spec create(map(), map()) :: {:ok, map()}
def create(%{discussion_id: discussion_id} = args, additional) when not is_nil(discussion_id) do
with %Discussion{} = discussion <- Discussions.get_discussion(discussion_id),
{:ok, %Discussion{last_comment_id: last_comment_id} = discussion} <-
Discussions.reply_to_discussion(discussion, args),
%Comment{} = last_comment <- Discussions.get_comment_with_preload(last_comment_id),
:ok <- maybe_publish_graphql_subscription(discussion),
comment_as_data <- Convertible.model_to_as(last_comment),
audience <-
Audience.calculate_to_and_cc_from_mentions(discussion),
create_data <-
make_create_data(comment_as_data, Map.merge(audience, additional)) do
{:ok, discussion, create_data}
end
end
@impl Entity
@spec create(map(), map()) :: {:ok, map()}
def create(args, additional) do
with {:ok, %Discussion{} = discussion} <-
Discussions.create_discussion(args),
discussion_as_data <- Convertible.model_to_as(discussion),
audience <-
Audience.calculate_to_and_cc_from_mentions(discussion),
create_data <-
make_create_data(discussion_as_data, Map.merge(audience, additional)) do
{:ok, discussion, create_data}
end
end
@impl Entity
@spec update(Discussion.t(), map(), map()) :: {:ok, Discussion.t(), Activity.t()} | any()
def update(%Discussion{} = old_discussion, args, additional) do
with {:ok, %Discussion{} = new_discussion} <-
Discussions.update_discussion(old_discussion, args),
{:ok, true} <- Cachex.del(:activity_pub, "discussion_#{new_discussion.slug}"),
discussion_as_data <- Convertible.model_to_as(new_discussion),
audience <-
Audience.calculate_to_and_cc_from_mentions(new_discussion),
update_data <- make_update_data(discussion_as_data, Map.merge(audience, additional)) do
{:ok, new_discussion, update_data}
else
err ->
Logger.error("Something went wrong while creating an update activity")
Logger.debug(inspect(err))
err
end
end
@impl Entity
@spec delete(Discussion.t(), Actor.t(), boolean) :: {:ok, Discussion.t()}
def delete(%Discussion{actor: group, url: url} = discussion, %Actor{} = actor, _local) do
stream =
discussion.comments
|> Enum.map(
&Repo.preload(&1, [
:actor,
:attributed_to,
:in_reply_to_comment,
:mentions,
:origin_comment,
:discussion,
:tags,
:replies
])
)
|> Enum.map(&Map.put(&1, :event, nil))
|> Task.async_stream(fn comment -> Comments.delete(comment, actor, nil) end)
Stream.run(stream)
with {:ok, %Discussion{}} <- Discussions.delete_discussion(discussion) do
# This is just fake
activity_data = %{
"type" => "Delete",
"actor" => actor.url,
"object" => Convertible.model_to_as(discussion),
"id" => url <> "/delete",
"to" => [group.members_url]
}
{:ok, activity_data, actor, discussion}
end
end
def actor(%Discussion{creator_id: creator_id}), do: Actors.get_actor(creator_id)
def group_actor(%Discussion{actor_id: actor_id}), do: Actors.get_actor(actor_id)
@spec maybe_publish_graphql_subscription(Discussion.t()) :: :ok
defp maybe_publish_graphql_subscription(%Discussion{} = discussion) do
Absinthe.Subscription.publish(Endpoint, discussion,
discussion_comment_changed: discussion.slug
)
:ok
end
end

View File

@ -0,0 +1,151 @@
alias Mobilizon.Federation.ActivityPub.Types.{
Actors,
Comments,
Discussions,
Entity,
Events,
Managable,
Ownable,
Posts,
Resources,
Todos,
TodoLists,
Tombstones
}
alias Mobilizon.Actors.Actor
alias Mobilizon.Events.Event
alias Mobilizon.Discussions.{Comment, Discussion}
alias Mobilizon.Posts.Post
alias Mobilizon.Resources.Resource
alias Mobilizon.Todos.{Todo, TodoList}
alias Mobilizon.Federation.ActivityStream
alias Mobilizon.Tombstone
defmodule Mobilizon.Federation.ActivityPub.Types.Entity do
@moduledoc """
ActivityPub entity behaviour
"""
@type t :: %{id: String.t()}
@callback create(data :: any(), additionnal :: map()) ::
{:ok, t(), ActivityStream.t()}
@callback update(struct :: t(), attrs :: map(), additionnal :: map()) ::
{:ok, t(), ActivityStream.t()}
@callback delete(struct :: t(), Actor.t(), local :: boolean()) ::
{:ok, ActivityStream.t(), Actor.t(), t()}
end
defprotocol Mobilizon.Federation.ActivityPub.Types.Managable do
@moduledoc """
ActivityPub entity Managable protocol.
"""
@spec update(Entity.t(), map(), map()) :: {:ok, Entity.t(), ActivityStream.t()}
@doc """
Updates a `Managable` entity with the appropriate attributes and returns the updated entity and an activitystream representation for it
"""
def update(entity, attrs, additionnal)
@spec delete(Entity.t(), Actor.t(), boolean()) ::
{:ok, ActivityStream.t(), Actor.t(), Entity.t()}
@doc "Deletes an entity and returns the activitystream representation for it"
def delete(entity, actor, local)
end
defprotocol Mobilizon.Federation.ActivityPub.Types.Ownable do
@spec group_actor(Entity.t()) :: Actor.t() | nil
@doc "Returns an eventual group for the entity"
def group_actor(entity)
@spec actor(Entity.t()) :: Actor.t() | nil
@doc "Returns the actor for the entity"
def actor(entity)
end
defimpl Managable, for: Event do
defdelegate update(entity, attrs, additionnal), to: Events
defdelegate delete(entity, actor, local), to: Events
end
defimpl Ownable, for: Event do
defdelegate group_actor(entity), to: Events
defdelegate actor(entity), to: Events
end
defimpl Managable, for: Comment do
defdelegate update(entity, attrs, additionnal), to: Comments
defdelegate delete(entity, actor, local), to: Comments
end
defimpl Ownable, for: Comment do
defdelegate group_actor(entity), to: Comments
defdelegate actor(entity), to: Comments
end
defimpl Managable, for: Post do
defdelegate update(entity, attrs, additionnal), to: Posts
defdelegate delete(entity, actor, local), to: Posts
end
defimpl Ownable, for: Post do
defdelegate group_actor(entity), to: Posts
defdelegate actor(entity), to: Posts
end
defimpl Managable, for: Actor do
defdelegate update(entity, attrs, additionnal), to: Actors
defdelegate delete(entity, actor, local), to: Actors
end
defimpl Ownable, for: Actor do
defdelegate group_actor(entity), to: Actors
defdelegate actor(entity), to: Actors
end
defimpl Managable, for: TodoList do
defdelegate update(entity, attrs, additionnal), to: TodoLists
defdelegate delete(entity, actor, local), to: TodoLists
end
defimpl Ownable, for: TodoList do
defdelegate group_actor(entity), to: TodoLists
defdelegate actor(entity), to: TodoLists
end
defimpl Managable, for: Todo do
defdelegate update(entity, attrs, additionnal), to: Todos
defdelegate delete(entity, actor, local), to: Todos
end
defimpl Ownable, for: Todo do
defdelegate group_actor(entity), to: Todos
defdelegate actor(entity), to: Todos
end
defimpl Managable, for: Resource do
defdelegate update(entity, attrs, additionnal), to: Resources
defdelegate delete(entity, actor, local), to: Resources
end
defimpl Ownable, for: Resource do
defdelegate group_actor(entity), to: Resources
defdelegate actor(entity), to: Resources
end
defimpl Managable, for: Discussion do
defdelegate update(entity, attrs, additionnal), to: Discussions
defdelegate delete(entity, actor, local), to: Discussions
end
defimpl Ownable, for: Discussion do
defdelegate group_actor(entity), to: Discussions
defdelegate actor(entity), to: Discussions
end
defimpl Ownable, for: Tombstone do
defdelegate group_actor(entity), to: Tombstones
defdelegate actor(entity), to: Tombstones
end

View File

@ -0,0 +1,203 @@
defmodule Mobilizon.Federation.ActivityPub.Types.Events do
@moduledoc false
alias Mobilizon.Actors
alias Mobilizon.Actors.Actor
alias Mobilizon.Events, as: EventsManager
alias Mobilizon.Events.{Event, Participant}
alias Mobilizon.Federation.ActivityPub
alias Mobilizon.Federation.ActivityPub.Audience
alias Mobilizon.Federation.ActivityPub.Types.Entity
alias Mobilizon.Federation.ActivityStream.Converter.Utils, as: ConverterUtils
alias Mobilizon.Federation.ActivityStream.Convertible
alias Mobilizon.GraphQL.API.Utils, as: APIUtils
alias Mobilizon.Service.Formatter.HTML
alias Mobilizon.Service.Notifications.Scheduler
alias Mobilizon.Share
alias Mobilizon.Tombstone
import Mobilizon.Federation.ActivityPub.Utils, only: [make_create_data: 2, make_update_data: 2]
require Logger
@behaviour Entity
@impl Entity
@spec create(map(), map()) :: {:ok, map()}
def create(args, additional) do
with args <- prepare_args_for_event(args),
{:ok, %Event{} = event} <- EventsManager.create_event(args),
event_as_data <- Convertible.model_to_as(event),
audience <-
Audience.calculate_to_and_cc_from_mentions(event),
create_data <-
make_create_data(event_as_data, Map.merge(audience, additional)) do
{:ok, event, create_data}
end
end
@impl Entity
@spec update(Event.t(), map(), map()) :: {:ok, Event.t(), Activity.t()} | any()
def update(%Event{} = old_event, args, additional) do
with args <- prepare_args_for_event(args),
{:ok, %Event{} = new_event} <- EventsManager.update_event(old_event, args),
{:ok, true} <- Cachex.del(:activity_pub, "event_#{new_event.uuid}"),
event_as_data <- Convertible.model_to_as(new_event),
audience <-
Audience.calculate_to_and_cc_from_mentions(new_event),
update_data <- make_update_data(event_as_data, Map.merge(audience, additional)) do
{:ok, new_event, update_data}
else
err ->
Logger.error("Something went wrong while creating an update activity")
Logger.debug(inspect(err))
err
end
end
@impl Entity
@spec delete(Event.t(), Actor.t(), boolean) :: {:ok, Event.t()}
def delete(%Event{url: url} = event, %Actor{} = actor, _local) do
activity_data = %{
"type" => "Delete",
"actor" => actor.url,
"object" => Convertible.model_to_as(event),
"to" => [actor.url <> "/followers", "https://www.w3.org/ns/activitystreams#Public"],
"id" => url <> "/delete"
}
with audience <-
Audience.calculate_to_and_cc_from_mentions(event),
{:ok, %Event{} = event} <- EventsManager.delete_event(event),
{:ok, true} <- Cachex.del(:activity_pub, "event_#{event.uuid}"),
{:ok, %Tombstone{} = _tombstone} <-
Tombstone.create_tombstone(%{uri: event.url, actor_id: actor.id}) do
Share.delete_all_by_uri(event.url)
{:ok, Map.merge(activity_data, audience), actor, event}
end
end
def actor(%Event{organizer_actor: %Actor{} = actor}), do: actor
def actor(%Event{organizer_actor_id: organizer_actor_id}),
do: Actors.get_actor(organizer_actor_id)
def actor(_), do: nil
def group_actor(%Event{attributed_to: %Actor{} = group}), do: group
def group_actor(%Event{attributed_to_id: attributed_to_id}) when not is_nil(attributed_to_id),
do: Actors.get_actor(attributed_to_id)
def group_actor(_), do: nil
def join(%Event{} = event, %Actor{} = actor, _local, additional) do
with {:maximum_attendee_capacity, true} <-
{:maximum_attendee_capacity, check_attendee_capacity(event)},
role <-
additional
|> Map.get(:metadata, %{})
|> Map.get(:role, Mobilizon.Events.get_default_participant_role(event)),
{:ok, %Participant{} = participant} <-
Mobilizon.Events.create_participant(%{
role: role,
event_id: event.id,
actor_id: actor.id,
url: Map.get(additional, :url),
metadata:
additional
|> Map.get(:metadata, %{})
|> Map.update(:message, nil, &String.trim(HTML.strip_tags(&1)))
}),
join_data <- Convertible.model_to_as(participant),
audience <-
Audience.calculate_to_and_cc_from_mentions(participant) do
approve_if_default_role_is_participant(
event,
Map.merge(join_data, audience),
participant,
role
)
else
{:maximum_attendee_capacity, err} ->
{:maximum_attendee_capacity, err}
end
end
defp check_attendee_capacity(%Event{options: options} = event) do
with maximum_attendee_capacity <-
Map.get(options, :maximum_attendee_capacity) || 0 do
maximum_attendee_capacity == 0 ||
Mobilizon.Events.count_participant_participants(event.id) < maximum_attendee_capacity
end
end
# Set the participant to approved if the default role for new participants is :participant
defp approve_if_default_role_is_participant(event, activity_data, participant, role) do
if event.local do
cond do
Mobilizon.Events.get_default_participant_role(event) === :participant &&
role == :participant ->
{:accept,
ActivityPub.accept(
:join,
participant,
true,
%{"actor" => event.organizer_actor.url}
)}
Mobilizon.Events.get_default_participant_role(event) === :not_approved &&
role == :not_approved ->
Scheduler.pending_participation_notification(event)
{:ok, activity_data, participant}
true ->
{:ok, activity_data, participant}
end
else
{:ok, activity_data, participant}
end
end
# Prepare and sanitize arguments for events
defp prepare_args_for_event(args) do
# If title is not set: we are not updating it
args =
if Map.has_key?(args, :title) && !is_nil(args.title),
do: Map.update(args, :title, "", &String.trim/1),
else: args
# If we've been given a description (we might not get one if updating)
# sanitize it, HTML it, and extract tags & mentions from it
args =
if Map.has_key?(args, :description) && !is_nil(args.description) do
{description, mentions, tags} =
APIUtils.make_content_html(
String.trim(args.description),
Map.get(args, :tags, []),
"text/html"
)
mentions = ConverterUtils.fetch_mentions(Map.get(args, :mentions, []) ++ mentions)
Map.merge(args, %{
description: description,
mentions: mentions,
tags: tags
})
else
args
end
# Check that we can only allow anonymous participation if our instance allows it
{_, options} =
Map.get_and_update(
Map.get(args, :options, %{anonymous_participation: false}),
:anonymous_participation,
fn value ->
{value, value && Mobilizon.Config.anonymous_participation?()}
end
)
args = Map.put(args, :options, options)
Map.update(args, :tags, [], &ConverterUtils.fetch_tags/1)
end
end

View File

@ -0,0 +1,93 @@
defmodule Mobilizon.Federation.ActivityPub.Types.Posts do
@moduledoc false
alias Mobilizon.{Actors, Posts}
alias Mobilizon.Actors.Actor
alias Mobilizon.Federation.ActivityPub.Types.Entity
alias Mobilizon.Federation.ActivityStream.Converter.Utils, as: ConverterUtils
alias Mobilizon.Federation.ActivityStream.Convertible
alias Mobilizon.Posts.Post
require Logger
import Mobilizon.Federation.ActivityPub.Utils, only: [make_create_data: 2, make_update_data: 2]
@behaviour Entity
@impl Entity
def create(args, additional) do
with args <- Map.update(args, :tags, [], &ConverterUtils.fetch_tags/1),
{:ok, %Post{attributed_to_id: group_id, author_id: creator_id} = post} <-
Posts.create_post(args),
{:ok, %Actor{} = group} <- Actors.get_group_by_actor_id(group_id),
%Actor{url: creator_url} = creator <- Actors.get_actor(creator_id),
post_as_data <-
Convertible.model_to_as(%{post | attributed_to: group, author: creator}),
audience <- %{
"to" => [group.members_url],
"cc" => [],
"actor" => creator_url,
"attributedTo" => [creator_url]
} do
create_data = make_create_data(post_as_data, Map.merge(audience, additional))
{:ok, post, create_data}
else
err ->
Logger.debug(inspect(err))
err
end
end
@impl Entity
def update(%Post{} = post, args, additional) do
with args <- Map.update(args, :tags, [], &ConverterUtils.fetch_tags/1),
{:ok, %Post{attributed_to_id: group_id, author_id: creator_id} = post} <-
Posts.update_post(post, args),
{:ok, true} <- Cachex.del(:activity_pub, "post_#{post.slug}"),
{:ok, %Actor{} = group} <- Actors.get_group_by_actor_id(group_id),
%Actor{url: creator_url} = creator <- Actors.get_actor(creator_id),
post_as_data <-
Convertible.model_to_as(%{post | attributed_to: group, author: creator}),
audience <- %{
"to" => [group.members_url],
"cc" => [],
"actor" => creator_url,
"attributedTo" => [creator_url]
} do
update_data = make_update_data(post_as_data, Map.merge(audience, additional))
{:ok, post, update_data}
else
err ->
Logger.debug(inspect(err))
err
end
end
@impl Entity
def delete(
%Post{
url: url,
attributed_to: %Actor{url: group_url}
} = post,
%Actor{url: actor_url} = actor,
_local
) do
activity_data = %{
"actor" => actor_url,
"type" => "Delete",
"object" => Convertible.model_to_as(post),
"id" => url <> "/delete",
"to" => [group_url]
}
with {:ok, _post} <- Posts.delete_post(post),
{:ok, true} <- Cachex.del(:activity_pub, "post_#{post.slug}") do
{:ok, activity_data, actor, post}
end
end
def actor(%Post{author_id: author_id}),
do: Actors.get_actor(author_id)
def group_actor(%Post{attributed_to_id: attributed_to_id}),
do: Actors.get_actor(attributed_to_id)
end

View File

@ -0,0 +1,43 @@
defmodule Mobilizon.Federation.ActivityPub.Types.Reports do
@moduledoc false
alias Mobilizon.{Actors, Discussions, Reports}
alias Mobilizon.Actors.Actor
alias Mobilizon.Federation.ActivityStream.Convertible
alias Mobilizon.Reports.Report
alias Mobilizon.Service.Formatter.HTML
require Logger
def flag(args, local \\ false, _additional \\ %{}) do
with {:build_args, args} <- {:build_args, prepare_args_for_report(args)},
{:create_report, {:ok, %Report{} = report}} <-
{:create_report, Reports.create_report(args)},
report_as_data <- Convertible.model_to_as(report),
cc <- if(local, do: [report.reported.url], else: []),
report_as_data <- Map.merge(report_as_data, %{"to" => [], "cc" => cc}) do
{report, report_as_data}
end
end
defp prepare_args_for_report(args) do
with {:reporter, %Actor{} = reporter_actor} <-
{:reporter, Actors.get_actor!(args.reporter_id)},
{:reported, %Actor{} = reported_actor} <-
{:reported, Actors.get_actor!(args.reported_id)},
content <- HTML.strip_tags(args.content),
event <- Discussions.get_comment(Map.get(args, :event_id)),
{:get_report_comments, comments} <-
{:get_report_comments,
Discussions.list_comments_by_actor_and_ids(
reported_actor.id,
Map.get(args, :comments_ids, [])
)} do
Map.merge(args, %{
reporter: reporter_actor,
reported: reported_actor,
content: content,
event: event,
comments: comments
})
end
end
end

View File

@ -0,0 +1,157 @@
defmodule Mobilizon.Federation.ActivityPub.Types.Resources do
@moduledoc false
alias Mobilizon.{Actors, Resources}
alias Mobilizon.Actors.Actor
alias Mobilizon.Federation.ActivityPub.Types.Entity
alias Mobilizon.Federation.ActivityStream.Convertible
alias Mobilizon.Resources.Resource
alias Mobilizon.Service.RichMedia.Parser
require Logger
import Mobilizon.Federation.ActivityPub.Utils,
only: [make_create_data: 2, make_update_data: 2, make_add_data: 3, make_move_data: 4]
@behaviour Entity
@impl Entity
def create(%{type: type} = args, additional) do
args =
case type do
:folder ->
args
_ ->
case Parser.parse(Map.get(args, :resource_url)) do
{:ok, metadata} ->
Map.put(args, :metadata, metadata)
_ ->
args
end
end
with {:ok,
%Resource{actor_id: group_id, creator_id: creator_id, parent_id: parent_id} = resource} <-
Resources.create_resource(args),
{:ok, %Actor{} = group} <- Actors.get_group_by_actor_id(group_id),
%Actor{url: creator_url} = creator <- Actors.get_actor(creator_id),
resource_as_data <-
Convertible.model_to_as(%{resource | actor: group, creator: creator}),
audience <- %{
"to" => [group.members_url],
"cc" => [],
"actor" => creator_url,
"attributedTo" => [creator_url]
} do
create_data =
case parent_id do
nil ->
make_create_data(resource_as_data, Map.merge(audience, additional))
parent_id ->
# In case the resource has a parent we don't `Create` the resource but `Add` it to an existing resource
parent = Resources.get_resource(parent_id)
make_add_data(resource_as_data, parent, Map.merge(audience, additional))
end
{:ok, resource, create_data}
else
err ->
Logger.debug(inspect(err))
err
end
end
@impl Entity
def update(%Resource{} = old_resource, %{parent_id: _parent_id} = args, additional) do
move(old_resource, args, additional)
end
# Simple rename
def update(%Resource{} = old_resource, %{title: title} = _args, additional) do
with {:ok, %Resource{actor_id: group_id, creator_id: creator_id} = resource} <-
Resources.update_resource(old_resource, %{title: title}),
{:ok, %Actor{} = group} <- Actors.get_group_by_actor_id(group_id),
%Actor{url: creator_url} <- Actors.get_actor(creator_id),
resource_as_data <-
Convertible.model_to_as(%{resource | actor: group}),
audience <- %{
"to" => [group.members_url],
"cc" => [],
"actor" => creator_url,
"attributedTo" => [creator_url]
},
update_data <-
make_update_data(resource_as_data, Map.merge(audience, additional)) do
{:ok, resource, update_data}
else
err ->
Logger.debug(inspect(err))
err
end
end
def move(
%Resource{parent_id: old_parent_id} = old_resource,
%{parent_id: _new_parent_id} = args,
additional
) do
with {:ok,
%Resource{actor_id: group_id, creator_id: creator_id, parent_id: new_parent_id} =
resource} <-
Resources.update_resource(old_resource, args),
old_parent <- Resources.get_resource(old_parent_id),
new_parent <- Resources.get_resource(new_parent_id),
{:ok, %Actor{} = group} <- Actors.get_group_by_actor_id(group_id),
%Actor{url: creator_url} <- Actors.get_actor(creator_id),
resource_as_data <-
Convertible.model_to_as(%{resource | actor: group}),
audience <- %{
"to" => [group.members_url],
"cc" => [],
"actor" => creator_url,
"attributedTo" => [creator_url]
},
move_data <-
make_move_data(
resource_as_data,
old_parent,
new_parent,
Map.merge(audience, additional)
) do
{:ok, resource, move_data}
else
err ->
Logger.debug(inspect(err))
err
end
end
@impl Entity
def delete(
%Resource{url: url, actor: %Actor{url: group_url, members_url: members_url}} = resource,
%Actor{url: actor_url} = actor,
_local
) do
Logger.debug("Building Delete Resource activity")
activity_data = %{
"actor" => actor_url,
"attributedTo" => [group_url],
"type" => "Delete",
"object" => Convertible.model_to_as(resource),
"id" => url <> "/delete",
"to" => [members_url]
}
with {:ok, _resource} <- Resources.delete_resource(resource),
{:ok, true} <- Cachex.del(:activity_pub, "resource_#{resource.id}") do
{:ok, activity_data, actor, resource}
end
end
def actor(%Resource{creator_id: creator_id}),
do: Actors.get_actor(creator_id)
def group_actor(%Resource{actor_id: actor_id}), do: Actors.get_actor(actor_id)
end

View File

@ -0,0 +1,69 @@
defmodule Mobilizon.Federation.ActivityPub.Types.TodoLists do
@moduledoc false
alias Mobilizon.{Actors, Todos}
alias Mobilizon.Actors.Actor
alias Mobilizon.Federation.ActivityPub.Types.Entity
alias Mobilizon.Federation.ActivityStream
alias Mobilizon.Federation.ActivityStream.Convertible
alias Mobilizon.Todos.TodoList
import Mobilizon.Federation.ActivityPub.Utils, only: [make_create_data: 2, make_update_data: 2]
require Logger
@behaviour Entity
@impl Entity
@spec create(map(), map()) :: {:ok, map()}
def create(args, additional) do
with {:ok, %TodoList{actor_id: group_id} = todo_list} <- Todos.create_todo_list(args),
{:ok, %Actor{} = group} <- Actors.get_group_by_actor_id(group_id),
todo_list_as_data <- Convertible.model_to_as(%{todo_list | actor: group}),
audience <- %{"to" => [group.members_url], "cc" => []},
create_data <-
make_create_data(todo_list_as_data, Map.merge(audience, additional)) do
{:ok, todo_list, create_data}
end
end
@impl Entity
@spec update(TodoList.t(), map, map) :: {:ok, TodoList.t(), Activity.t()} | any
def update(%TodoList{} = old_todo_list, args, additional) do
with {:ok, %TodoList{actor_id: group_id} = todo_list} <-
Todos.update_todo_list(old_todo_list, args),
{:ok, %Actor{} = group} <- Actors.get_group_by_actor_id(group_id),
todo_list_as_data <-
Convertible.model_to_as(%{todo_list | actor: group}),
audience <- %{"to" => [group.members_url], "cc" => []},
update_data <-
make_update_data(todo_list_as_data, Map.merge(audience, additional)) do
{:ok, todo_list, update_data}
end
end
@impl Entity
@spec delete(TodoList.t(), Actor.t(), boolean()) ::
{:ok, ActivityStream.t(), Actor.t(), TodoList.t()}
def delete(
%TodoList{url: url, actor: %Actor{url: group_url}} = todo_list,
%Actor{url: actor_url} = actor,
_local
) do
Logger.debug("Building Delete TodoList activity")
activity_data = %{
"actor" => actor_url,
"type" => "Delete",
"object" => Convertible.model_to_as(url),
"id" => url <> "/delete",
"to" => [group_url]
}
with {:ok, _todo_list} <- Todos.delete_todo_list(todo_list),
{:ok, true} <- Cachex.del(:activity_pub, "todo_list_#{todo_list.id}") do
{:ok, activity_data, actor, todo_list}
end
end
def actor(%TodoList{}), do: nil
def group_actor(%TodoList{actor_id: actor_id}), do: Actors.get_actor(actor_id)
end

View File

@ -0,0 +1,80 @@
defmodule Mobilizon.Federation.ActivityPub.Types.Todos do
@moduledoc false
alias Mobilizon.{Actors, Todos}
alias Mobilizon.Actors.Actor
alias Mobilizon.Federation.ActivityPub.Types.Entity
alias Mobilizon.Federation.ActivityStream.Convertible
alias Mobilizon.Todos.{Todo, TodoList}
import Mobilizon.Federation.ActivityPub.Utils, only: [make_create_data: 2, make_update_data: 2]
require Logger
@behaviour Entity
@impl Entity
@spec create(map(), map()) :: {:ok, map()}
def create(args, additional) do
with {:ok, %Todo{todo_list_id: todo_list_id, creator_id: creator_id} = todo} <-
Todos.create_todo(args),
%TodoList{actor_id: group_id} = todo_list <- Todos.get_todo_list(todo_list_id),
%Actor{} = creator <- Actors.get_actor(creator_id),
{:ok, %Actor{} = group} <- Actors.get_group_by_actor_id(group_id),
todo <- %{todo | todo_list: %{todo_list | actor: group}, creator: creator},
todo_as_data <-
Convertible.model_to_as(todo),
audience <- %{"to" => [group.members_url], "cc" => []},
create_data <-
make_create_data(todo_as_data, Map.merge(audience, additional)) do
{:ok, todo, create_data}
end
end
@impl Entity
@spec update(Todo.t(), map, map) :: {:ok, Todo.t(), Activity.t()} | any
def update(%Todo{} = old_todo, args, additional) do
with {:ok, %Todo{todo_list_id: todo_list_id} = todo} <- Todos.update_todo(old_todo, args),
%TodoList{actor_id: group_id} = todo_list <- Todos.get_todo_list(todo_list_id),
{:ok, %Actor{} = group} <- Actors.get_group_by_actor_id(group_id),
todo_as_data <-
Convertible.model_to_as(%{todo | todo_list: %{todo_list | actor: group}}),
audience <- %{"to" => [group.members_url], "cc" => []},
update_data <-
make_update_data(todo_as_data, Map.merge(audience, additional)) do
{:ok, todo, update_data}
end
end
@impl Entity
@spec delete(Todo.t(), Actor.t(), boolean()) :: {:ok, ActivityStream.t(), Actor.t(), Todo.t()}
def delete(
%Todo{url: url, creator: %Actor{url: group_url}} = todo,
%Actor{url: actor_url} = actor,
_local
) do
Logger.debug("Building Delete Todo activity")
activity_data = %{
"actor" => actor_url,
"type" => "Delete",
"object" => Convertible.model_to_as(url),
"id" => url <> "/delete",
"to" => [group_url]
}
with {:ok, _todo} <- Todos.delete_todo(todo),
{:ok, true} <- Cachex.del(:activity_pub, "todo_#{todo.id}") do
{:ok, activity_data, actor, todo}
end
end
def actor(%Todo{creator_id: creator_id}), do: Actors.get_actor(creator_id)
def group_actor(%Todo{todo_list_id: todo_list_id}) do
case Todos.get_todo_list(todo_list_id) do
%TodoList{actor_id: group_id} ->
Actors.get_actor(group_id)
_ ->
nil
end
end
end

View File

@ -0,0 +1,14 @@
defmodule Mobilizon.Federation.ActivityPub.Types.Tombstones do
@moduledoc false
alias Mobilizon.{Actors, Tombstone}
alias Mobilizon.Actors.Actor
def actor(%Tombstone{actor: %Actor{id: actor_id}}), do: Actors.get_actor(actor_id)
def actor(%Tombstone{actor_id: actor_id}) when not is_nil(actor_id),
do: Actors.get_actor(actor_id)
def actor(_), do: nil
def group_actor(_), do: nil
end

View File

@ -8,13 +8,16 @@ defmodule Mobilizon.Federation.ActivityPub.Utils do
Various ActivityPub related utils. Various ActivityPub related utils.
""" """
alias Mobilizon.Actors
alias Mobilizon.Actors.Actor alias Mobilizon.Actors.Actor
alias Mobilizon.Media.Picture alias Mobilizon.Media.Picture
alias Mobilizon.Federation.ActivityPub alias Mobilizon.Federation.ActivityPub
alias Mobilizon.Federation.ActivityPub.{Activity, Federator, Relay} alias Mobilizon.Federation.ActivityPub.{Activity, Federator, Relay}
alias Mobilizon.Federation.ActivityPub.Types.Ownable
alias Mobilizon.Federation.ActivityStream.Converter alias Mobilizon.Federation.ActivityStream.Converter
alias Mobilizon.Federation.HTTPSignatures alias Mobilizon.Federation.HTTPSignatures
alias Mobilizon.Web.Endpoint
require Logger require Logger
@ -114,6 +117,53 @@ defmodule Mobilizon.Federation.ActivityPub.Utils do
def maybe_federate(_), do: :ok def maybe_federate(_), do: :ok
@doc """
Applies to activities sent by group members from outside this instance to a group of this instance,
we then need to relay (`Announce`) the object to other members on other instances.
"""
def maybe_relay_if_group_activity(activity, attributed_to \\ nil)
def maybe_relay_if_group_activity(
%Activity{local: false, data: %{"object" => object}},
_attributed_to
)
when is_map(object) do
do_maybe_relay_if_group_activity(object, object["attributedTo"])
end
# When doing a delete the object is just an AP ID string, so we pass the attributed_to actor as well
def maybe_relay_if_group_activity(
%Activity{local: false, data: %{"object" => object}},
%Actor{url: attributed_to_url}
)
when is_binary(object) do
do_maybe_relay_if_group_activity(object, attributed_to_url)
end
def maybe_relay_if_group_activity(_, _), do: :ok
defp do_maybe_relay_if_group_activity(object, attributed_to) when not is_nil(attributed_to) do
id = "#{Endpoint.url()}/announces/#{Ecto.UUID.generate()}"
case Actors.get_local_group_by_url(attributed_to) do
%Actor{} = group ->
case ActivityPub.announce(group, object, id, true, false) do
{:ok, _activity, _object} ->
Logger.info("Forwarded activity to external members of the group")
:ok
_ ->
Logger.info("Failed to forward activity to external members of the group")
:error
end
_ ->
:ok
end
end
defp do_maybe_relay_if_group_activity(_, _), do: :ok
@spec remote_actors(list(String.t())) :: list(Actor.t()) @spec remote_actors(list(String.t())) :: list(Actor.t())
def remote_actors(recipients) do def remote_actors(recipients) do
recipients recipients
@ -135,7 +185,7 @@ defmodule Mobilizon.Federation.ActivityPub.Utils do
Adds an id and a published data if they aren't there, Adds an id and a published data if they aren't there,
also adds it to an included object also adds it to an included object
""" """
def lazy_put_activity_defaults(map) do def lazy_put_activity_defaults(%{"object" => _object} = map) do
if is_map(map["object"]) do if is_map(map["object"]) do
object = lazy_put_object_defaults(map["object"]) object = lazy_put_object_defaults(map["object"])
%{map | "object" => object} %{map | "object" => object}
@ -147,7 +197,7 @@ defmodule Mobilizon.Federation.ActivityPub.Utils do
@doc """ @doc """
Adds an id and published date if they aren't there. Adds an id and published date if they aren't there.
""" """
def lazy_put_object_defaults(map) do def lazy_put_object_defaults(map) when is_map(map) do
Map.put_new_lazy(map, "published", &make_date/0) Map.put_new_lazy(map, "published", &make_date/0)
end end
@ -175,25 +225,49 @@ defmodule Mobilizon.Federation.ActivityPub.Utils do
@doc """ @doc """
Checks that an incoming AP object's actor matches the domain it came from. Checks that an incoming AP object's actor matches the domain it came from.
Takes the actor or attributedTo attributes (considers only the first elem if they're an array)
""" """
def origin_check?(id, %{"actor" => actor, "attributedTo" => _attributed_to} = params)
when not is_nil(actor) and actor != "" do
params = Map.delete(params, "attributedTo")
origin_check?(id, params)
end
def origin_check?(id, %{"attributedTo" => actor} = params) do def origin_check?(id, %{"attributedTo" => actor} = params) do
params = params |> Map.put("actor", actor) |> Map.delete("attributedTo") params = params |> Map.put("actor", actor) |> Map.delete("attributedTo")
origin_check?(id, params) origin_check?(id, params)
end end
def origin_check?(id, %{"actor" => actor} = params) when not is_nil(actor) do def origin_check?(id, %{"actor" => actor} = params)
id_uri = URI.parse(id) when not is_nil(actor) and is_list(actor) and length(actor) > 0 do
actor_uri = URI.parse(get_actor(params)) origin_check?(id, Map.put(params, "actor", hd(actor)))
compare_uris?(actor_uri, id_uri)
end end
def origin_check?(_id, %{"actor" => nil}), do: false def origin_check?(id, %{"actor" => actor} = params)
when not is_nil(actor) do
actor = get_actor(params)
Logger.debug("Performing origin check on #{id} and #{actor} URIs")
compare_origins?(id, actor)
end
def origin_check?(_id, _data), do: false def origin_check?(_id, %{"type" => type} = _params) when type in ["Actor", "Group"], do: true
def origin_check?(_id, %{"actor" => nil} = _args), do: false
def origin_check?(_id, _args), do: false
@spec compare_origins?(String.t(), String.t()) :: boolean()
def compare_origins?(url_1, url_2) when is_binary(url_1) and is_binary(url_2) do
uri_1 = URI.parse(url_1)
uri_2 = URI.parse(url_2)
compare_uris?(uri_1, uri_2)
end
defp compare_uris?(%URI{} = id_uri, %URI{} = other_uri), do: id_uri.host == other_uri.host defp compare_uris?(%URI{} = id_uri, %URI{} = other_uri), do: id_uri.host == other_uri.host
@spec origin_check_from_id?(String.t(), String.t()) :: boolean()
def origin_check_from_id?(id, other_id) when is_binary(other_id) do def origin_check_from_id?(id, other_id) when is_binary(other_id) do
id_uri = URI.parse(id) id_uri = URI.parse(id)
other_uri = URI.parse(other_id) other_uri = URI.parse(other_id)
@ -201,9 +275,20 @@ defmodule Mobilizon.Federation.ActivityPub.Utils do
compare_uris?(id_uri, other_uri) compare_uris?(id_uri, other_uri)
end end
@spec origin_check_from_id?(String.t(), map()) :: boolean()
def origin_check_from_id?(id, %{"id" => other_id} = _params) when is_binary(other_id), def origin_check_from_id?(id, %{"id" => other_id} = _params) when is_binary(other_id),
do: origin_check_from_id?(id, other_id) do: origin_check_from_id?(id, other_id)
def activity_actor_is_group_member?(%Actor{id: actor_id}, object) do
case Ownable.group_actor(object) do
%Actor{type: :Group, id: group_id} ->
Actors.is_member?(actor_id, group_id)
_ ->
false
end
end
@doc """ @doc """
Save picture data from %Plug.Upload{} and return AS Link data. Save picture data from %Plug.Upload{} and return AS Link data.
""" """
@ -274,7 +359,7 @@ defmodule Mobilizon.Federation.ActivityPub.Utils do
activity_id, activity_id,
public public
) )
when type in ["Note", "Event", "ResourceCollection", "Document"] do when type in ["Note", "Event", "ResourceCollection", "Document", "Todo"] do
do_make_announce_data( do_make_announce_data(
actor, actor,
object_actor_url, object_actor_url,
@ -367,6 +452,7 @@ defmodule Mobilizon.Federation.ActivityPub.Utils do
"type" => "Create", "type" => "Create",
"to" => object["to"], "to" => object["to"],
"cc" => object["cc"], "cc" => object["cc"],
"attributedTo" => object["attributedTo"] || object["actor"],
"actor" => object["actor"], "actor" => object["actor"],
"object" => object, "object" => object,
"published" => make_date(), "published" => make_date(),
@ -494,7 +580,7 @@ defmodule Mobilizon.Federation.ActivityPub.Utils do
@doc """ @doc """
Sign a request with the instance Relay actor. Sign a request with the instance Relay actor.
""" """
@spec sign_fetch_relay(List.t(), String.t(), String.t()) :: List.t() @spec sign_fetch_relay(Enum.t(), String.t(), String.t()) :: Enum.t()
def sign_fetch_relay(headers, id, date) do def sign_fetch_relay(headers, id, date) do
with %Actor{} = actor <- Relay.get_actor() do with %Actor{} = actor <- Relay.get_actor() do
sign_fetch(headers, actor, id, date) sign_fetch(headers, actor, id, date)
@ -504,7 +590,7 @@ defmodule Mobilizon.Federation.ActivityPub.Utils do
@doc """ @doc """
Sign a request with an actor. Sign a request with an actor.
""" """
@spec sign_fetch(List.t(), Actor.t(), String.t(), String.t()) :: List.t() @spec sign_fetch(Enum.t(), Actor.t(), String.t(), String.t()) :: Enum.t()
def sign_fetch(headers, actor, id, date) do def sign_fetch(headers, actor, id, date) do
if Mobilizon.Config.get([:activitypub, :sign_object_fetches]) do if Mobilizon.Config.get([:activitypub, :sign_object_fetches]) do
headers ++ make_signature(actor, id, date) headers ++ make_signature(actor, id, date)
@ -516,7 +602,7 @@ defmodule Mobilizon.Federation.ActivityPub.Utils do
@doc """ @doc """
Add the Date header to the request if we sign object fetches Add the Date header to the request if we sign object fetches
""" """
@spec maybe_date_fetch(List.t(), String.t()) :: List.t() @spec maybe_date_fetch(Enum.t(), String.t()) :: Enum.t()
def maybe_date_fetch(headers, date) do def maybe_date_fetch(headers, date) do
if Mobilizon.Config.get([:activitypub, :sign_object_fetches]) do if Mobilizon.Config.get([:activitypub, :sign_object_fetches]) do
headers ++ [{:Date, date}] headers ++ [{:Date, date}]
@ -524,4 +610,15 @@ defmodule Mobilizon.Federation.ActivityPub.Utils do
headers headers
end end
end end
def check_for_actor_key_rotation(%Actor{} = actor) do
if Actors.should_rotate_actor_key(actor) do
Actors.schedule_key_rotation(
actor,
Application.get_env(:mobilizon, :activitypub)[:actor_key_rotation_delay]
)
end
:ok
end
end end

View File

@ -8,7 +8,7 @@ defmodule Mobilizon.Federation.ActivityPub.Visibility do
Utility functions related to content visibility Utility functions related to content visibility
""" """
alias Mobilizon.Conversations.Comment alias Mobilizon.Discussions.Comment
alias Mobilizon.Federation.ActivityPub.Activity alias Mobilizon.Federation.ActivityPub.Activity

View File

@ -0,0 +1,7 @@
defmodule Mobilizon.Federation.ActivityStream do
@moduledoc """
The ActivityStream Type
"""
@type t :: map()
end

View File

@ -49,7 +49,7 @@ defmodule Mobilizon.Federation.ActivityStream.Converter.Actor do
banner: banner, banner: banner,
name: data["name"], name: data["name"],
preferred_username: data["preferredUsername"], preferred_username: data["preferredUsername"],
summary: data["summary"], summary: data["summary"] || "",
keys: data["publicKey"]["publicKeyPem"], keys: data["publicKey"]["publicKeyPem"],
inbox_url: data["inbox"], inbox_url: data["inbox"],
outbox_url: data["outbox"], outbox_url: data["outbox"],
@ -57,6 +57,10 @@ defmodule Mobilizon.Federation.ActivityStream.Converter.Actor do
followers_url: data["followers"], followers_url: data["followers"],
members_url: data["members"], members_url: data["members"],
resources_url: data["resources"], resources_url: data["resources"],
todos_url: data["todos"],
events_url: data["events"],
posts_url: data["posts"],
discussions_url: data["discussions"],
shared_inbox_url: data["endpoints"]["sharedInbox"], shared_inbox_url: data["endpoints"]["sharedInbox"],
domain: URI.parse(data["id"]).host, domain: URI.parse(data["id"]).host,
manually_approves_followers: data["manuallyApprovesFollowers"], manually_approves_followers: data["manuallyApprovesFollowers"],
@ -77,12 +81,15 @@ defmodule Mobilizon.Federation.ActivityStream.Converter.Actor do
"type" => actor.type, "type" => actor.type,
"preferredUsername" => actor.preferred_username, "preferredUsername" => actor.preferred_username,
"name" => actor.name, "name" => actor.name,
"summary" => actor.summary, "summary" => actor.summary || "",
"following" => actor.following_url, "following" => actor.following_url,
"followers" => actor.followers_url, "followers" => actor.followers_url,
"members" => actor.members_url, "members" => actor.members_url,
"resources" => actor.resources_url, "resources" => actor.resources_url,
"todos" => actor.todos_url, "todos" => actor.todos_url,
"posts" => actor.posts_url,
"events" => actor.events_url,
"discussions" => actor.discussions_url,
"inbox" => actor.inbox_url, "inbox" => actor.inbox_url,
"outbox" => actor.outbox_url, "outbox" => actor.outbox_url,
"url" => actor.url, "url" => actor.url,

View File

@ -7,22 +7,30 @@ defmodule Mobilizon.Federation.ActivityStream.Converter.Comment do
""" """
alias Mobilizon.Actors.Actor alias Mobilizon.Actors.Actor
alias Mobilizon.Conversations.Comment, as: CommentModel alias Mobilizon.Discussions
alias Mobilizon.Discussions.Comment, as: CommentModel
alias Mobilizon.Discussions.Discussion
alias Mobilizon.Events.Event alias Mobilizon.Events.Event
alias Mobilizon.Tombstone, as: TombstoneModel
alias Mobilizon.Federation.ActivityPub alias Mobilizon.Federation.ActivityPub
alias Mobilizon.Federation.ActivityPub.Visibility alias Mobilizon.Federation.ActivityPub.Visibility
alias Mobilizon.Federation.ActivityStream.{Converter, Convertible} alias Mobilizon.Federation.ActivityStream.{Converter, Convertible}
alias Mobilizon.Federation.ActivityStream.Converter.Utils, as: ConverterUtils alias Mobilizon.Federation.ActivityStream.Converter.Comment, as: CommentConverter
alias Mobilizon.Tombstone, as: TombstoneModel
import Mobilizon.Federation.ActivityStream.Converter.Utils,
only: [
fetch_tags: 1,
fetch_mentions: 1,
build_tags: 1,
build_mentions: 1,
maybe_fetch_actor_and_attributed_to_id: 1
]
require Logger require Logger
@behaviour Converter @behaviour Converter
defimpl Convertible, for: CommentModel do defimpl Convertible, for: CommentModel do
alias Mobilizon.Federation.ActivityStream.Converter.Comment, as: CommentConverter
defdelegate model_to_as(comment), to: CommentConverter defdelegate model_to_as(comment), to: CommentConverter
end end
@ -35,61 +43,35 @@ defmodule Mobilizon.Federation.ActivityStream.Converter.Comment do
Logger.debug("We're converting raw ActivityStream data to a comment entity") Logger.debug("We're converting raw ActivityStream data to a comment entity")
Logger.debug(inspect(object)) Logger.debug(inspect(object))
with author_url <- Map.get(object, "actor") || Map.get(object, "attributedTo"), with {%Actor{id: actor_id, domain: actor_domain}, attributed_to} <-
{:ok, %Actor{id: actor_id, domain: domain, suspended: false}} <- maybe_fetch_actor_and_attributed_to_id(object),
ActivityPub.get_or_fetch_actor_by_url(author_url), {:tags, tags} <- {:tags, fetch_tags(Map.get(object, "tag", []))},
{:tags, tags} <- {:tags, ConverterUtils.fetch_tags(Map.get(object, "tag", []))},
{:mentions, mentions} <- {:mentions, mentions} <-
{:mentions, ConverterUtils.fetch_mentions(Map.get(object, "tag", []))} do {:mentions, fetch_mentions(Map.get(object, "tag", []))},
discussion <-
Discussions.get_discussion_by_url(Map.get(object, "context")) do
Logger.debug("Inserting full comment") Logger.debug("Inserting full comment")
Logger.debug(inspect(object)) Logger.debug(inspect(object))
data = %{ data = %{
text: object["content"], text: object["content"],
url: object["id"], url: object["id"],
# Will be used in conversations, ignored in basic comments
title: object["name"],
context: object["context"],
actor_id: actor_id, actor_id: actor_id,
attributed_to_id: if(is_nil(attributed_to), do: nil, else: attributed_to.id),
in_reply_to_comment_id: nil, in_reply_to_comment_id: nil,
event_id: nil, event_id: nil,
uuid: object["uuid"], uuid: object["uuid"],
discussion_id: if(is_nil(discussion), do: nil, else: discussion.id),
tags: tags, tags: tags,
mentions: mentions, mentions: mentions,
local: is_nil(domain), local: is_nil(actor_domain),
visibility: if(Visibility.is_public?(object), do: :public, else: :private) visibility: if(Visibility.is_public?(object), do: :public, else: :private)
} }
# We fetch the parent object maybe_fetch_parent_object(object, data)
Logger.debug("We're fetching the parent object")
if Map.has_key?(object, "inReplyTo") && object["inReplyTo"] != nil &&
object["inReplyTo"] != "" do
Logger.debug(fn -> "Object has inReplyTo #{object["inReplyTo"]}" end)
case ActivityPub.fetch_object_from_url(object["inReplyTo"]) do
# Reply to an event (Event)
{:ok, %Event{id: id}} ->
Logger.debug("Parent object is an event")
data |> Map.put(:event_id, id)
# Reply to a comment (Comment)
{:ok, %CommentModel{id: id} = comment} ->
Logger.debug("Parent object is another comment")
data
|> Map.put(:in_reply_to_comment_id, id)
|> Map.put(:origin_comment_id, comment |> CommentModel.get_thread_id())
|> Map.put(:event_id, comment.event_id)
# Anything else is kind of a MP
{:error, parent} ->
Logger.warn("Parent object is something we don't handle")
Logger.debug(inspect(parent))
data
end
else
Logger.debug("No parent object for this comment")
data
end
else else
{:ok, %Actor{suspended: true}} -> {:ok, %Actor{suspended: true}} ->
:error :error
@ -102,10 +84,7 @@ defmodule Mobilizon.Federation.ActivityStream.Converter.Comment do
@impl Converter @impl Converter
@spec model_to_as(CommentModel.t()) :: map @spec model_to_as(CommentModel.t()) :: map
def model_to_as(%CommentModel{deleted_at: nil} = comment) do def model_to_as(%CommentModel{deleted_at: nil} = comment) do
to = to = determine_to(comment)
if comment.visibility == :public,
do: ["https://www.w3.org/ns/activitystreams#Public"],
else: [comment.actor.followers_url]
object = %{ object = %{
"type" => "Note", "type" => "Note",
@ -114,13 +93,19 @@ defmodule Mobilizon.Federation.ActivityStream.Converter.Comment do
"content" => comment.text, "content" => comment.text,
"mediaType" => "text/html", "mediaType" => "text/html",
"actor" => comment.actor.url, "actor" => comment.actor.url,
"attributedTo" => comment.actor.url, "attributedTo" =>
if(is_nil(comment.attributed_to), do: nil, else: comment.attributed_to.url) ||
comment.actor.url,
"uuid" => comment.uuid, "uuid" => comment.uuid,
"id" => comment.url, "id" => comment.url,
"tag" => "tag" => build_mentions(comment.mentions) ++ build_tags(comment.tags)
ConverterUtils.build_mentions(comment.mentions) ++ ConverterUtils.build_tags(comment.tags)
} }
object =
if comment.discussion_id,
do: Map.put(object, "context", comment.discussion.url),
else: object
cond do cond do
comment.in_reply_to_comment -> comment.in_reply_to_comment ->
Map.put(object, "inReplyTo", comment.in_reply_to_comment.url) Map.put(object, "inReplyTo", comment.in_reply_to_comment.url)
@ -133,15 +118,78 @@ defmodule Mobilizon.Federation.ActivityStream.Converter.Comment do
end end
end end
@impl Converter
@spec model_to_as(CommentModel.t()) :: map
@doc """ @doc """
A "soft-deleted" comment is a tombstone A "soft-deleted" comment is a tombstone
""" """
@impl Converter
@spec model_to_as(CommentModel.t()) :: map
def model_to_as(%CommentModel{} = comment) do def model_to_as(%CommentModel{} = comment) do
Convertible.model_to_as(%TombstoneModel{ Convertible.model_to_as(%TombstoneModel{
uri: comment.url, uri: comment.url,
inserted_at: comment.deleted_at inserted_at: comment.deleted_at
}) })
end end
@spec determine_to(CommentModel.t()) :: [String.t()]
defp determine_to(%CommentModel{} = comment) do
cond do
not is_nil(comment.attributed_to) ->
[comment.attributed_to.url]
comment.visibility == :public ->
["https://www.w3.org/ns/activitystreams#Public"]
true ->
[comment.actor.followers_url]
end
end
defp maybe_fetch_parent_object(object, data) do
# We fetch the parent object
Logger.debug("We're fetching the parent object")
if Map.has_key?(object, "inReplyTo") && object["inReplyTo"] != nil &&
object["inReplyTo"] != "" do
Logger.debug(fn -> "Object has inReplyTo #{object["inReplyTo"]}" end)
case ActivityPub.fetch_object_from_url(object["inReplyTo"]) do
# Reply to an event (Event)
{:ok, %Event{id: id}} ->
Logger.debug("Parent object is an event")
data |> Map.put(:event_id, id)
# Reply to a comment (Comment)
{:ok, %CommentModel{id: id} = comment} ->
Logger.debug("Parent object is another comment")
data
|> Map.put(:in_reply_to_comment_id, id)
|> Map.put(:origin_comment_id, comment |> CommentModel.get_thread_id())
|> Map.put(:event_id, comment.event_id)
# Reply to a discucssion (Discussion)
{:ok,
%Discussion{
id: discussion_id,
last_comment: %CommentModel{id: last_comment_id, origin_comment_id: origin_comment_id}
} = _discussion} ->
Logger.debug("Parent object is a discussion")
data
|> Map.put(:in_reply_to_comment_id, last_comment_id)
|> Map.put(:origin_comment_id, origin_comment_id)
|> Map.put(:discussion_id, discussion_id)
# Anything else is kind of a MP
{:error, parent} ->
Logger.warn("Parent object is something we don't handle")
Logger.debug(inspect(parent))
data
end
else
Logger.debug("No parent object for this comment")
data
end
end
end end

View File

@ -6,6 +6,8 @@ defmodule Mobilizon.Federation.ActivityStream.Converter do
one, and back. one, and back.
""" """
@callback as_to_model_data(map) :: map @type model_data :: map()
@callback model_to_as(struct) :: map
@callback as_to_model_data(as_data :: ActivityStream.t()) :: model_data()
@callback model_to_as(model :: struct()) :: ActivityStream.t()
end end

View File

@ -0,0 +1,63 @@
defmodule Mobilizon.Federation.ActivityStream.Converter.Discussion do
@moduledoc """
Comment converter.
This module allows to convert events from ActivityStream format to our own
internal one, and back.
"""
alias Mobilizon.Actors.Actor
alias Mobilizon.Discussions.Discussion
alias Mobilizon.Federation.ActivityPub
alias Mobilizon.Federation.ActivityStream.{Converter, Convertible}
alias Mobilizon.Federation.ActivityStream.Converter.Discussion, as: DiscussionConverter
alias Mobilizon.Storage.Repo
require Logger
@behaviour Converter
defimpl Convertible, for: Discussion do
defdelegate model_to_as(comment), to: DiscussionConverter
end
@doc """
Make an AS comment object from an existing `discussion` structure.
"""
@impl Converter
@spec model_to_as(Discussion.t()) :: map
def model_to_as(%Discussion{} = discussion) do
discussion = Repo.preload(discussion, [:last_comment, :actor, :creator])
%{
"type" => "Note",
"to" => [discussion.actor.followers_url],
"cc" => [],
"name" => discussion.title,
"content" => discussion.last_comment.text,
"mediaType" => "text/html",
"actor" => discussion.creator.url,
"attributedTo" => discussion.actor.url,
"id" => discussion.url,
"context" => discussion.url
}
end
@impl Converter
@spec as_to_model_data(map) :: {:ok, map} | {:error, any()}
def as_to_model_data(%{"type" => "Note", "name" => name} = object) when not is_nil(name) do
with creator_url <- Map.get(object, "actor"),
{:ok, %Actor{id: creator_id, suspended: false}} <-
ActivityPub.get_or_fetch_actor_by_url(creator_url),
actor_url <- Map.get(object, "attributedTo"),
{:ok, %Actor{id: actor_id, suspended: false}} <-
ActivityPub.get_or_fetch_actor_by_url(actor_url) do
%{
title: name,
actor_id: actor_id,
creator_id: creator_id,
url: object["id"]
}
end
end
end

View File

@ -12,11 +12,17 @@ defmodule Mobilizon.Federation.ActivityStream.Converter.Event do
alias Mobilizon.Events.Event, as: EventModel alias Mobilizon.Events.Event, as: EventModel
alias Mobilizon.Media.Picture alias Mobilizon.Media.Picture
alias Mobilizon.Federation.ActivityPub
alias Mobilizon.Federation.ActivityStream.{Converter, Convertible} alias Mobilizon.Federation.ActivityStream.{Converter, Convertible}
alias Mobilizon.Federation.ActivityStream.Converter.Address, as: AddressConverter alias Mobilizon.Federation.ActivityStream.Converter.Address, as: AddressConverter
alias Mobilizon.Federation.ActivityStream.Converter.Picture, as: PictureConverter alias Mobilizon.Federation.ActivityStream.Converter.Picture, as: PictureConverter
alias Mobilizon.Federation.ActivityStream.Converter.Utils, as: ConverterUtils
import Mobilizon.Federation.ActivityStream.Converter.Utils,
only: [
fetch_tags: 1,
fetch_mentions: 1,
build_tags: 1,
maybe_fetch_actor_and_attributed_to_id: 1
]
require Logger require Logger
@ -34,16 +40,12 @@ defmodule Mobilizon.Federation.ActivityStream.Converter.Event do
@impl Converter @impl Converter
@spec as_to_model_data(map) :: {:ok, map()} | {:error, any()} @spec as_to_model_data(map) :: {:ok, map()} | {:error, any()}
def as_to_model_data(object) do def as_to_model_data(object) do
Logger.debug("event as_to_model_data") with {%Actor{id: actor_id, domain: actor_domain}, attributed_to} <-
Logger.debug(inspect(object)) maybe_fetch_actor_and_attributed_to_id(object),
with author_url <- Map.get(object, "actor") || Map.get(object, "attributedTo"),
{:actor, {:ok, %Actor{id: actor_id, domain: actor_domain, suspended: false}}} <-
{:actor, ActivityPub.get_or_fetch_actor_by_url(author_url)},
{:address, address_id} <- {:address, address_id} <-
{:address, get_address(object["location"])}, {:address, get_address(object["location"])},
{:tags, tags} <- {:tags, ConverterUtils.fetch_tags(object["tag"])}, {:tags, tags} <- {:tags, fetch_tags(object["tag"])},
{:mentions, mentions} <- {:mentions, ConverterUtils.fetch_mentions(object["tag"])}, {:mentions, mentions} <- {:mentions, fetch_mentions(object["tag"])},
{:visibility, visibility} <- {:visibility, get_visibility(object)}, {:visibility, visibility} <- {:visibility, get_visibility(object)},
{:options, options} <- {:options, get_options(object)} do {:options, options} <- {:options, get_options(object)} do
attachments = attachments =
@ -67,6 +69,7 @@ defmodule Mobilizon.Federation.ActivityStream.Converter.Event do
title: object["name"], title: object["name"],
description: object["content"], description: object["content"],
organizer_actor_id: actor_id, organizer_actor_id: actor_id,
attributed_to_id: if(is_nil(attributed_to), do: nil, else: attributed_to.id),
picture_id: picture_id, picture_id: picture_id,
begins_on: object["startTime"], begins_on: object["startTime"],
ends_on: object["endTime"], ends_on: object["endTime"],
@ -108,7 +111,9 @@ defmodule Mobilizon.Federation.ActivityStream.Converter.Event do
"type" => "Event", "type" => "Event",
"to" => to, "to" => to,
"cc" => [], "cc" => [],
"attributedTo" => event.organizer_actor.url, "attributedTo" =>
if(is_nil(event.attributed_to), do: nil, else: event.attributed_to.url) ||
event.organizer_actor.url,
"name" => event.title, "name" => event.title,
"actor" => event.organizer_actor.url, "actor" => event.organizer_actor.url,
"uuid" => event.uuid, "uuid" => event.uuid,
@ -120,7 +125,7 @@ defmodule Mobilizon.Federation.ActivityStream.Converter.Event do
"startTime" => event.begins_on |> date_to_string(), "startTime" => event.begins_on |> date_to_string(),
"joinMode" => to_string(event.join_options), "joinMode" => to_string(event.join_options),
"endTime" => event.ends_on |> date_to_string(), "endTime" => event.ends_on |> date_to_string(),
"tag" => event.tags |> ConverterUtils.build_tags(), "tag" => event.tags |> build_tags(),
"maximumAttendeeCapacity" => event.options.maximum_attendee_capacity, "maximumAttendeeCapacity" => event.options.maximum_attendee_capacity,
"repliesModerationOption" => event.options.comment_moderation, "repliesModerationOption" => event.options.comment_moderation,
"commentsEnabled" => event.options.comment_moderation == :allow_all, "commentsEnabled" => event.options.comment_moderation == :allow_all,

View File

@ -9,7 +9,7 @@ defmodule Mobilizon.Federation.ActivityStream.Converter.Flag do
""" """
alias Mobilizon.Actors.Actor alias Mobilizon.Actors.Actor
alias Mobilizon.Conversations alias Mobilizon.Discussions
alias Mobilizon.Events alias Mobilizon.Events
alias Mobilizon.Events.Event alias Mobilizon.Events.Event
alias Mobilizon.Reports.Report alias Mobilizon.Reports.Report
@ -92,7 +92,7 @@ defmodule Mobilizon.Federation.ActivityStream.Converter.Flag do
Enum.filter(objects, fn url -> Enum.filter(objects, fn url ->
!(url == reported.url || (!is_nil(event) && event.url == url)) !(url == reported.url || (!is_nil(event) && event.url == url))
end), end),
comments <- Enum.map(comments, &Conversations.get_comment_from_url/1) do comments <- Enum.map(comments, &Discussions.get_comment_from_url/1) do
%{ %{
"reporter" => reporter, "reporter" => reporter,
"uri" => object["id"], "uri" => object["id"],

View File

@ -39,7 +39,7 @@ defmodule Mobilizon.Federation.ActivityStream.Converter.Picture do
actor_id actor_id
) )
when is_bitstring(picture_url) do when is_bitstring(picture_url) do
with {:ok, %HTTPoison.Response{body: body}} <- HTTPoison.get(picture_url, [], @http_options), with {:ok, %{body: body}} <- Tesla.get(picture_url, opts: @http_options),
{:ok, %{name: name, url: url, content_type: content_type, size: size}} <- {:ok, %{name: name, url: url, content_type: content_type, size: size}} <-
Upload.store(%{body: body, name: name}), Upload.store(%{body: body, name: name}),
{:picture_exists, nil} <- {:picture_exists, Media.get_picture_by_url(url)} do {:picture_exists, nil} <- {:picture_exists, Media.get_picture_by_url(url)} do

View File

@ -0,0 +1,70 @@
defmodule Mobilizon.Federation.ActivityStream.Converter.Post do
@moduledoc """
Post converter.
This module allows to convert posts from ActivityStream format to our own
internal one, and back.
"""
alias Mobilizon.Actors.Actor
alias Mobilizon.Federation.ActivityPub
alias Mobilizon.Federation.ActivityPub.Utils
alias Mobilizon.Federation.ActivityStream.{Converter, Convertible}
alias Mobilizon.Posts.Post
require Logger
@behaviour Converter
defimpl Convertible, for: Post do
alias Mobilizon.Federation.ActivityStream.Converter.Post, as: PostConverter
defdelegate model_to_as(post), to: PostConverter
end
@doc """
Convert an post struct to an ActivityStream representation
"""
@impl Converter
@spec model_to_as(Post.t()) :: map
def model_to_as(
%Post{author: %Actor{url: actor_url}, attributed_to: %Actor{url: creator_url}} = post
) do
%{
"type" => "Article",
"actor" => actor_url,
"id" => post.url,
"name" => post.title,
"content" => post.body,
"attributedTo" => creator_url,
"published" => post.publish_at || post.inserted_at
}
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" => "Article", "actor" => creator, "attributedTo" => group} = object
) do
with {:ok, %Actor{id: attributed_to_id}} <- get_actor(group),
{:ok, %Actor{id: author_id}} <- get_actor(creator) do
%{
title: object["name"],
body: object["content"],
url: object["id"],
attributed_to_id: attributed_to_id,
author_id: author_id,
local: false,
publish_at: object["published"]
}
else
{:error, err} -> {:error, err}
err -> {:error, err}
end
end
@spec get_actor(String.t() | map() | nil) :: {:ok, Actor.t()} | {:error, String.t()}
defp get_actor(nil), do: {:error, "nil property found for actor data"}
defp get_actor(actor), do: actor |> Utils.get_url() |> ActivityPub.get_or_fetch_actor_by_url()
end

View File

@ -28,7 +28,7 @@ defmodule Mobilizon.Federation.ActivityStream.Converter.TodoList do
"type" => "TodoList", "type" => "TodoList",
"actor" => group_url, "actor" => group_url,
"id" => todo_list.url, "id" => todo_list.url,
"title" => todo_list.title "name" => todo_list.title
} }
end end

View File

@ -28,6 +28,7 @@ defmodule Mobilizon.Federation.ActivityStream.Converter.Tombstone do
%{ %{
"type" => "Tombstone", "type" => "Tombstone",
"id" => tombstone.uri, "id" => tombstone.uri,
"actor" => tombstone.actor.url,
"deleted" => tombstone.inserted_at "deleted" => tombstone.inserted_at
} }
end end

Some files were not shown because too many files have changed in this diff Show More