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"
]
config :tesla, adapter: Tesla.Adapter.Hackney
config :phoenix, :format_encoders, json: Jason, "activity-json": Jason
config :phoenix, :json_library, Jason

View File

@ -44,6 +44,11 @@ config :mobilizon, Mobilizon.Web.Upload.Uploader.Local, uploads: "test/uploads"
config :exvcr,
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, Oban, queues: false, prune: :disabled, crontab: false

View File

@ -49,7 +49,7 @@
"vue-router": "^3.1.6",
"vue-scrollto": "^2.17.1",
"vue2-leaflet": "^2.0.3",
"vuedraggable": "^2.23.2"
"vuedraggable": "2.23.2"
},
"devDependencies": {
"@types/chai": "^4.2.11",
@ -90,7 +90,7 @@
"prettier-eslint": "^10.1.1",
"sass-loader": "^8.0.2",
"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-i18n-extract": "^1.0.2",
"vue-template-compiler": "^2.6.11",

View File

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

View File

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

View File

@ -12,7 +12,9 @@
<span>@{{ comment.actor.preferredUsername }}</span>
</div>
<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 class="description-content" v-html="comment.text"></div>
@ -21,10 +23,10 @@
</template>
<script lang="ts">
import { Component, Prop, Vue } from "vue-property-decorator";
import { IComment } from "../../types/comment.model";
import { IComment, CommentModel } from "../../types/comment.model";
@Component
export default class ConversationComment extends Vue {
export default class DiscussionComment extends Vue {
@Prop({ required: true, type: Object }) comment!: IComment;
}
</script>

View File

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

View File

@ -247,6 +247,7 @@ export default class EventListCard extends mixins(ActorMixin, EventMixin) {
* Delete the event
*/
async openDeleteEventModalWrapper() {
// @ts-ignore
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 {
@Prop({ type: Object, required: true }) event!: IEvent;
@Prop({ type: Boolean, required: false, default: true }) eventCapacityOK!: boolean;
@Ref("eventURLInput") readonly eventURLInput!: any;
EventVisibility = EventVisibility;
EventStatus = EventStatus;
showCopiedTooltip: boolean = false;
showCopiedTooltip = false;
get twitterShareUrl(): string {
return `https://twitter.com/intent/tweet?url=${encodeURIComponent(this.event.url)}&text=${

View File

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

View File

@ -7,6 +7,7 @@ import { Component, Prop, Vue } from "vue-property-decorator";
// eslint-disable-next-line @typescript-eslint/ban-ts-ignore
// @ts-ignore
import MobilizonLogo from "../assets/mobilizon_logo.svg?inline";
@Component({
components: {
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;
.preview {
flex: 0 0 100px;
flex: 0 0 50px;
position: relative;
display: flex;
align-items: center;
@ -159,7 +159,7 @@ a {
display: block;
font-weight: 500;
margin-bottom: 5px;
color: $background-color;
color: $primary;
overflow: hidden;
text-overflow: ellipsis;
text-decoration: none;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,6 +1,7 @@
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 { POST_BASIC_FIELDS } from "./post";
export const FETCH_PERSON = gql`
query($username: String!) {
@ -479,10 +480,16 @@ export const FETCH_GROUP = gql`
}
total
}
conversations {
discussions {
total
elements {
...ConversationBasicFields
...DiscussionBasicFields
}
}
posts {
total
elements {
...PostBasicFields
}
}
members {
@ -497,6 +504,7 @@ export const FETCH_GROUP = gql`
url
}
}
insertedAt
}
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}
`;
export const CREATE_GROUP = gql`
mutation CreateGroup(
$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`
mutation 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";
/* eslint-disable import/prefer-default-export */
export const TAG_FRAGMENT = gql`
fragment TagFragment on Tag {
id
slug
title
}
`;
export const TAGS = gql`
query {
tags {

View File

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

View File

@ -21,7 +21,7 @@
"An error has occurred.": "Адбылася памылка.",
"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 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 edition? You'll lose all modifications.": "Вы сапраўды хочаце адмяніць рэдагаванне падзеі? Вы страціце ўсе рэдагаванні.",
"Are you sure you want to cancel your participation at event \"{title}\"?": "Вы сапраўды хочаце адмовіцца ад удзелу ў падзеі «{title}»?",

View File

@ -30,7 +30,7 @@
"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 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 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}\"?",

View File

@ -27,7 +27,7 @@
"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 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 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?",

View File

@ -29,7 +29,7 @@
"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 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 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}\"?",
@ -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}. 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",
"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",
"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 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 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}\"?",
@ -103,14 +103,14 @@
"Confirmed: Will happen": "Confirmado: sucederá",
"Contact": "Contacto",
"Continue editing": "Continua editando",
"Conversations": "Conversaciones",
"Discussions": "Conversaciones",
"Cookies and Local storage": "Cookies y almacenamiento local",
"Country": "País",
"Create": "Crear",
"Create a calc": "Crear un calco",
"Create a discussion": "Crear una discusión",
"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 group": "Crear un nuevo grupo",
"Create a new identity": "Crear una nueva identidad",
@ -347,7 +347,7 @@
"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.",
"Name": "Nombre",
"New conversation": "Nueva conversación",
"New discussion": "Nueva conversación",
"New discussion": "Nueva discusión",
"New email": "Nuevo correo electrónico",
"New folder": "Nueva carpeta",
@ -620,7 +620,7 @@
"Username": "Nombre de usuario",
"Users": "Los usuarios",
"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 resources": "Ver todos los recursos",
"View all todos": "Ver todas las tareas pendientes",

View File

@ -54,7 +54,7 @@
"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 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 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}?",
@ -101,14 +101,14 @@
"Confirmed: Will happen": "Vahvistettu: Tapahtuu",
"Contact": "Ota yhteyttä",
"Continue editing": "Jatka muokkausta",
"Conversations": "Keskustelut",
"Discussions": "Keskustelut",
"Cookies and Local storage": "Evästeet ja paikallisesti tallennettavat tiedot",
"Country": "Maa",
"Create": "Luo",
"Create a calc": "Luo taulukko",
"Create a discussion": "Luo keskustelu",
"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 group": "Luo uusi ryhmä",
"Create a new identity": "Luo uusi identiteetti",
@ -341,7 +341,7 @@
"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.",
"Name": "Nimi",
"New conversation": "Uusi keskustelu",
"New discussion": "Uusi keskustelu",
"New discussion": "Uusi keskustelu",
"New email": "Uusi sähköpostiosoite",
"New folder": "Uusi kansio",
@ -615,7 +615,7 @@
"Username": "Käyttäjänimi",
"Users": "Käyttäjät",
"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 resources": "Näytä kaikki resurssit",
"View all todos": "Näytä kaikki tehtävät",

View File

@ -53,7 +53,7 @@
"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 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 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} » ?",
@ -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}. 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",
"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 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 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 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} » ?",
@ -84,10 +84,10 @@
"Confirmed: Will happen": "Confirmat : se tendrà",
"Contact": "Contacte",
"Continue editing": "Contunhar la modificacion",
"Conversations": "Conversacions",
"Discussions": "Conversacions",
"Country": "País",
"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 group": "Crear un grop novèl",
"Create a new identity": "Crear una identitat novèla",
@ -273,7 +273,7 @@
"My groups": "Mos grops",
"My identities": "Mas identitats",
"Name": "Nom",
"New conversation": "Conversacion novèla",
"New discussion": "Conversacion novèla",
"New email": "Adreça novèla",
"New folder": "Dossièr novèl",
"New link": "Ligam novèl",

View File

@ -26,7 +26,7 @@
"Anonymous participations": "Participações anônimas",
"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 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 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}\"?",

View File

@ -6,15 +6,29 @@ import Component from "vue-class-component";
import VueScrollTo from "vue-scrollto";
import VueMeta from "vue-meta";
import VTooltip from "v-tooltip";
import TimeAgo from "javascript-time-ago";
import App from "./App.vue";
import router from "./router";
import { NotifierPlugin } from "./plugins/notifier";
import filters from "./filters";
import { i18n } from "./utils/i18n";
import messages from "./i18n";
import apolloProvider from "./vue-apollo";
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(NotifierPlugin);
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 {
TODO_LISTS = "TODO_LISTS",
@ -10,6 +10,10 @@ export enum GroupsRouteName {
RESOURCES = "RESOURCES",
RESOURCE_FOLDER_ROOT = "RESOURCE_FOLDER_ROOT",
RESOURCE_FOLDER = "RESOURCE_FOLDER",
POST_CREATE = "POST_CREATE",
POST_EDIT = "POST_EDIT",
POST = "POST",
POSTS = "POSTS",
}
const resourceFolder = () => import("@/views/Resources/ResourceFolder.vue");
@ -61,6 +65,7 @@ export const groupsRoutes: RouteConfig[] = [
{
path: "public",
name: GroupsRouteName.GROUP_PUBLIC_SETTINGS,
component: () => import("../views/Group/GroupSettings.vue"),
},
{
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 { settingsRoutes } from "./settings";
import { groupsRoutes } from "./groups";
import { conversationRoutes } from "./conversation";
import { discussionRoutes } from "./discussion";
import { userRoutes } from "./user";
import RouteName from "./name";
@ -46,7 +46,7 @@ const router = new Router({
...settingsRoutes,
...actorRoutes,
...groupsRoutes,
...conversationRoutes,
...discussionRoutes,
...errorRoutes,
{
path: "/search/:searchTerm/:searchType?",

View File

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

View File

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

View File

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

View File

@ -83,6 +83,7 @@ import { IStatistics } from "../../types/statistics.model";
})
export default class AboutInstance extends Vue {
config!: IConfig;
statistics!: IStatistics;
get isContactEmail(): boolean {
@ -97,7 +98,8 @@ export default class AboutInstance extends Vue {
if (!this.config.contact) return null;
if (this.isContactEmail) {
return { uri: `mailto:${this.config.contact}`, text: this.config.contact };
} else if (this.isContactURL) {
}
if (this.isContactURL) {
return {
uri: this.config.contact,
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 {
@Prop({ required: true }) id!: String;
@Prop({ required: true }) id!: string;
person!: IPerson;
@ -171,6 +171,7 @@ export default class AdminProfile extends Vue {
EVENTS_PER_PAGE = EVENTS_PER_PAGE;
organizedEventsPage = 1;
participationsPage = 1;
get metadata(): Array<object> {

View File

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

View File

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

View File

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

View File

@ -109,9 +109,11 @@ const USERS_PER_PAGE = 10;
})
export default class Users extends Vue {
page = 1;
email = "";
USERS_PER_PAGE = USERS_PER_PAGE;
RouteName = RouteName;
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">
<h1>{{ $t("Create a discussion") }}</h1>
<form @submit.prevent="createConversation">
<form @submit.prevent="createDiscussion">
<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 :label="$t('Text')">
<editor v-model="conversation.text" />
<editor v-model="discussion.text" />
</b-field>
<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 { IGroup, IPerson } from "@/types/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";
@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;
group!: IGroup;
currentActor!: IPerson;
conversation = { title: "", text: "" };
discussion = { title: "", text: "" };
async createConversation() {
async createDiscussion() {
try {
const { data } = await this.$apollo.mutate({
mutation: CREATE_CONVERSATION,
mutation: CREATE_DISCUSSION,
variables: {
title: this.conversation.title,
text: this.conversation.text,
title: this.discussion.title,
text: this.discussion.text,
actorId: this.group.id,
creatorId: this.currentActor.id,
},
// update: (store, { data: { createConversation } }) => {
// update: (store, { data: { createDiscussion } }) => {
// // TODO: update group list cache
// },
});
await this.$router.push({
name: RouteName.CONVERSATION,
name: RouteName.DISCUSSION,
params: {
id: data.createConversation.id,
slug: data.createConversation.slug,
id: data.createDiscussion.id,
slug: data.createDiscussion.slug,
},
});
} 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">
<router-link
:to="{
name: RouteName.CONVERSATION_LIST,
name: RouteName.DISCUSSION_LIST,
params: { preferredUsername: usernameWithDomain(group) },
}"
>{{ $t("Discussions") }}</router-link
@ -26,17 +26,17 @@
</ul>
</nav>
<section>
<div v-if="group.conversations.elements.length > 0">
<conversation-list-item
:conversation="conversation"
v-for="conversation in group.conversations.elements"
:key="conversation.id"
<div v-if="group.discussions.elements.length > 0">
<discussion-list-item
:discussion="discussion"
v-for="discussion in group.discussions.elements"
:key="discussion.id"
/>
</div>
<b-button
tag="router-link"
:to="{
name: RouteName.CREATE_CONVERSATION,
name: RouteName.CREATE_DISCUSSION,
params: { preferredUsername: this.preferredUsername },
}"
>{{ $t("New discussion") }}</b-button
@ -48,11 +48,11 @@
import { Component, Prop, Vue } from "vue-property-decorator";
import { FETCH_GROUP } from "@/graphql/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";
@Component({
components: { ConversationListItem },
components: { DiscussionListItem },
apollo: {
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;
group!: IGroup;

View File

@ -777,11 +777,9 @@ export default class Event extends EventMixin {
let reporterId = null;
if (this.currentActor.id) {
reporterId = this.currentActor.id;
} else {
if (this.config.anonymous.reports.allowed) {
} else if (this.config.anonymous.reports.allowed) {
reporterId = this.config.anonymous.actorId;
}
}
if (!reporterId) return;
try {
await this.$apollo.mutate<IReport>({

View File

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

View File

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

View File

@ -3,15 +3,31 @@
<nav class="breadcrumb" aria-label="breadcrumbs">
<ul>
<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>
<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 class="is-active">
<router-link :to="{ name: RouteName.GROUP_MEMBERS_SETTINGS }">{{
$t("Members")
}}</router-link>
<router-link
:to="{
name: RouteName.GROUP_MEMBERS_SETTINGS,
params: { preferredUsername: usernameWithDomain(group) },
}"
>{{ $t("Members") }}</router-link
>
</li>
</ul>
</nav>
@ -29,26 +45,127 @@
</b-field>
</form>
<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>
</section>
</div>
</template>
<script lang="ts">
import { Component, Vue } from "vue-property-decorator";
import { Component, Vue, Watch } from "vue-property-decorator";
import RouteName from "../../router/name";
import { FETCH_GROUP } from "../../graphql/actor";
import { INVITE_MEMBER } from "../../graphql/member";
import { IGroup } from "../../types/actor";
import { IMember } from "../../types/actor/group.model";
import { INVITE_MEMBER, GROUP_MEMBERS } from "../../graphql/member";
import { IGroup, usernameWithDomain } from "../../types/actor";
import { IMember, MemberRole } from "../../types/actor/group.model";
@Component({
apollo: {
group: {
query: FETCH_GROUP,
query: GROUP_MEMBERS,
// fetchPolicy: "network-only",
variables() {
return {
name: this.$route.params.preferredUsername,
page: 1,
limit: this.MEMBERS_PER_PAGE,
roles: this.roles,
};
},
skip() {
@ -66,6 +183,23 @@ export default class GroupMembers extends Vue {
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() {
await this.$apollo.mutate<{ inviteMember: IMember }>({
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>

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

View File

@ -315,7 +315,7 @@ export default class Report extends Vue {
this.$buefy.dialog.confirm({
title: this.$t("Deleting event") as string,
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,
confirmText: this.$t("Delete Event") as string,
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>
</transition-group>
</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>
</div>
</section>
@ -470,12 +470,12 @@ export default class Resources extends Mixins(ResourceMixin) {
handleRename(resource: IResource) {
this.renameModal = true;
this.updatedResource = Object.assign({}, resource);
this.updatedResource = { ...resource };
}
handleMove(resource: IResource) {
this.moveModal = true;
this.updatedResource = Object.assign({}, resource);
this.updatedResource = { ...resource };
}
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.{
Actors,
Config,
Conversations,
Discussions,
Events,
Reports,
Resources,
Share,
Todos,
Users
}
alias Mobilizon.Actors.{Actor, Follower, Member}
alias Mobilizon.Conversations.Comment
alias Mobilizon.Discussions.Comment
alias Mobilizon.Events.{Event, Participant}
alias Mobilizon.Reports.Report
alias Mobilizon.Resources.Resource
alias Mobilizon.Todos.{Todo, TodoList}
alias Mobilizon.Tombstone
alias Mobilizon.Federation.ActivityPub.{
Activity,
Audience,
Federator,
Fetcher,
Preloader,
Relay,
Transmogrifier,
Types,
Visibility
}
alias Mobilizon.Federation.ActivityPub.Types.{Managable, Ownable}
alias Mobilizon.Federation.ActivityStream.{Converter, Convertible}
alias Mobilizon.Federation.ActivityStream.Converter.Utils, as: ConverterUtils
alias Mobilizon.Federation.HTTPSignatures.Signature
alias Mobilizon.Federation.WebFinger
alias Mobilizon.GraphQL.API.Utils, as: APIUtils
alias Mobilizon.Service.Formatter.HTML
alias Mobilizon.Service.Notifications.Scheduler
alias Mobilizon.Service.RichMedia.Parser
alias Mobilizon.Storage.Page
alias Mobilizon.Web.Endpoint
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
"""
# TODO: Make database calls parallel
@spec fetch_object_from_url(String.t()) :: {:ok, %Event{}} | {:ok, %Comment{}} | {:error, any()}
def fetch_object_from_url(url) do
@spec fetch_object_from_url(String.t(), Keyword.t()) ::
{:ok, struct()} | {:error, any()}
def fetch_object_from_url(url, options \\ []) do
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")},
{:existing_event, nil} <- {:existing_event, Events.get_event_by_url(url)},
{:existing_comment, nil} <- {:existing_comment, Conversations.get_comment_from_url(url)},
{:existing_resource, nil} <- {:existing_resource, Resources.get_resource_by_url(url)},
{:existing_actor, {:error, :actor_not_found}} <-
{:existing_actor, Actors.get_actor_by_url(url)},
date <- Signature.generate_date_header(),
headers <-
[{:Accept, "application/activity+json"}]
|> maybe_date_fetch(date)
|> sign_fetch_relay(url, date),
{:ok, %HTTPoison.Response{body: body, status_code: code}} when code in 200..299 <-
HTTPoison.get(
url,
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)}
{:existing, nil} <-
{:existing, Tombstone.find_tombstone(url)},
{:existing, nil} <- {:existing, Events.get_event_by_url(url)},
{:existing, nil} <-
{:existing, Discussions.get_discussion_by_url(url)},
{:existing, nil} <- {:existing, Discussions.get_comment_from_url(url)},
{:existing, nil} <- {:existing, Resources.get_resource_by_url(url)},
{:existing, nil} <-
{:existing, Actors.get_actor_by_url_2(url)},
:ok <- Logger.info("Data for URL not found anywhere, going to fetch it"),
{:ok, _activity, entity} <- Fetcher.fetch_and_create(url, options) do
Logger.debug("Going to preload the new entity")
Preloader.maybe_preload(entity)
else
{:existing, entity} ->
Logger.debug("Entity is already existing")
"Note" ->
{:ok, Conversations.get_comment_from_url_with_preload!(object_url)}
entity =
if force_fetch and not compare_origins?(url, Endpoint.url()) do
Logger.debug("Entity is external and we want a force fetch")
"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}
with {:ok, _activity, entity} <- Fetcher.fetch_and_update(url, options) do
entity
end
else
{:existing_event, %Event{url: event_url}} ->
{:ok, Events.get_public_event_by_url_with_preload!(event_url)}
entity
end
{:existing_comment, %Comment{url: comment_url}} ->
{:ok, Conversations.get_comment_from_url_with_preload!(comment_url)}
Logger.debug("Going to preload an existing entity")
{:existing_resource, %Resource{url: resource_url}} ->
{:ok, Resources.get_resource_by_url_with_preloads(resource_url)}
{:existing_actor, {:ok, %Actor{url: actor_url}}} ->
{:ok, Actors.get_actor_by_url!(actor_url, true)}
{:origin_check, false} ->
Logger.warn("Object origin check failed")
{:error, "Object origin check failed"}
Preloader.maybe_preload(entity)
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)},
{:ok, entity, create_data} <-
(case type do
:event -> create_event(args, additional)
:comment -> create_comment(args, additional)
:group -> create_group(args, additional)
:todo_list -> create_todo_list(args, additional)
:todo -> create_todo(args, additional)
:resource -> create_resource(args, additional)
:event -> Types.Events.create(args, additional)
:comment -> Types.Comments.create(args, additional)
:discussion -> Types.Discussions.create(args, additional)
:actor -> Types.Actors.create(args, additional)
:todo_list -> Types.TodoLists.create(args, additional)
:todo -> Types.Todos.create(args, additional)
:resource -> Types.Resources.create(args, additional)
:post -> Types.Posts.create(args, additional)
end),
{: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}
else
err ->
@ -227,21 +196,15 @@ defmodule Mobilizon.Federation.ActivityPub do
* Federates (asynchronously) the activity
* Returns the activity
"""
@spec update(atom(), struct(), map(), boolean, map()) :: {:ok, Activity.t(), struct()} | any()
def update(type, old_entity, args, local \\ false, additional \\ %{}) do
@spec update(struct(), map(), boolean, map()) :: {:ok, Activity.t(), struct()} | any()
def update(old_entity, args, local \\ false, additional \\ %{}) do
Logger.debug("updating an activity")
Logger.debug(inspect(args))
with {:ok, entity, update_data} <-
(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),
with {:ok, entity, update_data} <- Managable.update(old_entity, args, additional),
{: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}
else
err ->
@ -366,182 +329,48 @@ defmodule Mobilizon.Federation.ActivityPub do
end
end
def delete(object, local \\ true)
@spec delete(Event.t(), boolean) :: {:ok, Activity.t(), Event.t()}
def delete(%Event{url: url, organizer_actor: actor} = event, local) do
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),
def delete(object, actor, local \\ true) do
with {:ok, activity_data, actor, object} <-
Managable.delete(object, actor, local),
group <- Ownable.group_actor(object),
: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),
:ok <- maybe_relay_if_group_activity(activity, group) do
{:ok, activity, object}
end
end
def join(%Event{} = event, %Actor{} = actor, local \\ true, additional \\ %{}) do
with {:ok, activity_data, participant} <- Types.Events.join(event, actor, local, additional),
{:ok, activity} <- create_activity(activity_data, local),
:ok <- maybe_federate(activity) do
{:ok, activity, event}
{:ok, activity, participant}
else
{:maximum_attendee_capacity, err} ->
{:maximum_attendee_capacity, err}
{:accept, accept} ->
accept
end
end
@spec delete(Comment.t(), boolean) :: {:ok, Activity.t(), Comment.t()}
def delete(%Comment{url: url, actor: actor} = comment, local) do
data = %{
"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, activity, comment}
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
def join_group(
%{parent_id: parent_id, actor_id: actor_id, role: role},
local \\ true,
additional \\ %{}
) 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
err ->
Logger.error("Something went wrong while creating an activity")
Logger.debug(inspect(err))
err
end
end
def join(object, actor, local \\ true, additional \\ %{})
def join(%Event{} = event, %Actor{} = actor, local, additional) do
# TODO Refactor me for federation
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)))
with {:ok, %Member{} = member} <-
Mobilizon.Actors.create_member(%{
parent_id: parent_id,
actor_id: actor_id,
role: role
}),
join_data <- Convertible.model_to_as(participant),
audience <-
Audience.calculate_to_and_cc_from_mentions(participant),
{:ok, activity} <- create_activity(Map.merge(join_data, audience), local),
activity_data when is_map(activity_data) <-
Convertible.model_to_as(member),
{:ok, activity} <- create_activity(Map.merge(activity_data, additional), local),
:ok <- maybe_federate(activity) do
if event.local do
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
{:ok, activity, member}
end
end
@ -640,7 +469,7 @@ defmodule Mobilizon.Federation.ActivityPub do
with {:ok, entity, update_data} <-
(case type do
:resource -> move_resource(old_entity, args, additional)
:resource -> Types.Resources.move(old_entity, args, additional)
end),
{:ok, activity} <- create_activity(update_data, local),
:ok <- maybe_federate(activity) do
@ -653,6 +482,25 @@ defmodule Mobilizon.Federation.ActivityPub do
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 """
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?(_), do: false
@spec is_announce_activity?(Activity.t()) :: boolean
defp is_announce_activity?(%Activity{data: %{"type" => "Announce"}}), do: true
defp is_announce_activity?(_), do: false
@spec convert_members_in_recipients(list(String.t())) :: {list(String.t()), list(Actor.t())}
defp convert_members_in_recipients(recipients) do
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 """
Publish an activity to all appropriated audiences inboxes
@ -741,19 +609,11 @@ defmodule Mobilizon.Federation.ActivityPub do
{recipients, []}
end
# If we want to send to all members of the group, because this server is the one the group is on
{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
{recipients, members} = convert_members_in_recipients(recipients)
remote_inboxes =
(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()
{:ok, data} = Transmogrifier.prepare_outgoing(activity.data)
@ -791,16 +651,15 @@ defmodule Mobilizon.Federation.ActivityPub do
date: date
})
HTTPoison.post(
Tesla.post(
inbox,
json,
[
headers: [
{"Content-Type", "application/activity+json"},
{"signature", signature},
{"digest", digest},
{"date", date}
],
hackney: [pool: :default]
]
)
end
@ -811,18 +670,15 @@ defmodule Mobilizon.Federation.ActivityPub do
Logger.debug(inspect(url))
res =
with {:ok, %HTTPoison.Response{status_code: 200, body: body}} <-
HTTPoison.get(url, [Accept: "application/activity+json"],
follow_redirect: true,
ssl: [{:versions, [:"tlsv1.2"]}]
),
with {:ok, %{status: 200, body: body}} <-
Tesla.get(url, headers: [{"Accept", "application/activity+json"}]),
:ok <- Logger.debug("response okay, now decoding json"),
{:ok, data} <- Jason.decode(body) do
Logger.debug("Got activity+json response at actor's endpoint, now converting data")
{:ok, Converter.Actor.as_to_model_data(data)}
else
# Actor is gone, probably deleted
{:ok, %HTTPoison.Response{status_code: 410}} ->
{:ok, %{status: 410}} ->
Logger.info("Response HTTP 410")
{:error, :actor_deleted}
@ -839,10 +695,11 @@ defmodule Mobilizon.Federation.ActivityPub do
"""
@spec fetch_public_activities_for_actor(Actor.t(), integer(), integer()) :: map()
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} =
Conversations.list_public_comments_for_actor(actor, page, limit)
%Page{total: total_comments, elements: comments} =
Discussions.list_public_comments_for_actor(actor, page, limit)
event_activities = Enum.map(events, &event_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", [])
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
defp check_for_tombstones(%{url: url}), do: Tombstone.find_tombstone(url)
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
defp accept_follow(%Follower{} = follower, additional) do
with {:ok, %Follower{} = follower} <- Actors.update_follower(follower, %{approved: true}),
@ -1254,138 +869,4 @@ defmodule Mobilizon.Federation.ActivityPub do
err
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

View File

@ -5,7 +5,7 @@ defmodule Mobilizon.Federation.ActivityPub.Audience do
alias Mobilizon.Actors
alias Mobilizon.Actors.Actor
alias Mobilizon.Conversations.Comment
alias Mobilizon.Discussions.{Comment, Discussion}
alias Mobilizon.Events.{Event, Participant}
alias Mobilizon.Share
alias Mobilizon.Storage.Repo
@ -79,6 +79,14 @@ defmodule Mobilizon.Federation.ActivityPub.Audience do
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
with mentioned_actors <- Enum.map(comment.mentions, &process_mention/1),
addressed_actors <- get_addressed_actors(mentioned_actors, nil),
@ -96,6 +104,28 @@ defmodule Mobilizon.Federation.ActivityPub.Audience do
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
with mentioned_actors <- Enum.map(event.mentions, &process_mention/1),
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
"""
alias Mobilizon.Actors
alias Mobilizon.Actors.Actor
alias Mobilizon.Federation.ActivityPub
alias Mobilizon.Federation.ActivityStream.Converter.Member, as: MemberConverter
alias Mobilizon.Federation.ActivityStream.Converter.Resource, as: ResourceConverter
alias Mobilizon.Federation.HTTPSignatures.Signature
alias Mobilizon.Resources
alias Mobilizon.Federation.ActivityPub.{Fetcher, Transmogrifier}
require Logger
import Mobilizon.Federation.ActivityPub.Utils,
only: [maybe_date_fetch: 2, sign_fetch: 4]
@spec fetch_group(String.t(), Actor.t()) :: :ok
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
fetch_collection(outbox_url, on_behalf_of)
fetch_collection(members_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
@ -30,12 +37,28 @@ defmodule Mobilizon.Federation.ActivityPub.Refresher do
Logger.debug("Fetching and preparing collection from 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")
process_collection(data, on_behalf_of)
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)
when type in ["OrderedCollection", "OrderedCollectionPage"] do
Logger.debug(
@ -55,55 +78,26 @@ defmodule Mobilizon.Federation.ActivityPub.Refresher do
when is_bitstring(first) do
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")
process_collection(data, on_behalf_of)
end
end
defp handling_element(%{"type" => "Member"} = data) do
Logger.debug("Handling Member element")
defp handling_element(data) when is_map(data) do
activity = %{
"type" => "Create",
"to" => data["to"],
"cc" => data["cc"],
"actor" => data["actor"],
"attributedTo" => data["attributedTo"],
"object" => data
}
data
|> MemberConverter.as_to_model_data()
|> Actors.create_member()
Transmogrifier.handle_incoming(activity)
end
defp handling_element(%{"type" => type} = data)
when type in ["Document", "ResourceCollection"] do
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
defp handling_element(uri) when is_binary(uri) do
ActivityPub.fetch_object_from_url(uri)
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.
"""
alias Mobilizon.{Actors, Conversations, Events, Resources, Todos}
alias Mobilizon.{Actors, Discussions, Events, Posts, Resources, Todos}
alias Mobilizon.Actors.{Actor, Follower, Member}
alias Mobilizon.Conversations.Comment
alias Mobilizon.Discussions.Comment
alias Mobilizon.Events.{Event, Participant}
alias Mobilizon.Posts.Post
alias Mobilizon.Resources.Resource
alias Mobilizon.Todos.{Todo, TodoList}
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.Tombstone
alias Mobilizon.Web.Email.{Group, Participation}
require Logger
@ -62,10 +64,20 @@ defmodule Mobilizon.Federation.ActivityPub.Transmogrifier do
with object_data when is_map(object_data) <-
object |> Converter.Comment.as_to_model_data(),
{:existing_comment, {:error, :comment_not_found}} <-
{:existing_comment, Conversations.get_comment_from_url_with_preload(object_data.url)},
{:ok, %Activity{} = activity, %Comment{} = comment} <-
ActivityPub.create(:comment, object_data, false) do
{:ok, activity, comment}
{:existing_comment, Discussions.get_comment_from_url_with_preload(object_data.url)},
object_data <- transform_object_data_for_discussion(object_data) do
# Check should be better
{: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
{:existing_comment, {:ok, %Comment{} = comment}} ->
{:ok, nil, comment}
@ -100,6 +112,77 @@ defmodule Mobilizon.Federation.ActivityPub.Transmogrifier do
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(
%{"type" => "Follow", "object" => followed, "actor" => follower, "id" => id} = _data
) do
@ -165,7 +248,7 @@ defmodule Mobilizon.Federation.ActivityPub.Transmogrifier do
Logger.info("Handle incoming to create a resource")
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} <-
{: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)},
{:ok, %Activity{} = activity, %Resource{} = resource} <-
ActivityPub.create(:resource, object_data, false),
{:ok, %Actor{type: :Group, id: group_id} = group} <-
ActivityPub.get_or_fetch_actor_by_url(group_url),
%Actor{type: :Group, id: group_id} = group <-
Actors.get_group_by_members_url(group_url),
announce_id <- "#{object_url}/announces/#{group_id}",
{:ok, _activity, _resource} <- ActivityPub.announce(group, object, announce_id) do
{:ok, activity, resource}
@ -190,7 +273,7 @@ defmodule Mobilizon.Federation.ActivityPub.Transmogrifier do
:error
{:error, e} ->
Logger.error(inspect(e))
Logger.debug(inspect(e))
:error
end
end
@ -261,23 +344,14 @@ defmodule Mobilizon.Federation.ActivityPub.Transmogrifier do
def handle_incoming(
%{"type" => "Announce", "object" => object, "actor" => _actor, "id" => _id} = data
) do
with actor <- Utils.get_actor(data),
# TODO: Is the following line useful?
{:ok, %Actor{id: actor_id, suspended: false} = _actor} <-
ActivityPub.get_or_fetch_actor_by_url(actor),
with actor_url <- Utils.get_actor(data),
{:ok, %Actor{id: actor_id, suspended: false} = actor} <-
ActivityPub.get_or_fetch_actor_by_url(actor_url),
:ok <- Logger.debug("Fetching contained object"),
{:ok, object} <- fetch_obj_helper_as_activity_streams(object),
:ok <- Logger.debug("Handling contained object"),
create_data <- Utils.make_create_data(object),
:ok <- Logger.debug(inspect(object)),
{: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}
{:ok, entity} <-
object |> Utils.get_url() |> fetch_object_optionnally_authenticated(actor),
:ok <- eventually_create_share(object, entity, actor_id) do
{:ok, nil, entity}
else
e ->
Logger.debug(inspect(e))
@ -296,7 +370,7 @@ defmodule Mobilizon.Federation.ActivityPub.Transmogrifier do
object_data <-
object |> Converter.Actor.as_to_model_data(),
{: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}
else
e ->
@ -317,7 +391,7 @@ defmodule Mobilizon.Federation.ActivityPub.Transmogrifier do
object_data <- Converter.Event.as_to_model_data(object),
{:origin_check, true} <- {:origin_check, Utils.origin_check?(actor_url, update_data)},
{: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}
else
_e ->
@ -325,6 +399,42 @@ defmodule Mobilizon.Federation.ActivityPub.Transmogrifier do
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(
%{
"type" => "Undo",
@ -367,21 +477,20 @@ defmodule Mobilizon.Federation.ActivityPub.Transmogrifier do
end
end
# TODO: We presently assume that any actor on the same origin domain as the object being
# deleted has the rights to delete that object. A better way to validate whether or not
# 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.
# We assume everyone on the same instance as the object
# or who is member of a group has the right to delete the object
def handle_incoming(
%{"type" => "Delete", "object" => object, "actor" => _actor, "id" => _id} = data
) do
with actor <- Utils.get_actor(data),
{:ok, %Actor{url: actor_url}} <- ActivityPub.get_or_fetch_actor_by_url(actor),
with actor_url <- Utils.get_actor(data),
{:ok, %Actor{} = actor} <- ActivityPub.get_or_fetch_actor_by_url(actor_url),
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, 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}
else
{:origin_check, false} ->
@ -449,6 +558,8 @@ defmodule Mobilizon.Federation.ActivityPub.Transmogrifier do
"target" => target
} = data
) do
Logger.info("Handle incoming to invite someone")
with {:ok, %Actor{} = actor} <-
data |> Utils.get_actor() |> ActivityPub.get_or_fetch_actor_by_url(),
{:ok, object} <- object |> Utils.get_url() |> ActivityPub.fetch_object_from_url(),
@ -485,7 +596,7 @@ defmodule Mobilizon.Federation.ActivityPub.Transmogrifier do
# end
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))
{:error, :not_supported}
end
@ -657,6 +768,52 @@ defmodule Mobilizon.Federation.ActivityPub.Transmogrifier do
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
with follow_object_id when not is_nil(follow_object_id) <- Utils.get_url(follow_object),
{: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.
"""
alias Mobilizon.Actors
alias Mobilizon.Actors.Actor
alias Mobilizon.Media.Picture
alias Mobilizon.Federation.ActivityPub
alias Mobilizon.Federation.ActivityPub.{Activity, Federator, Relay}
alias Mobilizon.Federation.ActivityPub.Types.Ownable
alias Mobilizon.Federation.ActivityStream.Converter
alias Mobilizon.Federation.HTTPSignatures
alias Mobilizon.Web.Endpoint
require Logger
@ -114,6 +117,53 @@ defmodule Mobilizon.Federation.ActivityPub.Utils do
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())
def remote_actors(recipients) do
recipients
@ -135,7 +185,7 @@ defmodule Mobilizon.Federation.ActivityPub.Utils do
Adds an id and a published data if they aren't there,
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
object = lazy_put_object_defaults(map["object"])
%{map | "object" => object}
@ -147,7 +197,7 @@ defmodule Mobilizon.Federation.ActivityPub.Utils do
@doc """
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)
end
@ -175,25 +225,49 @@ defmodule Mobilizon.Federation.ActivityPub.Utils do
@doc """
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
params = params |> Map.put("actor", actor) |> Map.delete("attributedTo")
origin_check?(id, params)
end
def origin_check?(id, %{"actor" => actor} = params) when not is_nil(actor) do
id_uri = URI.parse(id)
actor_uri = URI.parse(get_actor(params))
compare_uris?(actor_uri, id_uri)
def origin_check?(id, %{"actor" => actor} = params)
when not is_nil(actor) and is_list(actor) and length(actor) > 0 do
origin_check?(id, Map.put(params, "actor", hd(actor)))
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
@spec origin_check_from_id?(String.t(), String.t()) :: boolean()
def origin_check_from_id?(id, other_id) when is_binary(other_id) do
id_uri = URI.parse(id)
other_uri = URI.parse(other_id)
@ -201,9 +275,20 @@ defmodule Mobilizon.Federation.ActivityPub.Utils do
compare_uris?(id_uri, other_uri)
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),
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 """
Save picture data from %Plug.Upload{} and return AS Link data.
"""
@ -274,7 +359,7 @@ defmodule Mobilizon.Federation.ActivityPub.Utils do
activity_id,
public
)
when type in ["Note", "Event", "ResourceCollection", "Document"] do
when type in ["Note", "Event", "ResourceCollection", "Document", "Todo"] do
do_make_announce_data(
actor,
object_actor_url,
@ -367,6 +452,7 @@ defmodule Mobilizon.Federation.ActivityPub.Utils do
"type" => "Create",
"to" => object["to"],
"cc" => object["cc"],
"attributedTo" => object["attributedTo"] || object["actor"],
"actor" => object["actor"],
"object" => object,
"published" => make_date(),
@ -494,7 +580,7 @@ defmodule Mobilizon.Federation.ActivityPub.Utils do
@doc """
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
with %Actor{} = actor <- Relay.get_actor() do
sign_fetch(headers, actor, id, date)
@ -504,7 +590,7 @@ defmodule Mobilizon.Federation.ActivityPub.Utils do
@doc """
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
if Mobilizon.Config.get([:activitypub, :sign_object_fetches]) do
headers ++ make_signature(actor, id, date)
@ -516,7 +602,7 @@ defmodule Mobilizon.Federation.ActivityPub.Utils do
@doc """
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
if Mobilizon.Config.get([:activitypub, :sign_object_fetches]) do
headers ++ [{:Date, date}]
@ -524,4 +610,15 @@ defmodule Mobilizon.Federation.ActivityPub.Utils do
headers
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

View File

@ -8,7 +8,7 @@ defmodule Mobilizon.Federation.ActivityPub.Visibility do
Utility functions related to content visibility
"""
alias Mobilizon.Conversations.Comment
alias Mobilizon.Discussions.Comment
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,
name: data["name"],
preferred_username: data["preferredUsername"],
summary: data["summary"],
summary: data["summary"] || "",
keys: data["publicKey"]["publicKeyPem"],
inbox_url: data["inbox"],
outbox_url: data["outbox"],
@ -57,6 +57,10 @@ defmodule Mobilizon.Federation.ActivityStream.Converter.Actor do
followers_url: data["followers"],
members_url: data["members"],
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"],
domain: URI.parse(data["id"]).host,
manually_approves_followers: data["manuallyApprovesFollowers"],
@ -77,12 +81,15 @@ defmodule Mobilizon.Federation.ActivityStream.Converter.Actor do
"type" => actor.type,
"preferredUsername" => actor.preferred_username,
"name" => actor.name,
"summary" => actor.summary,
"summary" => actor.summary || "",
"following" => actor.following_url,
"followers" => actor.followers_url,
"members" => actor.members_url,
"resources" => actor.resources_url,
"todos" => actor.todos_url,
"posts" => actor.posts_url,
"events" => actor.events_url,
"discussions" => actor.discussions_url,
"inbox" => actor.inbox_url,
"outbox" => actor.outbox_url,
"url" => actor.url,

View File

@ -7,22 +7,30 @@ defmodule Mobilizon.Federation.ActivityStream.Converter.Comment do
"""
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.Tombstone, as: TombstoneModel
alias Mobilizon.Federation.ActivityPub
alias Mobilizon.Federation.ActivityPub.Visibility
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
@behaviour Converter
defimpl Convertible, for: CommentModel do
alias Mobilizon.Federation.ActivityStream.Converter.Comment, as: CommentConverter
defdelegate model_to_as(comment), to: CommentConverter
end
@ -35,28 +43,108 @@ defmodule Mobilizon.Federation.ActivityStream.Converter.Comment do
Logger.debug("We're converting raw ActivityStream data to a comment entity")
Logger.debug(inspect(object))
with author_url <- Map.get(object, "actor") || Map.get(object, "attributedTo"),
{:ok, %Actor{id: actor_id, domain: domain, suspended: false}} <-
ActivityPub.get_or_fetch_actor_by_url(author_url),
{:tags, tags} <- {:tags, ConverterUtils.fetch_tags(Map.get(object, "tag", []))},
with {%Actor{id: actor_id, domain: actor_domain}, attributed_to} <-
maybe_fetch_actor_and_attributed_to_id(object),
{:tags, tags} <- {:tags, fetch_tags(Map.get(object, "tag", []))},
{: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(inspect(object))
data = %{
text: object["content"],
url: object["id"],
# Will be used in conversations, ignored in basic comments
title: object["name"],
context: object["context"],
actor_id: actor_id,
attributed_to_id: if(is_nil(attributed_to), do: nil, else: attributed_to.id),
in_reply_to_comment_id: nil,
event_id: nil,
uuid: object["uuid"],
discussion_id: if(is_nil(discussion), do: nil, else: discussion.id),
tags: tags,
mentions: mentions,
local: is_nil(domain),
local: is_nil(actor_domain),
visibility: if(Visibility.is_public?(object), do: :public, else: :private)
}
maybe_fetch_parent_object(object, data)
else
{:ok, %Actor{suspended: true}} ->
:error
end
end
@doc """
Make an AS comment object from an existing `Comment` structure.
"""
@impl Converter
@spec model_to_as(CommentModel.t()) :: map
def model_to_as(%CommentModel{deleted_at: nil} = comment) do
to = determine_to(comment)
object = %{
"type" => "Note",
"to" => to,
"cc" => [],
"content" => comment.text,
"mediaType" => "text/html",
"actor" => comment.actor.url,
"attributedTo" =>
if(is_nil(comment.attributed_to), do: nil, else: comment.attributed_to.url) ||
comment.actor.url,
"uuid" => comment.uuid,
"id" => comment.url,
"tag" => build_mentions(comment.mentions) ++ build_tags(comment.tags)
}
object =
if comment.discussion_id,
do: Map.put(object, "context", comment.discussion.url),
else: object
cond do
comment.in_reply_to_comment ->
Map.put(object, "inReplyTo", comment.in_reply_to_comment.url)
comment.event ->
Map.put(object, "inReplyTo", comment.event.url)
true ->
object
end
end
@doc """
A "soft-deleted" comment is a tombstone
"""
@impl Converter
@spec model_to_as(CommentModel.t()) :: map
def model_to_as(%CommentModel{} = comment) do
Convertible.model_to_as(%TombstoneModel{
uri: comment.url,
inserted_at: comment.deleted_at
})
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")
@ -79,6 +167,19 @@ defmodule Mobilizon.Federation.ActivityStream.Converter.Comment do
|> 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")
@ -90,58 +191,5 @@ defmodule Mobilizon.Federation.ActivityStream.Converter.Comment do
data
end
else
{:ok, %Actor{suspended: true}} ->
:error
end
end
@doc """
Make an AS comment object from an existing `Comment` structure.
"""
@impl Converter
@spec model_to_as(CommentModel.t()) :: map
def model_to_as(%CommentModel{deleted_at: nil} = comment) do
to =
if comment.visibility == :public,
do: ["https://www.w3.org/ns/activitystreams#Public"],
else: [comment.actor.followers_url]
object = %{
"type" => "Note",
"to" => to,
"cc" => [],
"content" => comment.text,
"mediaType" => "text/html",
"actor" => comment.actor.url,
"attributedTo" => comment.actor.url,
"uuid" => comment.uuid,
"id" => comment.url,
"tag" =>
ConverterUtils.build_mentions(comment.mentions) ++ ConverterUtils.build_tags(comment.tags)
}
cond do
comment.in_reply_to_comment ->
Map.put(object, "inReplyTo", comment.in_reply_to_comment.url)
comment.event ->
Map.put(object, "inReplyTo", comment.event.url)
true ->
object
end
end
@impl Converter
@spec model_to_as(CommentModel.t()) :: map
@doc """
A "soft-deleted" comment is a tombstone
"""
def model_to_as(%CommentModel{} = comment) do
Convertible.model_to_as(%TombstoneModel{
uri: comment.url,
inserted_at: comment.deleted_at
})
end
end

View File

@ -6,6 +6,8 @@ defmodule Mobilizon.Federation.ActivityStream.Converter do
one, and back.
"""
@callback as_to_model_data(map) :: map
@callback model_to_as(struct) :: map
@type model_data :: map()
@callback as_to_model_data(as_data :: ActivityStream.t()) :: model_data()
@callback model_to_as(model :: struct()) :: ActivityStream.t()
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.Media.Picture
alias Mobilizon.Federation.ActivityPub
alias Mobilizon.Federation.ActivityStream.{Converter, Convertible}
alias Mobilizon.Federation.ActivityStream.Converter.Address, as: AddressConverter
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
@ -34,16 +40,12 @@ defmodule Mobilizon.Federation.ActivityStream.Converter.Event do
@impl Converter
@spec as_to_model_data(map) :: {:ok, map()} | {:error, any()}
def as_to_model_data(object) do
Logger.debug("event as_to_model_data")
Logger.debug(inspect(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)},
with {%Actor{id: actor_id, domain: actor_domain}, attributed_to} <-
maybe_fetch_actor_and_attributed_to_id(object),
{:address, address_id} <-
{:address, get_address(object["location"])},
{:tags, tags} <- {:tags, ConverterUtils.fetch_tags(object["tag"])},
{:mentions, mentions} <- {:mentions, ConverterUtils.fetch_mentions(object["tag"])},
{:tags, tags} <- {:tags, fetch_tags(object["tag"])},
{:mentions, mentions} <- {:mentions, fetch_mentions(object["tag"])},
{:visibility, visibility} <- {:visibility, get_visibility(object)},
{:options, options} <- {:options, get_options(object)} do
attachments =
@ -67,6 +69,7 @@ defmodule Mobilizon.Federation.ActivityStream.Converter.Event do
title: object["name"],
description: object["content"],
organizer_actor_id: actor_id,
attributed_to_id: if(is_nil(attributed_to), do: nil, else: attributed_to.id),
picture_id: picture_id,
begins_on: object["startTime"],
ends_on: object["endTime"],
@ -108,7 +111,9 @@ defmodule Mobilizon.Federation.ActivityStream.Converter.Event do
"type" => "Event",
"to" => to,
"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,
"actor" => event.organizer_actor.url,
"uuid" => event.uuid,
@ -120,7 +125,7 @@ defmodule Mobilizon.Federation.ActivityStream.Converter.Event do
"startTime" => event.begins_on |> date_to_string(),
"joinMode" => to_string(event.join_options),
"endTime" => event.ends_on |> date_to_string(),
"tag" => event.tags |> ConverterUtils.build_tags(),
"tag" => event.tags |> build_tags(),
"maximumAttendeeCapacity" => event.options.maximum_attendee_capacity,
"repliesModerationOption" => event.options.comment_moderation,
"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.Conversations
alias Mobilizon.Discussions
alias Mobilizon.Events
alias Mobilizon.Events.Event
alias Mobilizon.Reports.Report
@ -92,7 +92,7 @@ defmodule Mobilizon.Federation.ActivityStream.Converter.Flag do
Enum.filter(objects, fn url ->
!(url == reported.url || (!is_nil(event) && event.url == url))
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,
"uri" => object["id"],

View File

@ -39,7 +39,7 @@ defmodule Mobilizon.Federation.ActivityStream.Converter.Picture do
actor_id
)
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}} <-
Upload.store(%{body: body, name: name}),
{: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",
"actor" => group_url,
"id" => todo_list.url,
"title" => todo_list.title
"name" => todo_list.title
}
end

View File

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

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