Introduce group posts
Signed-off-by: Thomas Citharel <tcit@tcit.fr>
This commit is contained in:
parent
a6fad4bc17
commit
a338caca5e
@ -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
|
||||
|
||||
|
@ -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
|
||||
|
@ -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",
|
||||
|
@ -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() {
|
||||
|
@ -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: {
|
||||
|
@ -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>
|
@ -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;
|
@ -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);
|
||||
}
|
||||
|
||||
|
@ -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=${
|
||||
|
@ -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>
|
||||
|
||||
|
@ -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,
|
||||
|
48
js/src/components/Post/PostListItem.vue
Normal file
48
js/src/components/Post/PostListItem.vue
Normal 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>
|
@ -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;
|
||||
|
@ -81,7 +81,7 @@ a {
|
||||
flex: 1;
|
||||
|
||||
.preview {
|
||||
flex: 0 0 100px;
|
||||
flex: 0 0 50px;
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
@ -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;
|
||||
|
@ -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() {
|
||||
|
@ -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() {
|
||||
|
@ -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: {
|
||||
|
@ -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 },
|
||||
})
|
||||
|
@ -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) {
|
||||
|
@ -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}
|
||||
`;
|
158
js/src/graphql/discussion.ts
Normal file
158
js/src/graphql/discussion.ts
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
@ -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
151
js/src/graphql/post.ts
Normal 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
|
||||
}
|
||||
}
|
||||
`;
|
@ -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 {
|
||||
|
@ -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": "رابط جديد",
|
||||
|
@ -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}»?",
|
||||
|
@ -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}\"?",
|
||||
|
@ -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?",
|
||||
|
@ -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}"
|
||||
}
|
||||
|
@ -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",
|
||||
|
@ -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",
|
||||
|
@ -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}"
|
||||
}
|
||||
|
@ -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 qu’a la plaça volètz començar una conversacion amb l’organizaire 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 qu’a la plaça volètz començar una conversacion amb l’organizaire 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 l’eveniment ? 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 l’eveniment ? 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 l’eveniment « {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",
|
||||
|
@ -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}\"?",
|
||||
|
@ -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);
|
||||
|
@ -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 },
|
||||
},
|
||||
];
|
34
js/src/router/discussion.ts
Normal file
34
js/src/router/discussion.ts
Normal 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 },
|
||||
},
|
||||
];
|
@ -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,
|
||||
},
|
||||
];
|
||||
|
@ -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?",
|
||||
|
@ -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,
|
||||
};
|
||||
|
@ -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);
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
@ -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>;
|
||||
}
|
44
js/src/types/discussions.ts
Normal file
44
js/src/types/discussions.ts
Normal 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;
|
||||
}
|
||||
}
|
26
js/src/types/post.model.ts
Normal file
26
js/src/types/post.model.ts
Normal 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;
|
||||
}
|
@ -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);
|
||||
|
||||
|
@ -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),
|
||||
|
@ -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> {
|
||||
|
@ -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;
|
||||
|
||||
|
@ -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) {
|
||||
|
@ -270,6 +270,7 @@ export default class Settings extends Vue {
|
||||
adminSettings!: IAdminSettings;
|
||||
|
||||
InstanceTermsType = InstanceTermsType;
|
||||
|
||||
InstancePrivacyType = InstancePrivacyType;
|
||||
|
||||
RouteName = RouteName;
|
||||
|
@ -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) {
|
||||
|
@ -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>
|
@ -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) {
|
350
js/src/views/Discussions/Discussion.vue
Normal file
350
js/src/views/Discussions/Discussion.vue
Normal 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>
|
@ -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;
|
@ -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>({
|
||||
|
@ -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
|
||||
|
@ -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() {
|
||||
|
@ -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 {
|
||||
|
@ -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>
|
||||
|
91
js/src/views/Group/GroupSettings.vue
Normal file
91
js/src/views/Group/GroupSettings.vue
Normal 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>
|
@ -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) {
|
||||
|
@ -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
217
js/src/views/Posts/Edit.vue
Normal 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>
|
86
js/src/views/Posts/List.vue
Normal file
86
js/src/views/Posts/List.vue
Normal 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
184
js/src/views/Posts/Post.vue
Normal 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>
|
@ -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) {
|
||||
|
1359
js/yarn.lock
1359
js/yarn.lock
File diff suppressed because it is too large
Load Diff
@ -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
|
||||
|
@ -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),
|
||||
|
74
lib/federation/activity_pub/fetcher.ex
Normal file
74
lib/federation/activity_pub/fetcher.ex
Normal 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
|
30
lib/federation/activity_pub/preloader.ex
Normal file
30
lib/federation/activity_pub/preloader.ex
Normal 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
|
@ -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
|
||||
|
@ -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} <-
|
||||
|
74
lib/federation/activity_pub/types/actors.ex
Normal file
74
lib/federation/activity_pub/types/actors.ex
Normal 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
|
149
lib/federation/activity_pub/types/comments.ex
Normal file
149
lib/federation/activity_pub/types/comments.ex
Normal 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
|
115
lib/federation/activity_pub/types/discussions.ex
Normal file
115
lib/federation/activity_pub/types/discussions.ex
Normal 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
|
151
lib/federation/activity_pub/types/entity.ex
Normal file
151
lib/federation/activity_pub/types/entity.ex
Normal 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
|
203
lib/federation/activity_pub/types/events.ex
Normal file
203
lib/federation/activity_pub/types/events.ex
Normal 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
|
93
lib/federation/activity_pub/types/posts.ex
Normal file
93
lib/federation/activity_pub/types/posts.ex
Normal 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
|
43
lib/federation/activity_pub/types/reports.ex
Normal file
43
lib/federation/activity_pub/types/reports.ex
Normal 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
|
157
lib/federation/activity_pub/types/resources.ex
Normal file
157
lib/federation/activity_pub/types/resources.ex
Normal 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
|
69
lib/federation/activity_pub/types/todo_lists.ex
Normal file
69
lib/federation/activity_pub/types/todo_lists.ex
Normal 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
|
80
lib/federation/activity_pub/types/todos.ex
Normal file
80
lib/federation/activity_pub/types/todos.ex
Normal 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
|
14
lib/federation/activity_pub/types/tombstones.ex
Normal file
14
lib/federation/activity_pub/types/tombstones.ex
Normal 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
|
@ -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
|
||||
|
@ -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
|
||||
|
||||
|
7
lib/federation/activity_stream.ex
Normal file
7
lib/federation/activity_stream.ex
Normal file
@ -0,0 +1,7 @@
|
||||
defmodule Mobilizon.Federation.ActivityStream do
|
||||
@moduledoc """
|
||||
The ActivityStream Type
|
||||
"""
|
||||
|
||||
@type t :: map()
|
||||
end
|
@ -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,
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
63
lib/federation/activity_stream/converter/discussion.ex
Normal file
63
lib/federation/activity_stream/converter/discussion.ex
Normal 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
|
@ -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,
|
||||
|
@ -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"],
|
||||
|
@ -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
|
||||
|
70
lib/federation/activity_stream/converter/post.ex
Normal file
70
lib/federation/activity_stream/converter/post.ex
Normal 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
|
@ -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
|
||||
|
||||
|
@ -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
Loading…
Reference in New Issue
Block a user