Merge branch 'feat-private-messages' into 'main'

Private messages

Closes #496

See merge request framasoft/mobilizon!1477
This commit is contained in:
Thomas Citharel 2023-11-20 16:58:20 +00:00
commit dcfcf066f9
676 changed files with 17527 additions and 9061 deletions

View file

@ -22,7 +22,7 @@
# In the latter case `**/*.{ex,exs}` will be used.
#
included: ["lib/", "src/", "test/"],
excluded: [~r"/_build/", ~r"/deps/", ~r"/js/"]
excluded: [~r"/_build/", ~r"/deps/", ~r"/src/"]
},
#
# If you create your own checks, you must specify the source files for

View file

@ -1,44 +1,46 @@
// For format details, see https://aka.ms/devcontainer.json. For config options, see the README at:
// https://github.com/microsoft/vscode-dev-containers/tree/v0.209.6/containers/elixir-phoenix-postgres
{
"name": "Elixir, Phoenix, Node.js & PostgresSQL (Community)",
"dockerComposeFile": "docker-compose.yml",
"service": "elixir",
"workspaceFolder": "/workspace",
"name": "Elixir, Phoenix, Node.js & PostgresSQL (Community)",
"dockerComposeFile": "docker-compose.yml",
"service": "elixir",
"workspaceFolder": "/workspace",
// Set *default* container specific settings.json values on container create.
"settings": {
"sqltools.connections": [{
"name": "Container database",
"driver": "PostgreSQL",
"previewLimit": 50,
"server": "localhost",
"port": 5432,
"database": "postgres",
"username": "postgres",
"password": "postgres"
}]
},
// Set *default* container specific settings.json values on container create.
"settings": {
"sqltools.connections": [
{
"name": "Container database",
"driver": "PostgreSQL",
"previewLimit": 50,
"server": "localhost",
"port": 5432,
"database": "postgres",
"username": "postgres",
"password": "postgres"
}
]
},
// Add the IDs of extensions you want installed when the container is created.
"extensions": [
"jakebecker.elixir-ls",
"mtxr.sqltools",
"mtxr.sqltools-driver-pg"
],
// Add the IDs of extensions you want installed when the container is created.
"extensions": [
"jakebecker.elixir-ls",
"mtxr.sqltools",
"mtxr.sqltools-driver-pg"
],
// Use 'forwardPorts' to make a list of ports inside the container available locally.
"forwardPorts": [4000, 4001, 5432],
// Use 'forwardPorts' to make a list of ports inside the container available locally.
"forwardPorts": [4000, 4001, 5432],
// Use 'postCreateCommand' to run commands after the container is created.
// "postCreateCommand": "mix deps.get",
// "runArgs": ["--userns=keep-id", "--privileged"],
// "containerUser": "vscode",
// "containerEnv": {
// "HOME": "/home/vscode",
// },
// "workspaceMount": "source=${localWorkspaceFolder},target=/workspace,type=bind,Z",
// Use 'postCreateCommand' to run commands after the container is created.
// "postCreateCommand": "mix deps.get",
// "runArgs": ["--userns=keep-id", "--privileged"],
// "containerUser": "vscode",
// "containerEnv": {
// "HOME": "/home/vscode",
// },
// "workspaceMount": "source=${localWorkspaceFolder},target=/workspace,type=bind,Z",
// Uncomment to connect as a non-root user. See https://aka.ms/vscode-remote/containers/non-root.
"remoteUser": "vscode"
// Uncomment to connect as a non-root user. See https://aka.ms/vscode-remote/containers/non-root.
"remoteUser": "vscode"
}

View file

@ -15,5 +15,5 @@ Makefile
README.md
SECURITY.md
ssh_match_hostname
.js/package-lock.json
js/node_modules
package-lock.json
node_modules

9
.gitignore vendored
View file

@ -46,7 +46,14 @@ release/
.weblate
docker/production/.env
test-junit-report.xml
js/junit.xml
junit.xml
.env
demo/
codeclimate.json
node_modules
stats.html
/coverage
/playwright-report/
.histoire

View file

@ -13,7 +13,6 @@ stages:
variables:
MIX_ENV: "test"
YARN_CACHE_FOLDER: "js/.yarn"
# DB Variables for Postgres / Postgis
POSTGRES_DB: mobilizon_test
POSTGRES_USER: postgres
@ -38,8 +37,8 @@ cache:
paths:
- deps/
- _build/
- js/node_modules
- js/.yarn
- node_modules
- .npm
# Installed dependencies are cached across the pipeline
# So there is no need to reinstall them all the time
@ -47,7 +46,7 @@ cache:
install:
stage: install
script:
- yarn --cwd "js" install --frozen-lockfile
- npm ci
- mix deps.get
- mix compile
@ -68,27 +67,26 @@ lint-elixir:
reports:
codequality: codeclimate.json
lint-front:
image: node:16
image: node:20
stage: check
before_script:
- export EXITVALUE=0
- yarn --cwd "js" install --frozen-lockfile
- npm ci
script:
- yarn --cwd "js" run lint || export EXITVALUE=1
- yarn --cwd "js" run prettier -c . || export EXITVALUE=1
- npm run lint || export EXITVALUE=1
- npx prettier -c . || export EXITVALUE=1
- exit $EXITVALUE
build-frontend:
stage: build-js
image: node:16
image: node:20
before_script:
- apt update
- apt install -y --no-install-recommends python build-essential webp imagemagick gifsicle jpegoptim optipng pngquant
- apt install -y --no-install-recommends python3 build-essential webp imagemagick gifsicle jpegoptim optipng pngquant
script:
- yarn --cwd "js" install --frozen-lockfile
- yarn --cwd "js" run build
- npm install --frozen-lockfile
- npm run build
artifacts:
expire_in: 5 days
paths:
@ -118,7 +116,7 @@ deps:
script:
- export EXITVALUE=0
- mix hex.outdated || export EXITVALUE=1
- yarn --cwd "js" outdated || export EXITVALUE=1
- npm outdated || export EXITVALUE=1
- exit $EXITVALUE
allow_failure: true
needs:
@ -151,16 +149,16 @@ vitest:
needs:
- lint-front
before_script:
- yarn --cwd "js" install --frozen-lockfile
- npm install --frozen-lockfile
script:
- yarn --cwd "js" run coverage --reporter=default --reporter=junit --outputFile.junit=./junit.xml
- npm run coverage --reporter=default --reporter=junit --outputFile.junit=./junit.xml
artifacts:
when: always
paths:
- js/coverage
- coverage
reports:
junit:
- js/junit.xml
- junit.xml
expire_in: 30 days
e2e:
@ -175,21 +173,20 @@ e2e:
- mix ecto.create
- mix ecto.migrate
- mix run priv/repo/e2e.seed.exs
- cd js && yarn install && yarn run build && npx playwright install && cd ../
- npm install && npm run build && npx playwright install
- mix phx.digest
script:
- mix phx.server &
- cd js
- npx wait-on http://localhost:4000
- npx playwright test --project $BROWSER
parallel:
matrix:
- BROWSER: ['firefox', 'chromium']
- BROWSER: ["firefox", "chromium"]
artifacts:
expire_in: 2 days
paths:
- js/playwright-report/
- js/test-results/
- playwright-report/
- test-results/
pages:
stage: deploy
@ -198,8 +195,8 @@ pages:
- mix deps.get
- mix docs
- mv doc public/backend
# #- yarn run --cwd "js" styleguide:build
# #- mv js/styleguide public/frontend
# #- npm run styleguide:build
# #- mv styleguide public/frontend
rules:
- if: '$CI_COMMIT_BRANCH == "main"'
artifacts:
@ -270,7 +267,6 @@ build-and-push-to-latest-docker-tag:
- ARCH: ["arm64"]
ERL_FLAGS: ["ERL_FLAGS=+JMsingle true"]
# Don't push to latest when building beta/rc tags
build-and-push-docker-tag:
<<: *docker

View file

@ -1,4 +1,3 @@
#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"
cd js
yarn run lint-staged
npm run pre-commit

4
.prettierignore Normal file
View file

@ -0,0 +1,4 @@
src/i18n/*.json
coverage/
**/*.md
test/fixtures

View file

@ -3,18 +3,18 @@
1C29EE70E90ECED01AF28EC58D2575B5
31CE26BC979C57B9E3CC97B40C290CE5
3529E7A4CECC24D02678820E6F521162
4A4B7002DEB734A943B467DF7D2BD1AA
37E854EA3BDF7275C6A7631F80804EC4
4E7C044C59E0BCB76AA826789998F624
53CBBEB6243FAF5C37249CBA17DE6F4C
5BCE3651A03711295046DE48BDFE007E
5C16A2AE6A24E4795F95DDE20EEC458E
5C4CED447689F00D9D1ACEB9B895ED29
630C0972985257251EDF89A7117DE423
8274AF29B81EC7CF7F585D5EED8A64E1
94ACF7B17C3FF42F64E57DD1DA936BD8
A32E125003F1EDFAD95C487C6A969725
ACF6272A1DBB3A2ABD96C0C120B5CA69
C46C4893B2F702ACADC4CAA5683FE370
CDF2CCE0CF10F49CDFAE22FE26208155
E720CB13C50FF3ADEE7C522531E11217
E8FC5F2C5DEA6671BA596B022C4FE6F2
F3D5851D3FB050939841ED2F14307A27
FD1C9756370A195B74E95CE504C45E9E

View file

@ -1,6 +1,6 @@
FROM elixir:alpine
RUN apk add --no-cache inotify-tools postgresql-client yarn file make gcc libc-dev argon2 imagemagick cmake build-base libwebp-tools bash ncurses git python3
RUN apk add --no-cache inotify-tools postgresql-client file make gcc libc-dev argon2 imagemagick cmake build-base libwebp-tools bash ncurses git python3
RUN mix local.hex --force && mix local.rebar --force

View file

@ -4,7 +4,7 @@ init:
setup: stop
@bash docker/message.sh "Compiling everything"
docker-compose run --rm api bash -c 'mix deps.get; yarn --cwd "js"; yarn --cwd "js" build:pictures; mix ecto.create; mix ecto.migrate'
docker-compose run --rm api bash -c 'mix deps.get; npm ci; npm run build:pictures; mix ecto.create; mix ecto.migrate'
migrate:
docker-compose run --rm api mix ecto.migrate
logs:
@ -19,6 +19,7 @@ stop:
@bash docker/message.sh "Mobilizon is stopped"
test: stop
@bash docker/message.sh "Running tests"
docker-compose -f docker-compose.yml -f docker-compose.test.yml run api mix prepare_test
docker-compose -f docker-compose.yml -f docker-compose.test.yml run api mix test $(only)
@bash docker/message.sh "Done running tests"
format:

View file

@ -7,6 +7,6 @@ module.exports = {
localSchemaFile: "./schema.graphql",
},
// Files processed by the extension
includes: ["js/src/**/*.vue", "js/src/**/*.js"],
includes: ["src/**/*.vue", "src/**/*.js"],
},
};

View file

@ -15,8 +15,7 @@ config :mobilizon, Mobilizon.Web.Endpoint,
check_origin: false,
watchers: [
node: [
"node_modules/.bin/vite",
cd: Path.expand("../js", __DIR__)
"node_modules/.bin/vite"
]
]

View file

@ -16,7 +16,7 @@ config :mobilizon, Mobilizon.Web.Endpoint,
check_origin: false,
# Somehow this can't be merged properly with the dev config so we got this…
watchers: [
yarn: [cd: Path.expand("../js", __DIR__)]
npm: []
]
config :vite_phx,

View file

@ -11,7 +11,7 @@ services:
MIX_ENV: "test"
MOBILIZON_DATABASE_DBNAME: mobilizon_test
MOBILIZON_INSTANCE_HOST: mobilizon.test
command: "mix test"
command: "mix prepare_test && mix test"
volumes:
pgdata:
.:

View file

@ -18,9 +18,8 @@ ENV NODE_VERSION 18
RUN apt-get update -yq && apt-get install -yq build-essential cmake postgresql-client git curl gnupg unzip exiftool webp imagemagick gifsicle
RUN apt-get clean && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
# # Install Node & yarn
# # Install Node
# RUN curl -sL https://deb.nodesource.com/setup_16.x | bash && apt-get install nodejs -yq
# RUN npm install -g yarn
# Install build tools
RUN source /root/.bashrc && \
@ -32,8 +31,8 @@ COPY ./ /mobilizon
WORKDIR /mobilizon
# # Build front-end
# RUN yarn --cwd "js" install --frozen-lockfile
# RUN yarn --cwd "js" run build
# RUN npm install
# RUN npm run build
# Elixir release
RUN source /root/.bashrc && \

View file

@ -3,11 +3,10 @@ FROM node:18-alpine as assets
RUN apk add --no-cache python3 build-base libwebp-tools bash imagemagick ncurses
WORKDIR /build
COPY js .
COPY . .
# Network timeout because it's slow when cross-compiling
RUN yarn install --network-timeout 100000 \
&& yarn run build
RUN npm install && npm run build
# Then, build the application binary
FROM elixir:1.15-alpine AS builder

View file

@ -4,7 +4,7 @@ LABEL maintainer="Thomas Citharel <tcit@tcit.fr>"
ENV REFRESHED_AT=2023-08-17
RUN apt-get update -yq && apt-get install -yq build-essential inotify-tools postgresql-client git curl gnupg xvfb libgtk-3-dev libnotify-dev libgconf-2-4 libnss3 libxss1 libasound2 cmake exiftool python3-pip python3-setuptools
RUN curl -sL https://deb.nodesource.com/setup_20.x | bash && apt-get install nodejs -yq
RUN npm install -g yarn wait-on
RUN npm install -g wait-on
RUN apt-get clean && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
RUN mix local.hex --force && mix local.rebar --force
RUN pip3 install -Iv weasyprint pyexcel_ods3

View file

@ -19,7 +19,7 @@ Mobilizon is an app that uses:
* `config` backend compile-time and runtime configuration
* `docker` 🐳
* `js/src` Front-end
* `src` Front-end
* `lib/federation` Handling all the federation stuff (sending and receving activities, converting activities, signatures, helpers…)
* `lib/graphql/schema` The schema declarations for the GraphQL API
* `lib/graphql/resolvers` The logic behind the GraphQL API

View file

View file

@ -8,6 +8,7 @@ export default defineConfig({
plugins: [HstVue()],
setupFile: path.resolve(__dirname, "./src/histoire.setup.ts"),
viteNodeInlineDeps: [/date-fns/],
// viteIgnorePlugins: ['vite-plugin-pwa', 'vite-plugin-pwa:build', 'vite-plugin-pwa:info'],
tree: {
groups: [
{

27
js/.gitignore vendored
View file

@ -1,27 +0,0 @@
.DS_Store
node_modules
/dist
/coverage
stats.html
# local env files
.env.local
.env.*.local
# Log files
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Editor directories and files
.idea
.vscode
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
/test-results/
/playwright-report/
/playwright/.cache/

View file

@ -1,2 +0,0 @@
src/i18n/*.json
coverage/

View file

@ -1,20 +0,0 @@
import { FILTER_TAGS } from "@/graphql/tags";
import { ITag } from "@/types/tag.model";
import { apolloClient, waitApolloQuery } from "@/vue-apollo";
import { provideApolloClient, useQuery } from "@vue/apollo-composable";
export async function fetchTags(text: string): Promise<ITag[]> {
try {
const res = await waitApolloQuery(
provideApolloClient(apolloClient)(() =>
useQuery<{ tags: ITag[] }, { filter: string }>(FILTER_TAGS, {
filter: text,
})
)
);
return res.data.tags;
} catch (e) {
console.error(e);
return [];
}
}

View file

@ -1,5 +0,0 @@
declare module "@absinthe/socket";
declare module "@absinthe/socket-apollo-link";
declare module "apollo-absinthe-upload-link";

View file

@ -1,38 +0,0 @@
import {
ApolloClient,
ApolloQueryResult,
NormalizedCacheObject,
OperationVariables,
} from "@apollo/client/core";
import buildCurrentUserResolver from "@/apollo/user";
import { cache } from "./apollo/memory";
import { fullLink } from "./apollo/link";
import { UseQueryReturn } from "@vue/apollo-composable";
export const apolloClient = new ApolloClient<NormalizedCacheObject>({
cache,
link: fullLink,
connectToDevTools: true,
resolvers: buildCurrentUserResolver(cache),
});
export function waitApolloQuery<
TResult = any,
TVariables extends OperationVariables = OperationVariables,
>({
onResult,
onError,
}: UseQueryReturn<TResult, TVariables>): Promise<ApolloQueryResult<TResult>> {
return new Promise((res, rej) => {
const { off: offResult } = onResult((result) => {
if (result.loading === false) {
offResult();
res(result);
}
});
const { off: offError } = onError((error) => {
offError();
rej(error);
});
});
}

View file

@ -1,67 +0,0 @@
// Vitest Snapshot v1
exports[`CommentTree > renders a comment tree with comments 1`] = `
"<div data-v-5d0380ab=\\"\\">
<form class=\\"\\" data-v-5d0380ab=\\"\\">
<!--v-if-->
<article class=\\"flex flex-wrap items-start gap-2\\" data-v-5d0380ab=\\"\\">
<figure class=\\"\\" data-v-5d0380ab=\\"\\">
<identity-picker-wrapper-stub modelvalue=\\"[object Object]\\" inline=\\"false\\" masked=\\"false\\" data-v-5d0380ab=\\"\\"></identity-picker-wrapper-stub>
</figure>
<div class=\\"flex-1\\" data-v-5d0380ab=\\"\\">
<div class=\\"flex flex-col gap-2\\" data-v-5d0380ab=\\"\\">
<div class=\\"editor-wrapper\\" data-v-5d0380ab=\\"\\">
<editor-stub currentactor=\\"[object Object]\\" mode=\\"comment\\" modelvalue=\\"\\" aria-label=\\"Comment body\\" data-v-5d0380ab=\\"\\"></editor-stub>
<!--v-if-->
</div>
<!--v-if-->
</div>
</div>
<div class=\\"\\" data-v-5d0380ab=\\"\\">
<o-button-stub variant=\\"primary\\" iconleft=\\"send\\" rounded=\\"false\\" outlined=\\"false\\" expanded=\\"false\\" inverted=\\"false\\" nativetype=\\"submit\\" tag=\\"button\\" disabled=\\"false\\" iconboth=\\"false\\" data-v-5d0380ab=\\"\\"></o-button-stub>
</div>
</article>
</form>
<transition-group-stub data-v-5d0380ab=\\"\\">
<transition-group-stub data-v-5d0380ab=\\"\\">
<comment-stub comment=\\"[object Object]\\" event=\\"[object Object]\\" currentactor=\\"[object Object]\\" rootcomment=\\"true\\" class=\\"root-comment\\" data-v-5d0380ab=\\"\\"></comment-stub>
<comment-stub comment=\\"[object Object]\\" event=\\"[object Object]\\" currentactor=\\"[object Object]\\" rootcomment=\\"true\\" class=\\"root-comment\\" data-v-5d0380ab=\\"\\"></comment-stub>
</transition-group-stub>
</transition-group-stub>
</div>"
`;
exports[`CommentTree > renders a loading comment tree 1`] = `
"<div data-v-5d0380ab=\\"\\">
<!--v-if-->
<p class=\\"text-center\\" data-v-5d0380ab=\\"\\">Loading comments…</p>
</div>"
`;
exports[`CommentTree > renders an empty comment tree 1`] = `
"<div data-v-5d0380ab=\\"\\">
<form class=\\"\\" data-v-5d0380ab=\\"\\">
<!--v-if-->
<article class=\\"flex flex-wrap items-start gap-2\\" data-v-5d0380ab=\\"\\">
<figure class=\\"\\" data-v-5d0380ab=\\"\\">
<identity-picker-wrapper-stub modelvalue=\\"[object Object]\\" inline=\\"false\\" masked=\\"false\\" data-v-5d0380ab=\\"\\"></identity-picker-wrapper-stub>
</figure>
<div class=\\"flex-1\\" data-v-5d0380ab=\\"\\">
<div class=\\"flex flex-col gap-2\\" data-v-5d0380ab=\\"\\">
<div class=\\"editor-wrapper\\" data-v-5d0380ab=\\"\\">
<editor-stub currentactor=\\"[object Object]\\" mode=\\"comment\\" modelvalue=\\"\\" aria-label=\\"Comment body\\" data-v-5d0380ab=\\"\\"></editor-stub>
<!--v-if-->
</div>
<!--v-if-->
</div>
</div>
<div class=\\"\\" data-v-5d0380ab=\\"\\">
<o-button-stub variant=\\"primary\\" iconleft=\\"send\\" rounded=\\"false\\" outlined=\\"false\\" expanded=\\"false\\" inverted=\\"false\\" nativetype=\\"submit\\" tag=\\"button\\" disabled=\\"false\\" iconboth=\\"false\\" data-v-5d0380ab=\\"\\"></o-button-stub>
</div>
</article>
</form>
<transition-group-stub data-v-5d0380ab=\\"\\">
<empty-content-stub icon=\\"comment\\" descriptionclasses=\\"\\" inline=\\"true\\" center=\\"false\\" data-v-5d0380ab=\\"\\"></empty-content-stub>
</transition-group-stub>
</div>"
`;

View file

@ -1,37 +0,0 @@
// Vitest Snapshot v1
exports[`PostListItem > renders post list item with basic informations 1`] = `
"<a href=\\"/p/my-blog-post-some-uuid\\" class=\\"block md:flex bg-white dark:bg-violet-2 dark:text-white dark:hover:text-white rounded-lg shadow-md\\" dir=\\"auto\\" data-v-6ca7cc69=\\"\\">
<!--v-if-->
<div class=\\"flex flex-col gap-1 bg-inherit p-2 rounded-lg flex-1\\" data-v-6ca7cc69=\\"\\">
<h3 class=\\"text-xl color-violet-3 line-clamp-3 mb-2 font-bold\\" lang=\\"en\\" data-v-6ca7cc69=\\"\\">My Blog Post</h3>
<p class=\\"flex gap-2\\" data-v-6ca7cc69=\\"\\"><span aria-hidden=\\"true\\" class=\\"material-design-icon clock-icon\\" role=\\"img\\" data-v-6ca7cc69=\\"\\"><svg fill=\\"currentColor\\" class=\\"material-design-icon__svg\\" width=\\"24\\" height=\\"24\\" viewBox=\\"0 0 24 24\\"><path d=\\"M12,2A10,10 0 0,0 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12A10,10 0 0,0 12,2M16.2,16.2L11,13V7H12.5V12.2L17,14.9L16.2,16.2Z\\"><!--v-if--></path></svg></span><span dir=\\"auto\\" class=\\"\\" data-v-6ca7cc69=\\"\\">Dec 2, 2020</span></p>
<!--v-if-->
<!--v-if-->
</div>
</a>"
`;
exports[`PostListItem > renders post list item with publisher name 1`] = `
"<a href=\\"/p/my-blog-post-some-uuid\\" class=\\"block md:flex bg-white dark:bg-violet-2 dark:text-white dark:hover:text-white rounded-lg shadow-md\\" dir=\\"auto\\" data-v-6ca7cc69=\\"\\">
<!--v-if-->
<div class=\\"flex flex-col gap-1 bg-inherit p-2 rounded-lg flex-1\\" data-v-6ca7cc69=\\"\\">
<h3 class=\\"text-xl color-violet-3 line-clamp-3 mb-2 font-bold\\" lang=\\"en\\" data-v-6ca7cc69=\\"\\">My Blog Post</h3>
<p class=\\"flex gap-2\\" data-v-6ca7cc69=\\"\\"><span aria-hidden=\\"true\\" class=\\"material-design-icon clock-icon\\" role=\\"img\\" data-v-6ca7cc69=\\"\\"><svg fill=\\"currentColor\\" class=\\"material-design-icon__svg\\" width=\\"24\\" height=\\"24\\" viewBox=\\"0 0 24 24\\"><path d=\\"M12,2A10,10 0 0,0 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12A10,10 0 0,0 12,2M16.2,16.2L11,13V7H12.5V12.2L17,14.9L16.2,16.2Z\\"><!--v-if--></path></svg></span><span dir=\\"auto\\" class=\\"\\" data-v-6ca7cc69=\\"\\">Dec 2, 2020</span></p>
<!--v-if-->
<p class=\\"flex gap-1\\" data-v-6ca7cc69=\\"\\"><span aria-hidden=\\"true\\" class=\\"material-design-icon account-edit-icon\\" role=\\"img\\" data-v-6ca7cc69=\\"\\"><svg fill=\\"currentColor\\" class=\\"material-design-icon__svg\\" width=\\"24\\" height=\\"24\\" viewBox=\\"0 0 24 24\\"><path d=\\"M21.7,13.35L20.7,14.35L18.65,12.3L19.65,11.3C19.86,11.09 20.21,11.09 20.42,11.3L21.7,12.58C21.91,12.79 21.91,13.14 21.7,13.35M12,18.94L18.06,12.88L20.11,14.93L14.06,21H12V18.94M12,14C7.58,14 4,15.79 4,18V20H10V18.11L14,14.11C13.34,14.03 12.67,14 12,14M12,4A4,4 0 0,0 8,8A4,4 0 0,0 12,12A4,4 0 0,0 16,8A4,4 0 0,0 12,4Z\\"><!--v-if--></path></svg></span>Published by <b class=\\"\\" data-v-6ca7cc69=\\"\\">An author</b></p>
</div>
</a>"
`;
exports[`PostListItem > renders post list item with tags 1`] = `
"<a href=\\"/p/my-blog-post-some-uuid\\" class=\\"block md:flex bg-white dark:bg-violet-2 dark:text-white dark:hover:text-white rounded-lg shadow-md\\" dir=\\"auto\\" data-v-6ca7cc69=\\"\\">
<!--v-if-->
<div class=\\"flex flex-col gap-1 bg-inherit p-2 rounded-lg flex-1\\" data-v-6ca7cc69=\\"\\">
<h3 class=\\"text-xl color-violet-3 line-clamp-3 mb-2 font-bold\\" lang=\\"en\\" data-v-6ca7cc69=\\"\\">My Blog Post</h3>
<p class=\\"flex gap-2\\" data-v-6ca7cc69=\\"\\"><span aria-hidden=\\"true\\" class=\\"material-design-icon clock-icon\\" role=\\"img\\" data-v-6ca7cc69=\\"\\"><svg fill=\\"currentColor\\" class=\\"material-design-icon__svg\\" width=\\"24\\" height=\\"24\\" viewBox=\\"0 0 24 24\\"><path d=\\"M12,2A10,10 0 0,0 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12A10,10 0 0,0 12,2M16.2,16.2L11,13V7H12.5V12.2L17,14.9L16.2,16.2Z\\"><!--v-if--></path></svg></span><span dir=\\"auto\\" class=\\"\\" data-v-6ca7cc69=\\"\\">Dec 2, 2020</span></p>
<div class=\\"flex flex-wrap gap-y-0 gap-x-2\\" data-v-6ca7cc69=\\"\\"><span aria-hidden=\\"true\\" class=\\"material-design-icon tag-icon\\" role=\\"img\\" data-v-6ca7cc69=\\"\\"><svg fill=\\"currentColor\\" class=\\"material-design-icon__svg\\" width=\\"24\\" height=\\"24\\" viewBox=\\"0 0 24 24\\"><path d=\\"M5.5,7A1.5,1.5 0 0,1 4,5.5A1.5,1.5 0 0,1 5.5,4A1.5,1.5 0 0,1 7,5.5A1.5,1.5 0 0,1 5.5,7M21.41,11.58L12.41,2.58C12.05,2.22 11.55,2 11,2H4C2.89,2 2,2.89 2,4V11C2,11.55 2.22,12.05 2.59,12.41L11.58,21.41C11.95,21.77 12.45,22 13,22C13.55,22 14.05,21.77 14.41,21.41L21.41,14.41C21.78,14.05 22,13.55 22,13C22,12.44 21.77,11.94 21.41,11.58Z\\"><!--v-if--></path></svg></span><span class=\\"rounded-md truncate text-sm text-violet-title px-2 py-1 bg-purple-3 dark:text-violet-3\\" data-v-6955ca87=\\"\\" data-v-6ca7cc69=\\"\\">A tag</span></div>
<!--v-if-->
</div>
</a>"
`;

View file

@ -1,29 +0,0 @@
// Vitest Snapshot v1
exports[`ReportModal > renders report modal with basic informations and submits it 1`] = `
"<div class=\\"p-2\\" data-v-e0cceef3=\\"\\">
<!--v-if-->
<section data-v-e0cceef3=\\"\\">
<div class=\\"flex gap-1 flex-row mb-3\\" data-v-e0cceef3=\\"\\"><span class=\\"o-icon o-icon--warning hidden md:block flex-1\\" data-v-e0cceef3=\\"\\"><i class=\\"mdi mdi-alert 48\\"></i></span>
<p data-v-e0cceef3=\\"\\">The report will be sent to the moderators of your instance. You can explain why you report this content below.</p>
</div>
<div class=\\"\\" data-v-e0cceef3=\\"\\">
<!--v-if-->
<div class=\\"o-field o-field--filled\\" data-v-e0cceef3=\\"\\"><label for=\\"additional-comments\\" class=\\"o-field__label\\">Additional comments</label>
<div class=\\"o-ctrl-input\\" data-v-e0cceef3=\\"\\"><textarea id=\\"additional-comments\\" class=\\"o-input o-input__textarea\\"></textarea>
<!--v-if-->
<!--v-if-->
<!--v-if-->
</div>
<!--v-if-->
</div>
<!--v-if-->
</div>
</section>
<footer class=\\"flex gap-2 py-3\\" data-v-e0cceef3=\\"\\"><button type=\\"button\\" class=\\"o-btn o-btn--outlined\\" data-v-e0cceef3=\\"\\"><span class=\\"o-btn__wrapper\\"><!--v-if--><span class=\\"o-btn__label\\">Cancel</span>
<!--v-if--></span>
</button><button type=\\"button\\" class=\\"o-btn o-btn--primary\\" data-v-e0cceef3=\\"\\"><span class=\\"o-btn__wrapper\\"><!--v-if--><span class=\\"o-btn__label\\">Send the report</span>
<!--v-if--></span>
</button></footer>
</div>"
`;

View file

@ -1,196 +0,0 @@
// Vitest Snapshot v1
exports[`App component > renders a Vue component 1`] = `
"<nav class=\\"bg-white border-gray-200 px-2 sm:px-4 py-2.5 dark:bg-zinc-900\\" id=\\"navbar\\">
<div class=\\"container mx-auto flex flex-wrap items-center mx-auto gap-4\\">
<router-link to=\\"[object Object]\\" class=\\"flex items-center\\">
<mobilizon-logo-stub invert=\\"false\\" class=\\"w-40\\"></mobilizon-logo-stub>
</router-link>
<!--v-if--><button type=\\"button\\" class=\\"inline-flex items-center p-2 ml-1 text-sm text-zinc-500 rounded-lg md:hidden hover:bg-zinc-100 focus:outline-none focus:ring-2 focus:ring-gray-200 dark:text-zinc-400 dark:hover:bg-zinc-700 dark:focus:ring-gray-600\\" aria-controls=\\"mobile-menu-2\\" aria-expanded=\\"false\\"><span class=\\"sr-only\\">Open main menu</span><svg class=\\"w-6 h-6\\" aria-hidden=\\"true\\" fill=\\"currentColor\\" viewBox=\\"0 0 20 20\\" xmlns=\\"http://www.w3.org/2000/svg\\">
<path fill-rule=\\"evenodd\\" d=\\"M3 5a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zM3 10a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zM3 15a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1z\\" clip-rule=\\"evenodd\\"></path>
</svg></button>
<div class=\\"justify-between items-center w-full md:flex md:w-auto md:order-1 hidden\\" id=\\"mobile-menu-2\\">
<ul class=\\"flex flex-col md:flex-row md:space-x-8 mt-2 md:mt-0 md:font-lightbold\\">
<!--v-if-->
<!--v-if-->
<li>
<router-link to=\\"[object Object]\\" class=\\"block py-2 pr-4 pl-3 text-zinc-700 border-b border-gray-100 hover:bg-zinc-50 md:hover:bg-transparent md:border-0 md:hover:text-mbz-purple-700 md:p-0 dark:text-zinc-400 md:dark:hover:text-white dark:hover:bg-zinc-700 dark:hover:text-white md:dark:hover:bg-transparent dark:border-gray-700\\">Login</router-link>
</li>
<li>
<router-link to=\\"[object Object]\\" class=\\"block py-2 pr-4 pl-3 text-zinc-700 border-b border-gray-100 hover:bg-zinc-50 md:hover:bg-transparent md:border-0 md:hover:text-mbz-purple-700 md:p-0 dark:text-zinc-400 md:dark:hover:text-white dark:hover:bg-zinc-700 dark:hover:text-white md:dark:hover:bg-transparent dark:border-gray-700\\">Register</router-link>
</li>
</ul>
</div>
</div>
</nav>
<!-- <o-navbar
id=\\"navbar\\"
type=\\"is-secondary\\"
wrapper-class=\\"container mx-auto\\"
v-model:active=\\"mobileNavbarActive\\"
>
<template #brand>
<o-navbar-item
tag=\\"router-link\\"
:to=\\"{ name: RouteName.HOME }\\"
:aria-label=\\"$t('Home')\\"
>
<logo />
</o-navbar-item>
</template>
<template #start>
<o-navbar-item tag=\\"router-link\\" :to=\\"{ name: RouteName.SEARCH }\\">{{
$t(\\"Explore\\")
}}</o-navbar-item>
<o-navbar-item
v-if=\\"currentActor.id && currentUser?.isLoggedIn\\"
tag=\\"router-link\\"
:to=\\"{ name: RouteName.MY_EVENTS }\\"
>{{ $t(\\"My events\\") }}</o-navbar-item
>
<o-navbar-item
tag=\\"router-link\\"
:to=\\"{ name: RouteName.MY_GROUPS }\\"
v-if=\\"
config &&
config.features.groups &&
currentActor.id &&
currentUser?.isLoggedIn
\\"
>{{ $t(\\"My groups\\") }}</o-navbar-item
>
<o-navbar-item
tag=\\"span\\"
v-if=\\"
config &&
config.features.eventCreation &&
currentActor.id &&
currentUser?.isLoggedIn
\\"
>
<o-button
v-if=\\"!hideCreateEventsButton\\"
tag=\\"router-link\\"
:to=\\"{ name: RouteName.CREATE_EVENT }\\"
variant=\\"primary\\"
>{{ $t(\\"Create\\") }}</o-button
>
</o-navbar-item>
</template>
<template #end>
<o-navbar-item tag=\\"div\\">
<search-field @navbar-search=\\"mobileNavbarActive = false\\" />
</o-navbar-item>
<o-navbar-dropdown
v-if=\\"currentActor.id && currentUser?.isLoggedIn\\"
right
collapsible
ref=\\"user-dropdown\\"
tabindex=\\"0\\"
tag=\\"span\\"
@keyup.enter=\\"toggleMenu\\"
>
<template #label v-if=\\"currentActor\\">
<div class=\\"identity-wrapper\\">
<div>
<figure class=\\"image is-32x32\\" v-if=\\"currentActor.avatar\\">
<img
class=\\"is-rounded\\"
alt=\\"avatarUrl\\"
:src=\\"currentActor.avatar.url\\"
/>
</figure>
<o-icon v-else icon=\\"account-circle\\" />
</div>
<div class=\\"media-content is-hidden-desktop\\">
<span>{{ displayName(currentActor) }}</span>
<span class=\\"has-text-grey-dark\\" v-if=\\"currentActor.name\\"
>@{{ currentActor.preferredUsername }}</span
>
</div>
</div>
</template>
No identities dropdown if no identities
<span v-if=\\"identities.length <= 1\\"></span>
<o-navbar-item
tag=\\"span\\"
v-for=\\"identity in identities\\"
v-else
:active=\\"identity.id === currentActor.id\\"
:key=\\"identity.id\\"
tabindex=\\"0\\"
@click=\\"setIdentity({
preferredUsername: identity.preferredUsername,
})\\"
@keyup.enter=\\"setIdentity({
preferredUsername: identity.preferredUsername,
})\\"
>
<span>
<div class=\\"media-left\\">
<figure class=\\"image is-32x32\\" v-if=\\"identity.avatar\\">
<img
class=\\"is-rounded\\"
loading=\\"lazy\\"
:src=\\"identity.avatar.url\\"
alt
/>
</figure>
<o-icon v-else size=\\"is-medium\\" icon=\\"account-circle\\" />
</div>
<div class=\\"media-content\\">
<span>{{ displayName(identity) }}</span>
<span class=\\"has-text-grey-dark\\" v-if=\\"identity.name\\"
>@{{ identity.preferredUsername }}</span
>
</div>
</span>
<hr class=\\"navbar-divider\\" role=\\"presentation\\" />
</o-navbar-item>
<o-navbar-item
tag=\\"router-link\\"
:to=\\"{ name: RouteName.UPDATE_IDENTITY }\\"
>{{ $t(\\"My account\\") }}</o-navbar-item
>
<o-navbar-item
v-if=\\"currentUser.role === ICurrentUserRole.ADMINISTRATOR\\"
tag=\\"router-link\\"
:to=\\"{ name: RouteName.ADMIN_DASHBOARD }\\"
>{{ $t(\\"Administration\\") }}</o-navbar-item
>
<o-navbar-item
tag=\\"span\\"
tabindex=\\"0\\"
@click=\\"logout\\"
@keyup.enter=\\"logout\\"
>
<span>{{ $t(\\"Log out\\") }}</span>
</o-navbar-item>
</o-navbar-dropdown>
<o-navbar-item v-else tag=\\"div\\">
<div class=\\"buttons\\">
<router-link
class=\\"button is-primary\\"
v-if=\\"config && config.registrationsOpen\\"
:to=\\"{ name: RouteName.REGISTER }\\"
>
<strong>{{ $t(\\"Sign up\\") }}</strong>
</router-link>
<router-link
class=\\"button is-light\\"
:to=\\"{ name: RouteName.LOGIN }\\"
>{{ $t(\\"Log in\\") }}</router-link
>
</div>
</o-navbar-item>
</template>
</o-navbar> -->"
`;

View file

@ -1,40 +0,0 @@
{
"compilerOptions": {
"target": "esnext",
"module": "esnext",
"strict": true,
"jsx": "preserve",
"importHelpers": true,
"moduleResolution": "node",
"experimentalDecorators": true,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"resolveJsonModule": true,
"isolatedModules": true,
"sourceMap": true,
"baseUrl": ".",
"types": ["webpack-env", "jest", "vite/client", "vite-plugin-pwa/vue"],
"typeRoots": ["./@types", "./node_modules/@types"],
"paths": {
"@/*": ["src/*"]
},
"lib": [
"esnext",
"dom",
"es2017.intl",
"dom.iterable",
"scripthost",
"webworker"
]
},
"include": [
"src/**/*.ts",
"src/**/*.tsx",
"src/**/*.vue",
"tests/**/*.ts",
"tests/**/*.tsx",
"env.d.ts"
],
"exclude": ["node_modules"]
}

File diff suppressed because it is too large Load diff

View file

@ -14,7 +14,15 @@ defmodule Mobilizon.Federation.ActivityPub.Actions.Create do
]
@type create_entities ::
:event | :comment | :discussion | :actor | :todo_list | :todo | :resource | :post
:event
| :comment
| :discussion
| :conversation
| :actor
| :todo_list
| :todo
| :resource
| :post
@doc """
Create an activity of type `Create`
@ -50,18 +58,27 @@ defmodule Mobilizon.Federation.ActivityPub.Actions.Create do
end
end
@map_types %{
:event => Types.Events,
:comment => Types.Comments,
:discussion => Types.Discussions,
:conversation => Types.Conversations,
:actor => Types.Actors,
:todo_list => Types.TodoLists,
:todo => Types.Todos,
:resource => Types.Resources,
:post => Types.Posts
}
@spec do_create(create_entities(), map(), map()) ::
{:ok, Entity.t(), Activity.t()} | {:error, Ecto.Changeset.t() | atom()}
defp do_create(type, args, additional) do
case type do
: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)
mod = Map.get(@map_types, type)
if is_nil(mod) do
{:error, :type_not_supported}
else
mod.create(args, additional)
end
end

View file

@ -5,6 +5,7 @@ defmodule Mobilizon.Federation.ActivityPub.Audience do
alias Mobilizon.{Actors, Discussions, Events, Share}
alias Mobilizon.Actors.{Actor, Member}
alias Mobilizon.Conversations.Conversation
alias Mobilizon.Discussions.{Comment, Discussion}
alias Mobilizon.Events.{Event, Participant}
alias Mobilizon.Federation.ActivityPub.Types.Entity
@ -38,6 +39,10 @@ defmodule Mobilizon.Federation.ActivityPub.Audience do
%{"to" => maybe_add_group_members([], actor), "cc" => []}
end
def get_audience(%Conversation{participants: participants}) do
%{"to" => Enum.map(participants, & &1.url), "cc" => []}
end
# Deleted comments are just like tombstones
def get_audience(%Comment{deleted_at: deleted_at}) when not is_nil(deleted_at) do
%{"to" => [@ap_public], "cc" => []}

View file

@ -177,7 +177,7 @@ defmodule Mobilizon.Federation.ActivityPub.Fetcher do
{:error, :content_not_json}
{:ok, %Tesla.Env{} = res} ->
Logger.debug("Resource returned bad HTTP code #{inspect(res)}")
Logger.debug("Resource returned bad HTTP code (#{res.status}) #{inspect(res)}")
{:error, :http_error}
{:error, err} ->

View file

@ -68,24 +68,26 @@ defmodule Mobilizon.Federation.ActivityPub.Transmogrifier do
def handle_incoming(%{"type" => "Create", "object" => %{"type" => "Note"} = object}) do
Logger.info("Handle incoming to create notes")
case Converter.Comment.as_to_model_data(object) do
%{visibility: visibility, event_id: event_id}
when visibility != :public and event_id != nil ->
Logger.info("Tried to reply to an event with a private comment - ignore")
:error
case Discussions.get_comment_from_url_with_preload(object["id"]) do
{:error, :comment_not_found} ->
case Converter.Comment.as_to_model_data(object) do
%{visibility: visibility, attributed_to_id: attributed_to_id} = object_data
when visibility === :private and is_nil(attributed_to_id) ->
Actions.Create.create(:conversation, object_data, false)
object_data when is_map(object_data) ->
case Discussions.get_comment_from_url_with_preload(object_data.url) do
{:error, :comment_not_found} ->
object_data
|> transform_object_data_for_discussion()
|> save_comment_or_discussion()
{:ok, %Comment{} = comment} ->
# Object already exists
{:ok, nil, comment}
object_data when is_map(object_data) ->
case Discussions.get_comment_from_url_with_preload(object_data.url) do
{:error, :comment_not_found} ->
object_data
|> transform_object_data_for_discussion()
|> save_comment_or_discussion()
end
end
{:ok, %Comment{} = comment} ->
# Object already exists
{:ok, nil, comment}
{:error, err} ->
{:error, err}
end

View file

@ -0,0 +1,219 @@
defmodule Mobilizon.Federation.ActivityPub.Types.Conversations do
@moduledoc false
# alias Mobilizon.Conversations.ConversationParticipant
alias Mobilizon.{Actors, Conversations, Discussions}
alias Mobilizon.Actors.Actor
alias Mobilizon.Conversations.Conversation
alias Mobilizon.Discussions.Comment
alias Mobilizon.Federation.ActivityPub.Actor, as: ActivityPubActor
alias Mobilizon.Federation.ActivityPub.{Audience, Permission}
alias Mobilizon.Federation.ActivityPub.Types.Entity
alias Mobilizon.Federation.ActivityStream
alias Mobilizon.Federation.ActivityStream.Converter.Utils, as: ConverterUtils
alias Mobilizon.Federation.ActivityStream.Convertible
alias Mobilizon.GraphQL.API.Utils, as: APIUtils
alias Mobilizon.Service.Activity.Conversation, as: ConversationActivity
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, Conversation.t(), ActivityStream.t()}
| {:error,
:conversation_not_found
| :last_comment_not_found
| :empty_participants
| Ecto.Changeset.t()}
def create(%{conversation_id: conversation_id} = args, additional)
when not is_nil(conversation_id) do
Logger.debug("Creating a reply to a conversation #{inspect(args, pretty: true)}")
args = prepare_args(args)
Logger.debug("Creating a reply to a conversation #{inspect(args, pretty: true)}")
with args when is_map(args) <- prepare_args(args) do
case Conversations.get_conversation(conversation_id) do
%Conversation{} = conversation ->
case Conversations.reply_to_conversation(conversation, args) do
{:ok, %Conversation{last_comment_id: last_comment_id} = conversation} ->
ConversationActivity.insert_activity(conversation, subject: "conversation_replied")
maybe_publish_graphql_subscription(conversation)
case Discussions.get_comment_with_preload(last_comment_id) do
%Comment{} = last_comment ->
comment_as_data = Convertible.model_to_as(last_comment)
audience = Audience.get_audience(conversation)
create_data = make_create_data(comment_as_data, Map.merge(audience, additional))
{:ok, conversation, create_data}
nil ->
{:error, :last_comment_not_found}
end
{:error, _, %Ecto.Changeset{} = err, _} ->
{:error, err}
end
nil ->
{:error, :discussion_not_found}
end
end
end
@impl Entity
def create(args, additional) do
with args when is_map(args) <- prepare_args(args) do
case Conversations.create_conversation(args) do
{:ok, %Conversation{} = conversation} ->
ConversationActivity.insert_activity(conversation, subject: "conversation_created")
conversation_as_data = Convertible.model_to_as(conversation)
audience = Audience.get_audience(conversation)
create_data = make_create_data(conversation_as_data, Map.merge(audience, additional))
{:ok, conversation, create_data}
{:error, _, %Ecto.Changeset{} = err, _} ->
{:error, err}
end
end
end
@impl Entity
@spec update(Conversation.t(), map(), map()) ::
{:ok, Conversation.t(), ActivityStream.t()} | {:error, Ecto.Changeset.t()}
def update(%Conversation{} = old_conversation, args, additional) do
case Conversations.update_conversation(old_conversation, args) do
{:ok, %Conversation{} = new_conversation} ->
# ConversationActivity.insert_activity(new_conversation,
# subject: "conversation_renamed",
# old_conversation: old_conversation
# )
conversation_as_data = Convertible.model_to_as(new_conversation)
audience = Audience.get_audience(new_conversation)
update_data = make_update_data(conversation_as_data, Map.merge(audience, additional))
{:ok, new_conversation, update_data}
{:error, %Ecto.Changeset{} = err} ->
{:error, err}
end
end
@impl Entity
@spec delete(Conversation.t(), Actor.t(), boolean, map()) ::
{:error, Ecto.Changeset.t()} | {:ok, ActivityStream.t(), Actor.t(), Conversation.t()}
def delete(
%Conversation{} = _conversation,
%Actor{} = _actor,
_local,
_additionnal
) do
{:error, :not_applicable}
end
# @spec actor(Conversation.t()) :: Actor.t() | nil
# def actor(%ConversationParticipant{actor_id: actor_id}), do: Actors.get_actor(actor_id)
# @spec group_actor(Conversation.t()) :: Actor.t() | nil
# def group_actor(%Conversation{actor_id: actor_id}), do: Actors.get_actor(actor_id)
@spec permissions(Conversation.t()) :: Permission.t()
def permissions(%Conversation{}) do
%Permission{access: :member, create: :member, update: :moderator, delete: :moderator}
end
@spec maybe_publish_graphql_subscription(Conversation.t()) :: :ok
defp maybe_publish_graphql_subscription(%Conversation{} = conversation) do
Absinthe.Subscription.publish(Endpoint, conversation,
conversation_comment_changed: conversation.id
)
:ok
end
@spec prepare_args(map) :: map | {:error, :empty_participants}
defp prepare_args(args) do
{text, mentions, _tags} =
APIUtils.make_content_html(
args |> Map.get(:text, "") |> String.trim(),
# Can't put additional tags on a comment
[],
"text/html"
)
mentions =
(args |> Map.get(:mentions, []) |> prepare_mentions()) ++
ConverterUtils.fetch_mentions(mentions)
# Can't create a conversation with just ourselves
mentions =
Enum.filter(mentions, fn %{actor_id: actor_id} ->
to_string(actor_id) != to_string(args.actor_id)
end)
if Enum.empty?(mentions) do
{:error, :empty_participants}
else
event = Map.get(args, :event, get_event(Map.get(args, :event_id)))
participants =
(mentions ++
[
%{actor_id: args.actor_id},
%{
actor_id:
if(is_nil(event),
do: nil,
else: event.attributed_to_id || event.organizer_actor_id
)
}
])
|> Enum.reduce(
[],
fn %{actor_id: actor_id}, acc ->
case Actors.get_actor(actor_id) do
nil -> acc
actor -> acc ++ [actor]
end
end
)
|> Enum.uniq_by(& &1.id)
args
|> Map.put(:text, text)
|> Map.put(:mentions, mentions)
|> Map.put(:participants, participants)
end
end
@spec prepare_mentions(list(String.t())) :: list(%{actor_id: String.t()})
defp prepare_mentions(mentions) do
Enum.reduce(mentions, [], &prepare_mention/2)
end
@spec prepare_mention(String.t() | map(), list()) :: list(%{actor_id: String.t()})
defp prepare_mention(%{actor_id: _} = mention, mentions) do
mentions ++ [mention]
end
defp prepare_mention(mention, mentions) do
case ActivityPubActor.find_or_make_actor_from_nickname(mention) do
{:ok, %Actor{id: actor_id}} ->
mentions ++ [%{actor_id: actor_id}]
{:error, _} ->
mentions
end
end
defp get_event(nil), do: nil
defp get_event(event_id) do
case Mobilizon.Events.get_event(event_id) do
{:ok, event} -> event
_ -> nil
end
end
end

View file

@ -22,6 +22,7 @@ defmodule Mobilizon.Federation.ActivityPub.Utils do
@actor_types ["Group", "Person", "Application"]
@all_actor_types @actor_types ++ ["Organization", "Service"]
@ap_public_audience "https://www.w3.org/ns/activitystreams#Public"
# Wraps an object into an activity
@spec create_activity(map(), boolean()) :: {:ok, Activity.t()}
@ -491,8 +492,7 @@ defmodule Mobilizon.Federation.ActivityPub.Utils do
if public do
Logger.debug("Making announce data for a public object")
{[actor.followers_url, object_actor_url],
["https://www.w3.org/ns/activitystreams#Public"]}
{[actor.followers_url, object_actor_url], [@ap_public_audience]}
else
Logger.debug("Making announce data for a private object")
@ -539,7 +539,7 @@ defmodule Mobilizon.Federation.ActivityPub.Utils do
"actor" => url,
"object" => activity,
"to" => [actor.followers_url, actor.url],
"cc" => ["https://www.w3.org/ns/activitystreams#Public"]
"cc" => [@ap_public_audience]
}
if activity_id, do: Map.put(data, "id", activity_id), else: data

View file

@ -47,9 +47,6 @@ defmodule Mobilizon.Federation.ActivityStream.Converter.Comment do
case maybe_fetch_actor_and_attributed_to_id(object) do
{:ok, %Actor{id: actor_id, domain: actor_domain}, attributed_to} ->
Logger.debug("Inserting full comment")
Logger.debug(inspect(object))
data = %{
text: object["content"],
url: object["id"],
@ -70,14 +67,7 @@ defmodule Mobilizon.Federation.ActivityStream.Converter.Comment do
is_announcement: Map.get(object, "isAnnouncement", false)
}
Logger.debug("Converted object before fetching parents")
Logger.debug(inspect(data))
data = maybe_fetch_parent_object(object, data)
Logger.debug("Converted object after fetching parents")
Logger.debug(inspect(data))
data
maybe_fetch_parent_object(object, data)
{:error, err} ->
{:error, err}
@ -147,19 +137,22 @@ defmodule Mobilizon.Federation.ActivityStream.Converter.Comment do
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]
defp determine_to(%CommentModel{visibility: :private, mentions: mentions} = _comment) do
Enum.map(mentions, fn mention -> mention.actor.url end)
end
comment.visibility == :public ->
["https://www.w3.org/ns/activitystreams#Public"]
true ->
[comment.actor.followers_url]
defp determine_to(%CommentModel{visibility: :public} = comment) do
if is_nil(comment.attributed_to) do
["https://www.w3.org/ns/activitystreams#Public"]
else
[comment.attributed_to.url]
end
end
defp determine_to(%CommentModel{} = comment) do
[comment.actor.followers_url]
end
defp maybe_fetch_parent_object(object, data) do
# We fetch the parent object
Logger.debug("We're fetching the parent object")
@ -170,9 +163,12 @@ defmodule Mobilizon.Federation.ActivityStream.Converter.Comment do
case ActivityPub.fetch_object_from_url(object["inReplyTo"]) do
# Reply to an event (Event)
{:ok, %Event{id: id}} ->
{:ok, %Event{id: id} = event} ->
Logger.debug("Parent object is an event")
data |> Map.put(:event_id, id)
data
|> Map.put(:event_id, id)
|> Map.put(:event, event)
# Reply to a comment (Comment)
{:ok, %CommentModel{id: id} = comment} ->
@ -182,6 +178,7 @@ defmodule Mobilizon.Federation.ActivityStream.Converter.Comment do
|> Map.put(:in_reply_to_comment_id, id)
|> Map.put(:origin_comment_id, comment |> CommentModel.get_thread_id())
|> Map.put(:event_id, comment.event_id)
|> Map.put(:conversation_id, comment.conversation_id)
# Reply to a discucssion (Discussion)
{:ok,

View file

@ -0,0 +1,68 @@
defmodule Mobilizon.Federation.ActivityStream.Converter.Conversation do
@moduledoc """
Comment converter.
This module allows to convert conversations from ActivityStream format to our own
internal one, and back.
"""
alias Mobilizon.Actors.Actor
alias Mobilizon.Conversations.Conversation
alias Mobilizon.Federation.ActivityPub.Actor, as: ActivityPubActor
alias Mobilizon.Federation.ActivityStream.{Converter, Convertible}
alias Mobilizon.Federation.ActivityStream.Converter.Conversation, as: ConversationConverter
alias Mobilizon.Storage.Repo
import Mobilizon.Service.Guards, only: [is_valid_string: 1]
require Logger
@behaviour Converter
defimpl Convertible, for: Conversation do
defdelegate model_to_as(comment), to: ConversationConverter
end
@doc """
Make an AS comment object from an existing `conversation` structure.
"""
@impl Converter
@spec model_to_as(Conversation.t()) :: map
def model_to_as(%Conversation{} = conversation) do
conversation = Repo.preload(conversation, [:participants, last_comment: [:actor]])
%{
"type" => "Note",
"to" => Enum.map(conversation.participants, & &1.url),
"cc" => [],
"content" => conversation.last_comment.text,
"mediaType" => "text/html",
"actor" => conversation.last_comment.actor.url,
"id" => conversation.last_comment.url,
"publishedAt" => conversation.inserted_at
}
end
@impl Converter
@spec as_to_model_data(map) :: map() | {:error, atom()}
def as_to_model_data(%{"type" => "Note", "name" => name} = object) when is_valid_string(name) do
with %{actor_id: actor_id, creator_id: creator_id} <- extract_actors(object) do
%{actor_id: actor_id, creator_id: creator_id, title: name, url: object["id"]}
end
end
@spec extract_actors(map()) ::
%{actor_id: String.t(), creator_id: String.t()} | {:error, atom()}
defp extract_actors(%{"actor" => creator_url, "attributedTo" => actor_url} = _object)
when is_valid_string(creator_url) and is_valid_string(actor_url) do
with {:ok, %Actor{id: creator_id, suspended: false}} <-
ActivityPubActor.get_or_fetch_actor_by_url(creator_url),
{:ok, %Actor{id: actor_id, suspended: false}} <-
ActivityPubActor.get_or_fetch_actor_by_url(actor_url) do
%{actor_id: actor_id, creator_id: creator_id}
else
{:error, error} -> {:error, error}
{:ok, %Actor{url: ^creator_url}} -> {:error, :creator_suspended}
{:ok, %Actor{url: ^actor_url}} -> {:error, :actor_suspended}
end
end
end

View file

@ -242,12 +242,15 @@ defmodule Mobilizon.Federation.WebFinger do
@spec domain_from_federated_actor(String.t()) :: {:ok, String.t()} | {:error, :host_not_found}
defp domain_from_federated_actor(actor) do
case String.split(actor, "@") do
[_name, ""] ->
{:error, :host_not_found}
[_name, domain] ->
{:ok, domain}
_e ->
host = URI.parse(actor).host
if is_nil(host), do: {:error, :host_not_found}, else: {:ok, host}
if is_nil(host) or host == "", do: {:error, :host_not_found}, else: {:ok, host}
end
end

View file

@ -4,7 +4,8 @@ defmodule Mobilizon.GraphQL.API.Comments do
"""
alias Mobilizon.Actors.Actor
alias Mobilizon.Discussions.Comment
alias Mobilizon.Conversations.Conversation
alias Mobilizon.Discussions.{Comment, Discussion}
alias Mobilizon.Federation.ActivityPub.{Actions, Activity}
alias Mobilizon.GraphQL.API.Utils
@ -53,6 +54,22 @@ defmodule Mobilizon.GraphQL.API.Comments do
)
end
@doc """
Creates a conversation (or reply to a conversation)
"""
@spec create_conversation(map()) ::
{:ok, Activity.t(), Conversation.t()}
| {:error, :entity_tombstoned | atom | Ecto.Changeset.t()}
def create_conversation(args) do
args = extract_pictures_from_comment_body(args)
Actions.Create.create(
:conversation,
args,
true
)
end
@spec extract_pictures_from_comment_body(map()) :: map()
defp extract_pictures_from_comment_body(%{text: text, actor_id: actor_id} = args) do
pictures = Utils.extract_pictures_from_body(text, actor_id)

View file

@ -4,8 +4,8 @@ defmodule Mobilizon.GraphQL.API.Events do
"""
alias Mobilizon.Actors.Actor
alias Mobilizon.Discussions.Comment
alias Mobilizon.Events.Event
alias Mobilizon.Federation.ActivityPub.{Actions, Activity, Utils}
alias Mobilizon.GraphQL.API.Utils, as: APIUtils
@ -36,6 +36,12 @@ defmodule Mobilizon.GraphQL.API.Events do
Actions.Delete.delete(event, actor, true)
end
@spec send_private_message_to_participants(map()) ::
{:ok, Activity.t(), Comment.t()} | {:error, atom() | Ecto.Changeset.t()}
def send_private_message_to_participants(args) do
Actions.Create.create(:comment, args, true)
end
@spec prepare_args(map) :: map
defp prepare_args(args) do
organizer_actor = Map.get(args, :organizer_actor)

View file

@ -116,13 +116,9 @@ defmodule Mobilizon.GraphQL.API.Search do
@spec process_from_username(String.t()) :: Page.t(Actor.t())
defp process_from_username(search) do
case ActivityPubActor.find_or_make_actor_from_nickname(search) do
{:ok, %Actor{type: :Group} = actor} ->
{:ok, %Actor{} = actor} ->
%Page{total: 1, elements: [actor]}
# Don't return anything else than groups
{:ok, %Actor{}} ->
%Page{total: 0, elements: []}
{:error, _err} ->
Logger.debug(fn -> "Unable to find or make actor '#{search}'" end)

View file

@ -16,11 +16,13 @@ defmodule Mobilizon.GraphQL.Authorization do
@impl true
def has_user_access?(%User{}, _scope, _rule), do: true
@impl true
def has_user_access?(%ApplicationToken{scope: scope} = _current_app_token, _struct, rule)
when rule != :forbid_app_access do
AppScope.has_app_access?(scope, rule)
end
@impl true
def has_user_access?(_current_user, _scoped_struct, _rule), do: false
@impl true

View file

@ -0,0 +1,277 @@
defmodule Mobilizon.GraphQL.Resolvers.Conversation do
@moduledoc """
Handles the group-related GraphQL calls.
"""
alias Mobilizon.{Actors, Conversations}
alias Mobilizon.Actors.Actor
alias Mobilizon.Conversations.{Conversation, ConversationParticipant, ConversationView}
alias Mobilizon.Events.Event
alias Mobilizon.GraphQL.API.Comments
alias Mobilizon.Storage.Page
alias Mobilizon.Users.User
alias Mobilizon.Web.Endpoint
import Mobilizon.Web.Gettext, only: [dgettext: 2]
require Logger
@spec find_conversations_for_event(Event.t(), map(), Absinthe.Resolution.t()) ::
{:ok, Page.t(ConversationView.t())} | {:error, :unauthenticated}
def find_conversations_for_event(
%Event{id: event_id, attributed_to_id: attributed_to_id},
%{page: page, limit: limit},
%{
context: %{
current_actor: %Actor{id: actor_id}
}
}
)
when not is_nil(attributed_to_id) do
if Actors.is_member?(actor_id, attributed_to_id) do
{:ok,
event_id
|> Conversations.find_conversations_for_event(attributed_to_id, page, limit)
|> conversation_participant_to_view()}
else
{:ok, %Page{total: 0, elements: []}}
end
end
@spec find_conversations_for_event(Event.t(), map(), Absinthe.Resolution.t()) ::
{:ok, Page.t(ConversationView.t())} | {:error, :unauthenticated}
def find_conversations_for_event(
%Event{id: event_id, organizer_actor_id: organizer_actor_id},
%{page: page, limit: limit},
%{
context: %{
current_actor: %Actor{id: actor_id}
}
}
) do
if organizer_actor_id == actor_id do
{:ok,
event_id
|> Conversations.find_conversations_for_event(actor_id, page, limit)
|> conversation_participant_to_view()}
else
{:ok, %Page{total: 0, elements: []}}
end
end
def list_conversations(%Actor{id: actor_id}, %{page: page, limit: limit}, %{
context: %{
current_actor: %Actor{id: _current_actor_id}
}
}) do
{:ok,
actor_id
|> Conversations.list_conversation_participants_for_actor(page, limit)
|> conversation_participant_to_view()}
end
def list_conversations(%User{id: user_id}, %{page: page, limit: limit}, %{
context: %{
current_actor: %Actor{id: _current_actor_id}
}
}) do
{:ok,
user_id
|> Conversations.list_conversation_participants_for_user(page, limit)
|> conversation_participant_to_view()}
end
def unread_conversations_count(%Actor{id: actor_id}, _args, %{
context: %{
current_user: %User{} = user
}
}) do
case User.owns_actor(user, actor_id) do
{:is_owned, %Actor{}} ->
{:ok, Conversations.count_unread_conversation_participants_for_person(actor_id)}
_ ->
{:error, :unauthorized}
end
end
def get_conversation(_parent, %{id: conversation_participant_id}, %{
context: %{
current_actor: %Actor{id: performing_actor_id}
}
}) do
case Conversations.get_conversation_participant(conversation_participant_id) do
nil ->
{:error, :not_found}
%ConversationParticipant{actor_id: actor_id} = conversation_participant ->
if actor_id == performing_actor_id or Actors.is_member?(performing_actor_id, actor_id) do
{:ok, conversation_participant_to_view(conversation_participant)}
else
{:error, :not_found}
end
end
end
def get_comments_for_conversation(
%ConversationView{origin_comment_id: origin_comment_id, actor_id: conversation_actor_id},
%{page: page, limit: limit},
%{
context: %{
current_actor: %Actor{id: performing_actor_id}
}
}
) do
if conversation_actor_id == performing_actor_id or
Actors.is_member?(performing_actor_id, conversation_actor_id) do
{:ok,
Mobilizon.Discussions.get_comments_in_reply_to_comment_id(origin_comment_id, page, limit)}
else
{:error, :unauthorized}
end
end
def create_conversation(
_parent,
%{actor_id: actor_id} = args,
%{
context: %{
current_actor: %Actor{} = current_actor
}
}
) do
if authorized_to_reply?(
Map.get(args, :conversation_id),
Map.get(args, :attributed_to_id),
current_actor.id
) do
case Comments.create_conversation(args) do
{:ok, _activity, %Conversation{} = conversation} ->
Absinthe.Subscription.publish(
Endpoint,
Conversations.count_unread_conversation_participants_for_person(current_actor.id),
person_unread_conversations_count: current_actor.id
)
conversation_participant_actor =
args |> Map.get(:attributed_to_id, actor_id) |> Actors.get_actor()
{:ok, conversation_to_view(conversation, conversation_participant_actor)}
{:error, :empty_participants} ->
{:error,
dgettext(
"errors",
"Conversation needs to mention at least one participant that's not yourself"
)}
end
else
Logger.debug(
"Actor #{current_actor.id} is not authorized to reply to conversation #{inspect(Map.get(args, :conversation_id))}"
)
{:error, :unauthorized}
end
end
def update_conversation(_parent, %{conversation_id: conversation_participant_id, read: read}, %{
context: %{
current_actor: %Actor{id: current_actor_id}
}
}) do
with {:no_participant,
%ConversationParticipant{actor_id: actor_id} = conversation_participant} <-
{:no_participant,
Conversations.get_conversation_participant(conversation_participant_id)},
{:valid_actor, true} <-
{:valid_actor,
actor_id == current_actor_id or
Actors.is_member?(current_actor_id, actor_id)},
{:ok, %ConversationParticipant{} = conversation_participant} <-
Conversations.update_conversation_participant(conversation_participant, %{
unread: !read
}) do
Absinthe.Subscription.publish(
Endpoint,
Conversations.count_unread_conversation_participants_for_person(actor_id),
person_unread_conversations_count: actor_id
)
{:ok, conversation_participant_to_view(conversation_participant)}
else
{:no_participant, _} ->
{:error, :not_found}
{:valid_actor, _} ->
{:error, :unauthorized}
end
end
def delete_conversation(_, _, _), do: :ok
defp conversation_participant_to_view(%Page{elements: elements} = page) do
%Page{page | elements: Enum.map(elements, &conversation_participant_to_view/1)}
end
defp conversation_participant_to_view(%ConversationParticipant{} = conversation_participant) do
value =
conversation_participant
|> Map.from_struct()
|> Map.merge(Map.from_struct(conversation_participant.conversation))
|> Map.delete(:conversation)
|> Map.put(
:participants,
Enum.map(
conversation_participant.conversation.participants,
&conversation_participant_to_actor/1
)
)
|> Map.put(:conversation_participant_id, conversation_participant.id)
struct(ConversationView, value)
end
defp conversation_to_view(
%Conversation{id: conversation_id} = conversation,
%Actor{id: actor_id} = actor,
unread \\ true
) do
value =
conversation
|> Map.from_struct()
|> Map.put(:actor, actor)
|> Map.put(:unread, unread)
|> Map.put(
:conversation_participant_id,
Conversations.get_participant_by_conversation_and_actor(conversation_id, actor_id).id
)
struct(ConversationView, value)
end
defp conversation_participant_to_actor(%Actor{} = actor), do: actor
defp conversation_participant_to_actor(%ConversationParticipant{} = conversation_participant),
do: conversation_participant.actor
@spec authorized_to_reply?(String.t() | nil, String.t() | nil, String.t()) :: boolean()
# Not a reply
defp authorized_to_reply?(conversation_id, _attributed_to_id, _current_actor_id)
when is_nil(conversation_id),
do: true
# We are authorized to reply if we are one of the participants, or if we a a member of a participant group
defp authorized_to_reply?(conversation_id, attributed_to_id, current_actor_id) do
case Conversations.get_conversation(conversation_id) do
nil ->
false
%Conversation{participants: participants} ->
participant_ids = Enum.map(participants, fn participant -> to_string(participant.id) end)
to_string(current_actor_id) in participant_ids or
Enum.any?(participant_ids, fn participant_id ->
Actors.is_member?(current_actor_id, participant_id) and
attributed_to_id == participant_id
end)
end
end
end

View file

@ -2,9 +2,11 @@ defmodule Mobilizon.GraphQL.Resolvers.Participant do
@moduledoc """
Handles the participation-related GraphQL calls.
"""
alias Mobilizon.{Actors, Config, Crypto, Events}
alias Mobilizon.{Actors, Config, Conversations, Crypto, Events}
alias Mobilizon.Actors.Actor
alias Mobilizon.Conversations.{Conversation, ConversationView}
alias Mobilizon.Events.{Event, Participant}
alias Mobilizon.GraphQL.API.Comments
alias Mobilizon.GraphQL.API.Participations
alias Mobilizon.Service.Export.Participants.{CSV, ODS, PDF}
alias Mobilizon.Users.User
@ -346,6 +348,75 @@ defmodule Mobilizon.GraphQL.Resolvers.Participant do
def export_event_participants(_, _, _), do: {:error, :unauthorized}
def send_private_messages_to_participants(
_parent,
%{roles: roles, event_id: event_id, actor_id: actor_id} =
args,
%{
context: %{
current_user: %User{locale: _locale},
current_actor: %Actor{id: current_actor_id}
}
}
) do
participant_actors =
event_id
|> Events.list_all_participants_for_event(roles)
|> Enum.map(& &1.actor)
mentions =
participant_actors
|> Enum.map(& &1.id)
|> Enum.uniq()
|> Enum.map(&%{actor_id: &1, event_id: event_id})
args =
Map.merge(args, %{
mentions: mentions,
visibility: :private
})
with {:member, true} <-
{:member,
current_actor_id == actor_id or Actors.is_member?(current_actor_id, actor_id)},
{:ok, _activity, %Conversation{} = conversation} <- Comments.create_conversation(args) do
{:ok, conversation_to_view(conversation, Actors.get_actor(actor_id))}
else
{:member, false} ->
{:error, :unauthorized}
{:error, :empty_participants} ->
{:error,
dgettext(
"errors",
"There are no participants matching the audience you've selected."
)}
{:error, err} ->
{:error, err}
end
end
def send_private_messages_to_participants(_parent, _args, _resolution),
do: {:error, :unauthorized}
defp conversation_to_view(
%Conversation{id: conversation_id} = conversation,
%Actor{id: actor_id} = actor
) do
value =
conversation
|> Map.from_struct()
|> Map.put(:actor, actor)
|> Map.put(:unread, false)
|> Map.put(
:conversation_participant_id,
Conversations.get_participant_by_conversation_and_actor(conversation_id, actor_id).id
)
struct(ConversationView, value)
end
@spec valid_email?(String.t() | nil) :: boolean
defp valid_email?(email) when is_nil(email), do: false

View file

@ -55,6 +55,7 @@ defmodule Mobilizon.GraphQL.Schema do
import_types(Schema.Users.ActivitySetting)
import_types(Schema.FollowedGroupActivityType)
import_types(Schema.AuthApplicationType)
import_types(Schema.ConversationType)
@desc "A struct containing the id of the deleted object"
object :deleted_object do
@ -165,6 +166,7 @@ defmodule Mobilizon.GraphQL.Schema do
import_fields(:todo_list_queries)
import_fields(:todo_queries)
import_fields(:discussion_queries)
import_fields(:conversation_queries)
import_fields(:resource_queries)
import_fields(:post_queries)
import_fields(:statistics_queries)
@ -189,6 +191,7 @@ defmodule Mobilizon.GraphQL.Schema do
import_fields(:todo_list_mutations)
import_fields(:todo_mutations)
import_fields(:discussion_mutations)
import_fields(:conversation_mutations)
import_fields(:resource_mutations)
import_fields(:post_mutations)
import_fields(:actor_mutations)
@ -204,6 +207,7 @@ defmodule Mobilizon.GraphQL.Schema do
subscription do
import_fields(:person_subscriptions)
import_fields(:discussion_subscriptions)
import_fields(:conversation_subscriptions)
end
@spec middleware(list(module()), any(), map()) :: list(module())

View file

@ -7,7 +7,7 @@ defmodule Mobilizon.GraphQL.Schema.Actors.PersonType do
import Absinthe.Resolution.Helpers, only: [dataloader: 2]
alias Mobilizon.Events
alias Mobilizon.GraphQL.Resolvers.{Media, Person}
alias Mobilizon.GraphQL.Resolvers.{Conversation, Media, Person}
alias Mobilizon.GraphQL.Schema
import_types(Schema.Events.FeedTokenType)
@ -136,6 +136,25 @@ defmodule Mobilizon.GraphQL.Schema.Actors.PersonType do
arg(:limit, :integer, default_value: 10, description: "The limit of follows per page")
resolve(&Person.person_follows/3)
end
@desc "The list of conversations this person has"
field(:conversations, :paginated_conversation_list,
meta: [private: true, rule: :"read:profile:conversations"]
) do
arg(:page, :integer,
default_value: 1,
description: "The page in the conversations list"
)
arg(:limit, :integer, default_value: 10, description: "The limit of conversations per page")
resolve(&Conversation.list_conversations/3)
end
field(:unread_conversations_count, :integer,
meta: [private: true, rule: :"read:profile:conversations"]
) do
resolve(&Conversation.unread_conversations_count/3)
end
end
@desc """
@ -353,5 +372,16 @@ defmodule Mobilizon.GraphQL.Schema.Actors.PersonType do
{:ok, topic: [args.group, args.person_id]}
end)
end
@desc "Notify when a person unread conversations count changed"
field(:person_unread_conversations_count, :integer,
meta: [private: true, rule: :"read:profile:conversations"]
) do
arg(:person_id, non_null(:id), description: "The person's ID")
config(fn args, _ ->
{:ok, topic: [args.person_id]}
end)
end
end
end

View file

@ -0,0 +1,132 @@
defmodule Mobilizon.GraphQL.Schema.ConversationType do
@moduledoc """
Schema representation for conversation
"""
use Absinthe.Schema.Notation
# import Absinthe.Resolution.Helpers, only: [dataloader: 1]
# alias Mobilizon.Actors
alias Mobilizon.GraphQL.Resolvers.Conversation
@desc "A conversation"
object :conversation do
meta(:authorize, :user)
interfaces([:activity_object])
field(:id, :id, description: "Internal ID for this conversation")
field(:conversation_participant_id, :id,
description: "Internal ID for the conversation participant"
)
field(:last_comment, :comment, description: "The last comment of the conversation")
field :comments, :paginated_comment_list do
arg(:page, :integer, default_value: 1)
arg(:limit, :integer, default_value: 10)
resolve(&Conversation.get_comments_for_conversation/3)
description("The comments for the conversation")
end
field(:participants, list_of(:person),
# resolve: dataloader(Actors),
description: "The list of participants to the conversation"
)
field(:event, :event, description: "The event this conversation is associated to")
field(:actor, :person,
# resolve: dataloader(Actors),
description: "The actor concerned by the conversation"
)
field(:unread, :boolean, description: "Whether this conversation is unread")
field(:inserted_at, :datetime, description: "When was this conversation's created")
field(:updated_at, :datetime, description: "When was this conversation's updated")
end
@desc "A paginated list of conversations"
object :paginated_conversation_list do
meta(:authorize, :user)
field(:elements, list_of(:conversation), description: "A list of conversations")
field(:total, :integer, description: "The total number of conversations in the list")
end
object :conversation_queries do
@desc "Get a conversation"
field :conversation, type: :conversation do
arg(:id, :id, description: "The conversation's ID")
middleware(Rajska.QueryAuthorization,
permit: :user,
scope: Mobilizon.Conversations.Conversation,
rule: :"read:conversations",
args: %{id: :id}
)
resolve(&Conversation.get_conversation/3)
end
end
object :conversation_mutations do
@desc "Post a private message"
field :post_private_message, type: :conversation do
arg(:text, non_null(:string), description: "The conversation's first comment body")
arg(:actor_id, non_null(:id), description: "The profile ID to create the conversation as")
arg(:attributed_to_id, :id, description: "The group ID to attribute the conversation to")
arg(:conversation_id, :id, description: "The conversation ID to reply to")
arg(:language, :string, description: "The comment language", default_value: "und")
arg(:mentions, list_of(:string), description: "A list of federated usernames to mention")
middleware(Rajska.QueryAuthorization,
permit: :user,
scope: Mobilizon.Conversations.ConversationParticipant,
rule: :"write:conversation:create",
args: %{actor_id: :actor_id}
)
resolve(&Conversation.create_conversation/3)
end
@desc "Update a conversation"
field :update_conversation, type: :conversation do
arg(:conversation_id, non_null(:id), description: "The conversation's ID")
arg(:read, non_null(:boolean), description: "Whether the conversation is read or not")
middleware(Rajska.QueryAuthorization,
permit: :user,
scope: Mobilizon.Conversations.Conversation,
rule: :"write:conversation:update",
args: %{id: :conversation_id}
)
resolve(&Conversation.update_conversation/3)
end
@desc "Delete a conversation"
field :delete_conversation, type: :conversation do
arg(:conversation_id, non_null(:id), description: "The conversation's ID")
middleware(Rajska.QueryAuthorization,
permit: :user,
scope: Mobilizon.Conversations.Conversation,
rule: :"write:conversation:delete",
args: %{id: :conversation_id}
)
resolve(&Conversation.delete_conversation/3)
end
end
object :conversation_subscriptions do
@desc "Notify when a conversation changed"
field :conversation_comment_changed, :conversation do
arg(:id, non_null(:id), description: "The conversation's ID")
config(fn args, _ ->
{:ok, topic: args.id}
end)
end
end
end

View file

@ -56,6 +56,8 @@ defmodule Mobilizon.GraphQL.Schema.Discussions.CommentType do
description: "Whether this comment needs to be announced to participants"
)
field(:conversation, :conversation, description: "The conversation this comment is part of")
field(:language, :string, description: "The comment language")
end

View file

@ -8,7 +8,7 @@ defmodule Mobilizon.GraphQL.Schema.EventType do
import Absinthe.Resolution.Helpers, only: [dataloader: 1, dataloader: 2]
alias Mobilizon.{Actors, Addresses, Discussions}
alias Mobilizon.GraphQL.Resolvers.{Event, Media, Tag}
alias Mobilizon.GraphQL.Resolvers.{Conversation, Event, Media, Tag}
alias Mobilizon.GraphQL.Schema
import_types(Schema.AddressType)
@ -113,6 +113,18 @@ defmodule Mobilizon.GraphQL.Schema.EventType do
field(:options, :event_options, description: "The event options")
field(:metadata, list_of(:event_metadata), description: "A key-value list of metadata")
field(:language, :string, description: "The event language")
field(:conversations, :paginated_conversation_list,
description: "The list of conversations started on this event"
) do
arg(:page, :integer,
default_value: 1,
description: "The page in the paginated conversation list"
)
arg(:limit, :integer, default_value: 10, description: "The limit of conversations per page")
resolve(&Conversation.find_conversations_for_event/3)
end
end
@desc "The list of visibility options for an event"

View file

@ -159,5 +159,34 @@ defmodule Mobilizon.GraphQL.Schema.Events.ParticipantType do
resolve(&Participant.export_event_participants/3)
end
@desc "Send private messages to participants"
field :send_event_private_message, :conversation do
arg(:event_id, non_null(:id),
description: "The ID from the event for which to export participants"
)
arg(:roles, list_of(:participant_role_enum),
default_value: [],
description: "The participant roles to include"
)
arg(:text, non_null(:string), description: "The private message body")
arg(:actor_id, non_null(:id),
description: "The profile ID to create the private message as"
)
arg(:language, :string, description: "The private message language", default_value: "und")
middleware(Rajska.QueryAuthorization,
permit: :user,
scope: Mobilizon.Events.Event,
rule: :"write:event:participants:private_message",
args: %{id: :event_id}
)
resolve(&Participant.send_private_messages_to_participants/3)
end
end
end

View file

@ -8,7 +8,7 @@ defmodule Mobilizon.GraphQL.Schema.UserType do
alias Mobilizon.Events
alias Mobilizon.GraphQL.Resolvers.Application, as: ApplicationResolver
alias Mobilizon.GraphQL.Resolvers.{Media, User}
alias Mobilizon.GraphQL.Resolvers.{Conversation, Media, User}
alias Mobilizon.GraphQL.Resolvers.Users.ActivitySettings
alias Mobilizon.GraphQL.Schema
@ -191,6 +191,19 @@ defmodule Mobilizon.GraphQL.Schema.UserType do
) do
resolve(&ApplicationResolver.get_user_applications/3)
end
@desc "The list of conversations this person has"
field(:conversations, :paginated_conversation_list,
meta: [private: true, rule: :"read:profile:conversations"]
) do
arg(:page, :integer,
default_value: 1,
description: "The page in the conversations list"
)
arg(:limit, :integer, default_value: 10, description: "The limit of conversations per page")
resolve(&Conversation.list_conversations/3)
end
end
@desc "The list of roles an user can have"

View file

@ -17,10 +17,24 @@ defmodule Mobilizon.Activities do
very_high: 50
)
@activity_types ["event", "post", "discussion", "resource", "group", "member", "comment"]
@activity_types [
"event",
"post",
"conversation",
"discussion",
"resource",
"group",
"member",
"comment"
]
@event_activity_subjects ["event_created", "event_updated", "event_deleted", "comment_posted"]
@participant_activity_subjects ["event_new_participation"]
@post_activity_subjects ["post_created", "post_updated", "post_deleted"]
@conversation_activity_subjects [
"conversation_created",
"conversation_replied",
"conversation_event_announcement"
]
@discussion_activity_subjects [
"discussion_created",
"discussion_replied",
@ -49,6 +63,7 @@ defmodule Mobilizon.Activities do
@settings_activity_subjects ["group_created", "group_updated"]
@subjects @event_activity_subjects ++
@conversation_activity_subjects ++
@participant_activity_subjects ++
@post_activity_subjects ++
@discussion_activity_subjects ++
@ -61,6 +76,7 @@ defmodule Mobilizon.Activities do
"actor",
"post",
"discussion",
"conversation",
"resource",
"member",
"group",

View file

@ -10,6 +10,7 @@ defmodule Mobilizon.Actors.Actor do
alias Mobilizon.{Actors, Addresses, Config, Crypto, Mention, Share}
alias Mobilizon.Actors.{ActorOpenness, ActorType, ActorVisibility, Follower, Member}
alias Mobilizon.Addresses.Address
alias Mobilizon.Conversations.Conversation
alias Mobilizon.Discussions.Comment
alias Mobilizon.Events.{Event, FeedToken, Participant}
alias Mobilizon.Medias.File
@ -196,6 +197,11 @@ defmodule Mobilizon.Actors.Actor do
has_many(:owner_shares, Share, foreign_key: :owner_actor_id)
many_to_many(:memberships, __MODULE__, join_through: Member)
many_to_many(:conversations, Conversation,
join_through: "conversation_participants",
join_keys: [conversation_id: :id, participant_id: :id]
)
timestamps()
end

View file

@ -0,0 +1,57 @@
defmodule Mobilizon.Conversations.Conversation do
@moduledoc """
Represents a conversation
"""
use Ecto.Schema
import Ecto.Changeset
alias Ecto.Changeset
alias Mobilizon.Actors.Actor
alias Mobilizon.Conversations.ConversationParticipant
alias Mobilizon.Discussions.Comment
alias Mobilizon.Events.Event
@type t :: %__MODULE__{
id: String.t(),
origin_comment: Comment.t(),
last_comment: Comment.t(),
participants: list(Actor.t())
}
@required_attrs [:origin_comment_id, :last_comment_id]
@optional_attrs [:event_id]
@attrs @required_attrs ++ @optional_attrs
schema "conversations" do
belongs_to(:origin_comment, Comment)
belongs_to(:last_comment, Comment)
belongs_to(:event, Event)
has_many(:comments, Comment)
many_to_many(:participants, Actor,
join_through: ConversationParticipant,
join_keys: [conversation_id: :id, actor_id: :id],
on_replace: :delete
)
timestamps(type: :utc_datetime)
end
@doc false
@spec changeset(t | Ecto.Schema.t(), map) :: Ecto.Changeset.t()
def changeset(%__MODULE__{} = conversation, attrs) do
conversation
|> cast(attrs, @attrs)
|> maybe_set_participants(attrs)
|> validate_required(@required_attrs)
end
defp maybe_set_participants(%Changeset{} = changeset, %{participants: participants})
when length(participants) > 0 do
put_assoc(changeset, :participants, participants)
end
defp maybe_set_participants(%Changeset{} = changeset, _), do: changeset
end

View file

@ -0,0 +1,40 @@
defmodule Mobilizon.Conversations.ConversationParticipant do
@moduledoc """
Represents a conversation participant
"""
use Ecto.Schema
import Ecto.Changeset
alias Mobilizon.Actors.Actor
alias Mobilizon.Conversations.Conversation
@type t :: %__MODULE__{
conversation: Conversation.t(),
actor: Actor.t(),
unread: boolean()
}
@required_attrs [:actor_id, :conversation_id]
@optional_attrs [:unread]
@attrs @required_attrs ++ @optional_attrs
schema "conversation_participants" do
belongs_to(:conversation, Conversation)
belongs_to(:actor, Actor)
field(:unread, :boolean, default: true)
timestamps(type: :utc_datetime)
end
@doc false
@spec changeset(t | Ecto.Schema.t(), map) :: Ecto.Changeset.t()
def changeset(%__MODULE__{} = conversation, attrs) do
conversation
|> cast(attrs, @attrs)
|> validate_required(@required_attrs)
|> foreign_key_constraint(:conversation_id)
|> foreign_key_constraint(:actor_id)
end
end

View file

@ -0,0 +1,22 @@
defmodule Mobilizon.Conversations.ConversationView do
@moduledoc """
Represents a conversation view for GraphQL API
"""
defstruct [
:id,
:conversation_participant_id,
:origin_comment,
:origin_comment_id,
:last_comment,
:last_comment_id,
:event,
:event_id,
:actor,
:actor_id,
:unread,
:inserted_at,
:updated_at,
:participants
]
end

View file

@ -0,0 +1,344 @@
defmodule Mobilizon.Conversations do
@moduledoc """
The conversations context
"""
import Ecto.Query
alias Ecto.Changeset
alias Ecto.Multi
alias Mobilizon.Actors.{Actor, Member}
alias Mobilizon.Conversations.{Conversation, ConversationParticipant}
alias Mobilizon.Discussions.Comment
alias Mobilizon.Events.Event
alias Mobilizon.Storage.{Page, Repo}
@conversation_preloads [
:origin_comment,
:last_comment,
:event,
:participants
]
@comment_preloads [
:actor,
:event,
:attributed_to,
:in_reply_to_comment,
:origin_comment,
:replies,
:tags,
:mentions,
:media
]
@doc """
Get a conversation by it's ID
"""
@spec get_conversation(String.t() | integer()) :: Conversation.t() | nil
def get_conversation(conversation_id) do
Conversation
|> Repo.get(conversation_id)
|> Repo.preload(@conversation_preloads)
end
@doc """
Get a conversation by it's ID
"""
@spec get_conversation_participant(String.t() | integer()) :: Conversation.t() | nil
def get_conversation_participant(conversation_participant_id) do
preload_conversation_participant_details()
|> where([cp], cp.id == ^conversation_participant_id)
|> Repo.one()
end
def get_participant_by_conversation_and_actor(conversation_id, actor_id) do
preload_conversation_participant_details()
|> where([cp], cp.conversation_id == ^conversation_id and cp.actor_id == ^actor_id)
|> Repo.one()
end
defp preload_conversation_participant_details do
ConversationParticipant
|> join(:inner, [cp], c in Conversation, on: cp.conversation_id == c.id)
|> join(:left, [_cp, c], e in Event, on: c.event_id == e.id)
|> join(:inner, [cp], a in Actor, on: cp.actor_id == a.id)
|> join(:inner, [_cp, c], lc in Comment, on: c.last_comment_id == lc.id)
|> join(:inner, [_cp, c], oc in Comment, on: c.origin_comment_id == oc.id)
|> join(:inner, [_cp, c], p in ConversationParticipant, on: c.id == p.conversation_id)
|> join(:inner, [_cp, _c, _e, _a, _lc, _oc, p], ap in Actor, on: p.actor_id == ap.id)
|> preload([_cp, c, e, a, lc, oc, p, ap],
actor: a,
conversation:
{c, event: e, last_comment: lc, origin_comment: oc, participants: {p, actor: ap}}
)
end
@doc """
Get a paginated list of conversations for an actor
"""
@spec find_conversations_for_actor(Actor.t(), integer | nil, integer | nil) ::
Page.t(Conversation.t())
def find_conversations_for_actor(%Actor{id: actor_id}, page \\ nil, limit \\ nil) do
Conversation
|> where([c], c.actor_id == ^actor_id)
|> preload(^@conversation_preloads)
|> order_by(desc: :updated_at)
|> Page.build_page(page, limit)
end
@spec find_conversations_for_event(
String.t() | integer,
String.t() | integer,
integer | nil,
integer | nil
) :: Page.t(ConversationParticipant.t())
def find_conversations_for_event(event_id, actor_id, page \\ nil, limit \\ nil) do
ConversationParticipant
|> join(:inner, [cp], c in Conversation, on: cp.conversation_id == c.id)
|> join(:left, [_cp, c], e in Event, on: c.event_id == e.id)
|> join(:inner, [cp], a in Actor, on: cp.actor_id == a.id)
|> join(:inner, [_cp, c], lc in Comment, on: c.last_comment_id == lc.id)
|> join(:inner, [_cp, c], oc in Comment, on: c.origin_comment_id == oc.id)
|> join(:inner, [_cp, c], p in ConversationParticipant, on: c.id == p.conversation_id)
|> join(:inner, [_cp, _c, _e, _a, _lc, _oc, p], ap in Actor, on: p.actor_id == ap.id)
|> where([_cp, c], c.event_id == ^event_id)
|> where([cp], cp.actor_id == ^actor_id)
|> preload([_cp, c, e, a, lc, oc, p, ap],
actor: a,
conversation:
{c, event: e, last_comment: lc, origin_comment: oc, participants: {p, actor: ap}}
)
|> Page.build_page(page, limit)
end
@spec list_conversation_participants_for_actor(
integer | String.t(),
integer | nil,
integer | nil
) ::
Page.t(ConversationParticipant.t())
def list_conversation_participants_for_actor(actor_id, page \\ nil, limit \\ nil) do
subquery =
ConversationParticipant
|> distinct([cp], cp.conversation_id)
|> join(:left, [cp], m in Member, on: cp.actor_id == m.parent_id)
|> where([cp], cp.actor_id == ^actor_id)
|> or_where(
[_cp, m],
m.actor_id == ^actor_id and m.role in [:creator, :administrator, :moderator]
)
subquery
|> subquery()
|> order_by([cp], desc: cp.unread, desc: cp.updated_at)
|> preload([:actor, conversation: [:last_comment, :participants]])
|> Page.build_page(page, limit)
end
@spec list_conversation_participants_for_user(
integer | String.t(),
integer | nil,
integer | nil
) ::
Page.t(ConversationParticipant.t())
def list_conversation_participants_for_user(user_id, page \\ nil, limit \\ nil) do
ConversationParticipant
|> join(:inner, [cp], a in Actor, on: cp.actor_id == a.id)
|> where([_cp, a], a.user_id == ^user_id)
|> preload([:actor, conversation: [:last_comment, :participants]])
|> Page.build_page(page, limit)
end
@spec list_conversation_participants_for_conversation(integer | String.t()) ::
list(ConversationParticipant.t())
def list_conversation_participants_for_conversation(conversation_id) do
ConversationParticipant
|> where([cp], cp.conversation_id == ^conversation_id)
|> Repo.all()
end
@spec count_unread_conversation_participants_for_person(integer | String.t()) ::
non_neg_integer()
def count_unread_conversation_participants_for_person(actor_id) do
ConversationParticipant
|> where([cp], cp.actor_id == ^actor_id and cp.unread == true)
|> Repo.aggregate(:count)
end
@doc """
Creates a conversation.
"""
@spec create_conversation(map()) ::
{:ok, Conversation.t()} | {:error, atom(), Changeset.t(), map()}
def create_conversation(attrs) do
with {:ok, %{comment: %Comment{} = _comment, conversation: %Conversation{} = conversation}} <-
Multi.new()
|> Multi.insert(
:comment,
Comment.changeset(
%Comment{},
Map.merge(attrs, %{
actor_id: attrs.actor_id,
attributed_to_id: attrs.actor_id,
visibility: :private
})
)
)
|> Multi.insert(:conversation, fn %{
comment: %Comment{
id: comment_id,
origin_comment_id: origin_comment_id
}
} ->
Conversation.changeset(
%Conversation{},
Map.merge(attrs, %{
last_comment_id: comment_id,
origin_comment_id: origin_comment_id || comment_id,
participants: attrs.participants
})
)
end)
|> Multi.update(:update_comment, fn %{
comment: %Comment{} = comment,
conversation: %Conversation{id: conversation_id}
} ->
Comment.changeset(
comment,
%{conversation_id: conversation_id}
)
end)
|> Multi.update_all(
:conversation_participants,
fn %{
conversation: %Conversation{
id: conversation_id
}
} ->
ConversationParticipant
|> where(
[cp],
cp.conversation_id == ^conversation_id and cp.actor_id == ^attrs.actor_id
)
|> update([cp], set: [unread: false, updated_at: ^NaiveDateTime.utc_now()])
end,
[]
)
|> Repo.transaction(),
%Conversation{} = conversation <- Repo.preload(conversation, @conversation_preloads) do
{:ok, conversation}
end
end
@doc """
Create a response to a conversation
"""
@spec reply_to_conversation(Conversation.t(), map()) ::
{:ok, Conversation.t()} | {:error, atom(), Ecto.Changeset.t(), map()}
def reply_to_conversation(%Conversation{id: conversation_id} = conversation, attrs \\ %{}) do
attrs =
Map.merge(attrs, %{
conversation_id: conversation_id,
actor_id: Map.get(attrs, :creator_id, Map.get(attrs, :actor_id)),
origin_comment_id: conversation.origin_comment_id,
in_reply_to_comment_id: conversation.last_comment_id,
visibility: :private
})
changeset =
Comment.changeset(
%Comment{},
attrs
)
with {:ok, %{comment: %Comment{} = comment, conversation: %Conversation{} = conversation}} <-
Multi.new()
|> Multi.insert(
:comment,
changeset
)
|> Multi.update(:conversation, fn %{comment: %Comment{id: comment_id}} ->
Conversation.changeset(
conversation,
%{last_comment_id: comment_id}
)
end)
|> Multi.update_all(
:conversation_participants,
fn %{
conversation: %Conversation{
id: conversation_id
}
} ->
ConversationParticipant
|> where(
[cp],
cp.conversation_id == ^conversation_id and cp.actor_id != ^attrs.actor_id
)
|> update([cp], set: [unread: true, updated_at: ^NaiveDateTime.utc_now()])
end,
[]
)
|> Multi.update_all(
:conversation_participants_author,
fn %{
conversation: %Conversation{
id: conversation_id
}
} ->
ConversationParticipant
|> where(
[cp],
cp.conversation_id == ^conversation_id and cp.actor_id == ^attrs.actor_id
)
|> update([cp], set: [unread: false, updated_at: ^NaiveDateTime.utc_now()])
end,
[]
)
|> Repo.transaction(),
# Conversation is not updated
%Comment{} = comment <- Repo.preload(comment, @comment_preloads) do
{:ok, %Conversation{conversation | last_comment: comment}}
end
end
@doc """
Update a conversation.
"""
@spec update_conversation(Conversation.t(), map()) ::
{:ok, Conversation.t()} | {:error, Changeset.t()}
def update_conversation(%Conversation{} = conversation, attrs \\ %{}) do
conversation
|> Conversation.changeset(attrs)
|> Repo.update()
end
@doc """
Delete a conversation.
"""
@spec delete_conversation(Conversation.t()) ::
{:ok, %{comments: {integer() | nil, any()}}} | {:error, :comments, Changeset.t(), map()}
def delete_conversation(%Conversation{id: conversation_id}) do
Multi.new()
|> Multi.delete_all(:comments, fn _ ->
where(Comment, [c], c.conversation_id == ^conversation_id)
end)
# |> Multi.delete(:conversation, conversation)
|> Repo.transaction()
end
@doc """
Update a conversation participant. Only their read status for now
"""
@spec update_conversation_participant(ConversationParticipant.t(), map()) ::
{:ok, ConversationParticipant.t()} | {:error, Changeset.t()}
def update_conversation_participant(
%ConversationParticipant{} = conversation_participant,
attrs \\ %{}
) do
conversation_participant
|> ConversationParticipant.changeset(attrs)
|> Repo.update()
end
end

View file

@ -9,6 +9,7 @@ defmodule Mobilizon.Discussions.Comment do
import Mobilizon.Storage.Ecto, only: [maybe_add_published_at: 1]
alias Mobilizon.Actors.Actor
alias Mobilizon.Conversations.Conversation
alias Mobilizon.Discussions.{Comment, CommentVisibility, Discussion}
alias Mobilizon.Events.{Event, Tag}
alias Mobilizon.Medias.Media
@ -49,7 +50,9 @@ defmodule Mobilizon.Discussions.Comment do
:local,
:is_announcement,
:discussion_id,
:language
:conversation_id,
:language,
:visibility
]
@attrs @required_attrs ++ @optional_attrs
@ -71,16 +74,17 @@ defmodule Mobilizon.Discussions.Comment do
belongs_to(:in_reply_to_comment, Comment, foreign_key: :in_reply_to_comment_id)
belongs_to(:origin_comment, Comment, foreign_key: :origin_comment_id)
belongs_to(:discussion, Discussion, type: :binary_id)
belongs_to(:conversation, Conversation)
has_many(:replies, Comment, foreign_key: :origin_comment_id)
many_to_many(:tags, Tag, join_through: "comments_tags", on_replace: :delete)
has_many(:mentions, Mention)
has_many(:mentions, Mention, on_replace: :delete)
many_to_many(:media, Media, join_through: "comments_medias", on_replace: :delete)
timestamps(type: :utc_datetime)
end
@doc """
Returns the id of the first comment in the discussion.
Returns the id of the first comment in the discussion or conversation.
"""
@spec get_thread_id(t) :: integer
def get_thread_id(%__MODULE__{id: id, origin_comment_id: origin_comment_id}) do
@ -181,7 +185,7 @@ defmodule Mobilizon.Discussions.Comment do
Tag.changeset(%Tag{}, tag)
end
defp process_mention(tag) do
Mention.changeset(%Mention{}, tag)
defp process_mention(mention) do
Mention.changeset(%Mention{}, mention)
end
end

View file

@ -42,9 +42,9 @@ defmodule Mobilizon.Discussions do
:origin_comment,
:replies,
:tags,
:mentions,
:discussion,
:media
:media,
mentions: [:actor]
]
@discussion_preloads [
@ -76,6 +76,7 @@ defmodule Mobilizon.Discussions do
Comment
|> join(:left, [c], r in Comment, on: r.origin_comment_id == c.id)
|> where([c, _], is_nil(c.in_reply_to_comment_id))
|> where([c], c.visibility in ^@public_visibility)
# TODO: This was added because we don't want to count deleted comments in total_replies.
# However, it also excludes all top-level comments with deleted replies from being selected
# |> where([_, r], is_nil(r.deleted_at))
@ -197,9 +198,13 @@ defmodule Mobilizon.Discussions do
"""
@spec update_comment(Comment.t(), map) :: {:ok, Comment.t()} | {:error, Changeset.t()}
def update_comment(%Comment{} = comment, attrs) do
comment
|> Comment.update_changeset(attrs)
|> Repo.update()
with {:ok, %Comment{} = comment} <-
comment
|> Comment.update_changeset(attrs)
|> Repo.update(),
%Comment{} = comment <- Repo.preload(comment, @comment_preloads) do
{:ok, comment}
end
end
@doc """
@ -272,6 +277,19 @@ defmodule Mobilizon.Discussions do
|> Page.build_page(page, limit)
end
@doc """
Get all the comments contained into a discussion
"""
@spec get_comments_in_reply_to_comment_id(integer, integer | nil, integer | nil) ::
Page.t(Comment.t())
def get_comments_in_reply_to_comment_id(origin_comment_id, page \\ nil, limit \\ nil) do
Comment
|> where([c], c.id == ^origin_comment_id)
|> or_where([c], c.origin_comment_id == ^origin_comment_id)
|> order_by(asc: :inserted_at)
|> Page.build_page(page, limit)
end
@doc """
Counts local comments under events
"""

View file

@ -13,6 +13,7 @@ defmodule Mobilizon.Events.Event do
alias Mobilizon.{Addresses, Events, Medias, Mention}
alias Mobilizon.Addresses.Address
alias Mobilizon.Conversations.Conversation
alias Mobilizon.Discussions.Comment
alias Mobilizon.Events.{
@ -126,6 +127,7 @@ defmodule Mobilizon.Events.Event do
has_many(:sessions, Session)
has_many(:mentions, Mention)
has_many(:comments, Comment)
has_many(:conversations, Conversation)
many_to_many(:contacts, Actor, join_through: "event_contacts", on_replace: :delete)
many_to_many(:tags, Tag, join_through: "events_tags", on_replace: :delete)
many_to_many(:participants, Actor, join_through: Participant)

View file

@ -871,6 +871,21 @@ defmodule Mobilizon.Events do
|> Page.build_page(page, limit)
end
@doc """
Returns the whole list of participants for an event.
Default behaviour is to not return :not_approved or :not_confirmed participants
"""
@spec list_all_participants_for_event(String.t(), list(atom())) :: list(Participant.t())
def list_all_participants_for_event(
id,
roles \\ []
) do
id
|> participants_for_event_query(roles)
|> preload([:actor, :event])
|> Repo.all()
end
@spec list_actors_participants_for_event(String.t()) :: [Actor.t()]
def list_actors_participants_for_event(id) do
id

View file

@ -32,8 +32,8 @@ defmodule Mobilizon.Mention do
@doc false
@spec changeset(t | Ecto.Schema.t(), map) :: Ecto.Changeset.t()
def changeset(event, attrs) do
event
def changeset(mention, attrs) do
mention
|> cast(attrs, @attrs)
# TODO: Enforce having either event_id or comment_id
|> validate_required(@required_attrs)

View file

@ -21,7 +21,14 @@ defmodule Mobilizon.Reports do
def get_report(id) do
Report
|> Repo.get(id)
|> Repo.preload([:reported, :reporter, :manager, :events, :comments, :notes])
|> Repo.preload([
:reported,
:reporter,
:manager,
:events,
:notes,
comments: [conversation: [:participants]]
])
end
@doc """

View file

@ -0,0 +1,95 @@
defmodule Mobilizon.Service.Activity.Conversation do
@moduledoc """
Insert a conversation activity
"""
alias Mobilizon.Conversations
alias Mobilizon.Conversations.{Conversation, ConversationParticipant}
alias Mobilizon.Discussions.Comment
alias Mobilizon.Events.Event
alias Mobilizon.Service.Activity
alias Mobilizon.Service.Workers.LegacyNotifierBuilder
@behaviour Activity
@impl Activity
def insert_activity(conversation, options \\ [])
def insert_activity(
%Conversation{} = conversation,
options
) do
subject = Keyword.fetch!(options, :subject)
send_participant_notifications(subject, conversation, conversation.last_comment, options)
end
def insert_activity(_, _), do: {:ok, nil}
@impl Activity
def get_object(conversation_id) do
Conversations.get_conversation(conversation_id)
end
# An actor is mentionned
@spec send_participant_notifications(String.t(), Discussion.t(), Comment.t(), Keyword.t()) ::
{:ok, Oban.Job.t()} | {:ok, :skipped}
defp send_participant_notifications(
subject,
%Conversation{
id: conversation_id
} = conversation,
%Comment{actor_id: actor_id},
_options
)
when subject in [
"conversation_created",
"conversation_replied",
"conversation_event_announcement"
] do
# We need to send each notification individually as the conversation URL varies for each participant
conversation_id
|> Conversations.list_conversation_participants_for_conversation()
|> Enum.each(fn %ConversationParticipant{id: conversation_participant_id} =
conversation_participant ->
LegacyNotifierBuilder.enqueue(
:legacy_notify,
%{
"subject" => subject,
"subject_params" =>
Map.merge(
%{
conversation_id: conversation_id,
conversation_participant_id: conversation_participant_id
},
event_subject_params(conversation)
),
"type" => :conversation,
"object_type" => :conversation,
"author_id" => actor_id,
"object_id" => to_string(conversation_id),
"participant" => Map.take(conversation_participant, [:id, :actor_id])
}
)
end)
{:ok, :enqueued}
end
defp send_participant_notifications(_, _, _, _), do: {:ok, :skipped}
defp event_subject_params(%Conversation{
event: %Event{
id: conversation_event_id,
title: conversation_event_title,
uuid: conversation_event_uuid
}
}),
do: %{
conversation_event_id: conversation_event_id,
conversation_event_title: conversation_event_title,
conversation_event_uuid: conversation_event_uuid
}
defp event_subject_params(_), do: %{}
end

View file

@ -0,0 +1,73 @@
defmodule Mobilizon.Service.Activity.Renderer.Conversation do
@moduledoc """
Render a conversation activity
"""
alias Mobilizon.Activities.Activity
alias Mobilizon.Actors.Actor
alias Mobilizon.Service.Activity.Renderer
alias Mobilizon.Web.Endpoint
alias Mobilizon.Web.Router.Helpers, as: Routes
import Mobilizon.Web.Gettext, only: [dgettext: 3]
@behaviour Renderer
@impl Renderer
def render(%Activity{} = activity, options) do
locale = Keyword.get(options, :locale, "en")
Gettext.put_locale(locale)
profile = profile(activity)
case activity.subject do
:conversation_created ->
%{
body:
dgettext(
"activity",
"%{profile} sent you a message",
%{
profile: profile
}
),
url: conversation_url(activity)
}
:conversation_replied ->
%{
body:
dgettext(
"activity",
"%{profile} replied to your message",
%{
profile: profile
}
),
url: conversation_url(activity)
}
:conversation_event_announcement ->
%{
body:
dgettext(
"activity",
"%{profile} sent a private message about event %{event}",
%{
profile: profile,
event: event_title(activity)
}
),
url: conversation_url(activity)
}
end
end
defp conversation_url(activity) do
Routes.page_url(
Endpoint,
:conversation,
activity.subject_params["conversation_id"]
)
end
defp profile(activity), do: Actor.display_name_and_username(activity.author)
defp event_title(activity), do: activity.subject_params["conversation_event_title"]
end

View file

@ -51,17 +51,25 @@ defmodule Mobilizon.Service.Activity.Renderer do
res
end
@types_map %{
discussion: Discussion,
conversation: Conversation,
event: Event,
group: Group,
member: Member,
post: Post,
resource: Resource,
comment: Comment
}
@spec do_render(Activity.t(), Keyword.t()) :: common_render()
defp do_render(%Activity{type: type} = activity, options) do
case type do
:discussion -> Discussion.render(activity, options)
:event -> Event.render(activity, options)
:group -> Group.render(activity, options)
:member -> Member.render(activity, options)
:post -> Post.render(activity, options)
:resource -> Resource.render(activity, options)
:comment -> Comment.render(activity, options)
_ -> nil
case Map.get(@types_map, type) do
nil ->
nil
mod ->
mod.render(activity, options)
end
end
end

View file

@ -14,6 +14,8 @@ defmodule Mobilizon.Service.Formatter.HTML do
def filter_tags(html), do: Sanitizer.scrub(html, DefaultScrubbler)
defdelegate basic_html(html), to: FastSanitize
@spec strip_tags(String.t()) :: String.t() | no_return()
def strip_tags(html) do
case FastSanitize.strip_tags(html) do
@ -39,5 +41,17 @@ defmodule Mobilizon.Service.Formatter.HTML do
def strip_tags_and_insert_spaces(html), do: html
@spec html_to_text(String.t()) :: String.t()
def html_to_text(html) do
html
|> String.replace(~r/<li>/, "\\g{1}- ", global: true)
|> String.replace(
~r/<\/?\s?br>|<\/\s?p>|<\/\s?li>|<\/\s?div>|<\/\s?h.>/,
"\\g{1}\n\r",
global: true
)
|> strip_tags()
end
def filter_tags_for_oembed(html), do: Sanitizer.scrub(html, OEmbed)
end

View file

@ -0,0 +1,36 @@
defmodule Mobilizon.Service.Formatter.Text do
@moduledoc """
Helps to format text blocks
Inspired from https://elixirforum.com/t/is-there-are-text-wrapping-library-for-elixir/21733/4
Using the Knuth-Plass Line Wrapping Algorithm https://www.students.cs.ubc.ca/~cs-490/2015W2/lectures/Knuth.pdf
"""
def quote_paragraph(string, max_line_length) do
paragraph(string, max_line_length, "> ")
end
def paragraph(string, max_line_length, prefix \\ "") do
string
|> String.split("\n\n", trim: true)
|> Enum.map_join("\n#{prefix}\n", &subparagraph(&1, max_line_length, prefix))
end
defp subparagraph(string, max_line_length, prefix) do
[word | rest] = String.split(string, ~r/\s+/, trim: true)
rest
|> lines_assemble(max_line_length - String.length(prefix), String.length(word), word, [])
|> Enum.map_join("\n", &"#{prefix}#{&1}")
end
defp lines_assemble([], _, _, line, acc), do: [line | acc] |> Enum.reverse()
defp lines_assemble([word | rest], max, line_length, line, acc) do
if line_length + 1 + String.length(word) > max do
lines_assemble(rest, max, String.length(word), word, [line | acc])
else
lines_assemble(rest, max, line_length + 1 + String.length(word), line <> " " <> word, acc)
end
end
end

View file

@ -70,6 +70,9 @@ defmodule Mobilizon.Service.Notifier.Email do
@always_direct_subjects [
:participation_event_comment,
:event_comment_mention,
:conversation_mention,
:conversation_created,
:conversation_replied,
:discussion_mention,
:event_new_comment
]
@ -175,6 +178,9 @@ defmodule Mobilizon.Service.Notifier.Email do
"member_updated" => false,
"user_email_password_updated" => true,
"event_comment_mention" => true,
"conversation_mention" => true,
"conversation_created" => true,
"conversation_replied" => true,
"discussion_mention" => true,
"event_new_comment" => true
}

View file

@ -33,6 +33,10 @@ defmodule Mobilizon.Service.Notifier.Filter do
defp map_activity_to_activity_setting(%Activity{subject: :event_comment_mention}),
do: "event_comment_mention"
defp map_activity_to_activity_setting(%Activity{subject: subject})
when subject in [:conversation_mention, :conversation_created, :conversation_replied],
do: to_string(subject)
defp map_activity_to_activity_setting(%Activity{subject: :discussion_mention}),
do: "discussion_mention"

View file

@ -64,6 +64,7 @@ defmodule Mobilizon.Service.Notifier.Push do
"member_updated" => false,
"user_email_password_updated" => false,
"event_comment_mention" => true,
"conversation_mention" => true,
"discussion_mention" => false,
"event_new_comment" => false
}

View file

@ -8,6 +8,7 @@ defmodule Mobilizon.Service.Workers.LegacyNotifierBuilder do
alias Mobilizon.Actors.Actor
alias Mobilizon.Events.{Event, Participant}
alias Mobilizon.Service.Notifier
require Logger
use Mobilizon.Service.Workers.Helper, queue: "activity"
@ -15,14 +16,22 @@ defmodule Mobilizon.Service.Workers.LegacyNotifierBuilder do
def perform(%Job{args: args}) do
{"legacy_notify", args} = Map.pop(args, "op")
activity = build_activity(args)
Logger.debug("Handling activity #{activity.subject} to notify in LegacyNotifierBuilder")
if args["subject"] == "participation_event_comment" do
notify_anonymous_participants(get_in(args, ["subject_params", "event_id"]), activity)
end
if args["subject"] == "conversation_created" do
notify_anonymous_participants(
get_in(args, ["subject_params", "conversation_event_id"]),
activity
)
end
args
|> users_to_notify(author_id: args["author_id"], group_id: Map.get(args, "group_id"))
|> Enum.each(&Notifier.notify(&1, activity, single_activity: true))
|> Enum.each(&notify_user(&1, activity))
end
defp build_activity(args) do
@ -48,6 +57,15 @@ defmodule Mobilizon.Service.Workers.LegacyNotifierBuilder do
users_from_actor_ids(mentionned_actor_ids, Keyword.fetch!(options, :author_id))
end
@spec users_to_notify(map(), Keyword.t()) :: list(Users.t())
defp users_to_notify(
%{"subject" => subject, "participant" => %{"actor_id" => actor_id}},
options
)
when subject in ["conversation_created", "conversation_replied"] do
users_from_actor_ids([actor_id], Keyword.fetch!(options, :author_id))
end
defp users_to_notify(
%{"subject" => "discussion_mention", "mentions" => mentionned_actor_ids},
options
@ -114,4 +132,9 @@ defmodule Mobilizon.Service.Workers.LegacyNotifierBuilder do
)
end)
end
defp notify_user(user, activity) do
Logger.debug("Notifying #{user.email} for activity #{activity.subject}")
Notifier.notify(user, activity, single_activity: true)
end
end

View file

@ -44,7 +44,7 @@ defmodule Mobilizon.Web.Auth.Context do
context = if is_nil(user_agent), do: context, else: Map.put(context, :user_agent, user_agent)
put_private(conn, :absinthe, %{context: context})
Absinthe.Plug.put_options(conn, context: context)
end
defp set_user_context({conn, context}, %User{id: user_id, email: user_email} = user) do

View file

@ -3,9 +3,10 @@ defmodule Mobilizon.Web.Cache.ActivityPub do
ActivityPub related cache.
"""
alias Mobilizon.{Actors, Discussions, Events, Posts, Resources, Todos, Tombstone}
alias Mobilizon.{Actors, Conversations, Discussions, Events, Posts, Resources, Todos, Tombstone}
alias Mobilizon.Actors.Actor, as: ActorModel
alias Mobilizon.Actors.Member
alias Mobilizon.Conversations.Conversation
alias Mobilizon.Discussions.{Comment, Discussion}
alias Mobilizon.Events.Event
alias Mobilizon.Federation.ActivityPub.{Actor, Relay}
@ -184,6 +185,23 @@ defmodule Mobilizon.Web.Cache.ActivityPub do
end)
end
@doc """
Gets a conversation participant by it's ID, with all associations loaded.
"""
@spec get_conversation_by_id_with_preload(String.t()) ::
{:commit, Todo.t()} | {:ignore, nil}
def get_conversation_by_id_with_preload(id) do
Cachex.fetch(@cache, "conversation_participant_" <> id, fn "conversation_participant_" <> id ->
case Conversations.get_conversation_participant(id) do
%Conversation{} = conversation ->
{:commit, conversation}
nil ->
{:ignore, nil}
end
end)
end
@doc """
Gets a member by its UUID, with all associations loaded.
"""

View file

@ -4,6 +4,7 @@ defmodule Mobilizon.Web.Cache do
"""
alias Mobilizon.Actors.{Actor, Member}
alias Mobilizon.Conversations.Conversation
alias Mobilizon.Discussions.{Comment, Discussion}
alias Mobilizon.Events.Event
alias Mobilizon.Posts.Post
@ -27,6 +28,10 @@ defmodule Mobilizon.Web.Cache do
defdelegate get_todo_list_by_uuid_with_preload(uuid), to: ActivityPub
@spec get_todo_by_uuid_with_preload(binary) :: {:commit, Todo.t()} | {:ignore, nil}
defdelegate get_todo_by_uuid_with_preload(uuid), to: ActivityPub
@spec get_conversation_by_id_with_preload(binary) ::
{:commit, Conversation.t()} | {:ignore, nil}
defdelegate get_conversation_by_id_with_preload(uuid), to: ActivityPub
@spec get_member_by_uuid_with_preload(binary) :: {:commit, Member.t()} | {:ignore, nil}
defdelegate get_member_by_uuid_with_preload(uuid), to: ActivityPub
@spec get_post_by_slug_with_preload(binary) :: {:commit, Post.t()} | {:ignore, nil}

View file

@ -13,9 +13,7 @@ defmodule Mobilizon.Web.GraphQLSocket do
with {:ok, authed_socket} <-
Guardian.Phoenix.Socket.authenticate(socket, Mobilizon.Web.Auth.Guardian, token),
resource <- Guardian.Phoenix.Socket.current_resource(authed_socket) do
set_context(authed_socket, resource)
{:ok, authed_socket}
{:ok, set_context(authed_socket, resource)}
else
{:error, _} ->
:error
@ -24,8 +22,17 @@ defmodule Mobilizon.Web.GraphQLSocket do
def connect(_args, _socket), do: :error
@spec id(any) :: nil
def id(_socket), do: nil
@spec id(Phoenix.Socket.t()) :: String.t() | nil
def id(%Phoenix.Socket{assigns: assigns}) do
context = Keyword.get(assigns.absinthe.opts, :context)
current_user = Map.get(context, :current_user)
if current_user do
"user_socket:#{current_user.id}"
else
nil
end
end
@spec set_context(Phoenix.Socket.t(), User.t() | ApplicationToken.t()) :: Phoenix.Socket.t()
defp set_context(socket, %User{} = user) do

View file

@ -85,6 +85,12 @@ defmodule Mobilizon.Web.PageController do
render_or_error(conn, &checks?/3, status, :todo, todo)
end
@spec conversation(Plug.Conn.t(), map()) :: Plug.Conn.t() | {:error, :not_found}
def conversation(conn, %{"id" => slug}) do
{status, conversation} = Cache.get_conversation_by_id_with_preload(slug)
render_or_error(conn, &checks?/3, status, :conversation, conversation)
end
@typep collections :: :resources | :posts | :discussions | :events | :todos
@spec resources(Plug.Conn.t(), map()) :: Plug.Conn.t()

View file

@ -10,6 +10,7 @@ defmodule Mobilizon.Web.Email.Activity do
alias Mobilizon.Actors.Actor
alias Mobilizon.Config
alias Mobilizon.Web.Email
require Logger
@spec direct_activity(String.t(), list(), Keyword.t()) :: Swoosh.Email.t()
def direct_activity(
@ -39,6 +40,36 @@ defmodule Mobilizon.Web.Email.Activity do
end
@spec anonymous_activity(String.t(), Activity.t(), Keyword.t()) :: Swoosh.Email.t()
def anonymous_activity(
email,
%Activity{subject_params: subject_params, type: :conversation} = activity,
options
) do
locale = Keyword.get(options, :locale, "en")
subject =
dgettext(
"activity",
"Informations about your event %{event}",
event: subject_params["conversation_event_title"]
)
conversation = Mobilizon.Conversations.get_conversation(activity.object_id)
Logger.debug("Going to send anonymous activity of type #{activity.type} to #{email}")
[to: email, subject: subject]
|> Email.base_email()
|> render_body(:email_anonymous_activity, %{
subject: subject,
activity: activity,
locale: locale,
extra: %{
"conversation" => conversation
}
})
end
def anonymous_activity(email, %Activity{subject_params: subject_params} = activity, options) do
locale = Keyword.get(options, :locale, "en")
@ -49,6 +80,8 @@ defmodule Mobilizon.Web.Email.Activity do
event: subject_params["event_title"]
)
Logger.debug("Going to send anonymous activity of type #{activity.type} to #{email}")
[to: email, subject: subject]
|> Email.base_email()
|> render_body(:email_anonymous_activity, %{

View file

@ -10,6 +10,21 @@ defmodule Mobilizon.Web.Endpoint do
use Phoenix.Endpoint, otp_app: :mobilizon
use Absinthe.Phoenix.Endpoint
# Serve at "/" the static files from "priv/static" directory.
#
# You should set gzip to true if you are running phoenix.digest
# when deploying your static files in production.
plug(
Plug.Static,
at: "/",
from: {:mobilizon, "priv/static"},
gzip: false,
only: Mobilizon.Web.static_paths()
# only_matching: ["precache-manifest"]
)
plug(Mobilizon.Web.Plugs.UploadedMedia)
plug(Mobilizon.Web.Plugs.DetectLocalePlug)
if Application.compile_env(:mobilizon, :env) !== :dev do
@ -37,21 +52,6 @@ defmodule Mobilizon.Web.Endpoint do
do: RemoteIp
)
plug(Mobilizon.Web.Plugs.UploadedMedia)
# Serve at "/" the static files from "priv/static" directory.
#
# You should set gzip to true if you are running phoenix.digest
# when deploying your static files in production.
plug(
Plug.Static,
at: "/",
from: {:mobilizon, "priv/static"},
gzip: false,
only: Mobilizon.Web.static_paths(),
only_matching: ["precache-manifest"]
)
# Code reloading can be explicitly enabled under the
# :code_reloader configuration of your endpoint.
if code_reloading? do

View file

@ -72,7 +72,6 @@ defmodule Mobilizon.Web.Router do
pipeline :browser do
plug(:put_request_context)
plug(Plug.Static, at: "/", from: "priv/static")
plug(Mobilizon.Web.Plugs.SetLocalePlug)
@ -132,6 +131,7 @@ defmodule Mobilizon.Web.Router do
get("/@:name/discussions", PageController, :discussions)
get("/@:name/events", PageController, :events)
get("/p/:slug", PageController, :post)
get("/conversations/:id", PageController, :conversation)
get("/@:name/c/:slug", PageController, :discussion)
end
@ -176,6 +176,7 @@ defmodule Mobilizon.Web.Router do
forward("/", Absinthe.Plug.GraphiQL,
schema: Mobilizon.GraphQL.Schema,
socket: Mobilizon.Web.GraphQLSocket,
interface: :playground
)
end

View file

@ -0,0 +1,20 @@
<%= case @activity.subject do %>
<% :conversation_created -> %>
<%= dgettext("activity", "%{profile} mentionned you in a %{conversation}.", %{
profile: "<b>#{escaped_display_name_and_username(@activity.author)}</b>",
conversation:
"<a href=\"#{Routes.page_url(Mobilizon.Web.Endpoint,
:conversation,
@activity.subject_params["conversation_participant_id"]) |> URI.decode()}\">conversation</a>"
})
|> raw %>
<% :conversation_replied -> %>
<%= dgettext("activity", "%{profile} replied you in a %{conversation}.", %{
profile: "<b>#{escaped_display_name_and_username(@activity.author)}</b>",
conversation:
"<a href=\"#{Routes.page_url(Mobilizon.Web.Endpoint,
:conversation,
@activity.subject_params["conversation_participant_id"]) |> URI.decode()}\">conversation</a>"
})
|> raw %>
<% end %>

View file

@ -0,0 +1,11 @@
<%= case @activity.subject do %><% :conversation_created -> %><%= dgettext("activity", "%{profile} mentionned you in a conversation.",
%{
profile: Mobilizon.Actors.Actor.display_name_and_username(@activity.author),
}
) %>
<%= Routes.page_url(Mobilizon.Web.Endpoint, :conversation, @activity.subject_params["conversation_participant_id"]) |> URI.decode() %><% :conversation_replied -> %><%= dgettext("activity", "%{profile} replied you in a conversation.",
%{
profile: Mobilizon.Actors.Actor.display_name_and_username(@activity.author),
}
) %>
<%= Routes.page_url(Mobilizon.Web.Endpoint, :conversation, @activity.subject_params["conversation_participant_id"]) |> URI.decode() %><% end %>

View file

@ -35,61 +35,164 @@
<tr>
<td align="center" valign="top" width="600">
<![endif]-->
<table border="0" cellpadding="0" cellspacing="0" width="100%" style="max-width: 600px;">
<!-- COPY -->
<tr>
<td bgcolor="#ffffff" align="left">
<table width="100%" border="0" cellspacing="0" cellpadding="0">
<tr>
<td
align="center"
style="border-radius: 3px; text-align: left; padding: 10px 5% 0px 30px; color: #474467; font-family: 'Roboto', Helvetica, Arial, sans-serif; font-size: 16px; font-weight: 400;line-height: 25px;"
>
<%= dgettext(
"activity",
"%{profile} has posted an announcement under event %{event}.",
%{
profile: "<b>#{escape_html(display_name_and_username(@activity.author))}</b>",
event:
"<a href=\"#{Routes.page_url(Mobilizon.Web.Endpoint,
:event,
@activity.subject_params["event_uuid"]) |> URI.decode()}\">
<%= case @activity.type do %>
<% :comment -> %>
<table border="0" cellpadding="0" cellspacing="0" width="100%" style="max-width: 600px;">
<!-- COPY -->
<tr>
<td bgcolor="#ffffff" align="left">
<table width="100%" border="0" cellspacing="0" cellpadding="0">
<tr>
<td
align="center"
style="border-radius: 3px; text-align: left; padding: 10px 5% 0px 30px; color: #474467; font-family: 'Roboto', Helvetica, Arial, sans-serif; font-size: 16px; font-weight: 400;line-height: 25px;"
>
<%= dgettext(
"activity",
"%{profile} has posted a public announcement under event %{event}.",
%{
profile:
"<b>#{escape_html(display_name_and_username(@activity.author))}</b>",
event:
"<a href=\"#{Routes.page_url(Mobilizon.Web.Endpoint,
:event,
@activity.subject_params["event_uuid"]) |> URI.decode()}\">
#{escape_html(@activity.subject_params["event_title"])}
</a>"
}
)
|> raw %>
</td>
</tr>
</table>
</td>
</tr>
<tr>
<td bgcolor="#ffffff" align="left">
<table width="100%" border="0" cellspacing="0" cellpadding="0">
<tr>
<td bgcolor="#ffffff" align="center" style="padding: 20px 30px 60px 30px;">
<table border="0" cellspacing="0" cellpadding="0">
<tr>
<td align="center" style="border-radius: 3px;" bgcolor="#3C376E">
<a
href={
}
)
|> raw %>
</td>
</tr>
</table>
</td>
</tr>
<tr>
<td bgcolor="#ffffff" align="left">
<table width="100%" border="0" cellspacing="0" cellpadding="0">
<tr>
<td bgcolor="#ffffff" align="center" style="padding: 20px 30px 60px 30px;">
<table border="0" cellspacing="0" cellpadding="0">
<tr>
<td align="center" style="border-radius: 3px;" bgcolor="#3C376E">
<a
href={
"#{Routes.page_url(Mobilizon.Web.Endpoint, :event, @activity.subject_params["event_uuid"])}"
}
target="_blank"
style="font-size: 20px; font-family: Helvetica, Arial, sans-serif; color: #ffffff; text-decoration: none; padding: 15px 25px; border-radius: 2px; border: 1px solid #3C376E; display: inline-block;"
>
<%= gettext("Visit event page") %>
</a>
</td>
</tr>
</table>
</td>
</tr>
</table>
</td>
</tr>
</table>
target="_blank"
style="font-size: 20px; font-family: Helvetica, Arial, sans-serif; color: #ffffff; text-decoration: none; padding: 15px 25px; border-radius: 2px; border: 1px solid #3C376E; display: inline-block;"
>
<%= gettext("Visit event page") %>
</a>
</td>
</tr>
</table>
</td>
</tr>
</table>
</td>
</tr>
</table>
<% :conversation -> %>
<table border="0" cellpadding="0" cellspacing="0" width="100%" style="max-width: 600px;">
<!-- COPY -->
<tr>
<td bgcolor="#ffffff" align="left">
<table width="100%" border="0" cellspacing="0" cellpadding="0">
<tr>
<td
align="center"
style="border-radius: 3px; text-align: left; padding: 10px 5% 0px 30px; color: #474467; font-family: 'Roboto', Helvetica, Arial, sans-serif; font-size: 16px; font-weight: 400;line-height: 25px;"
>
<%= dgettext(
"activity",
"%{profile} has posted a private announcement about event %{event}.",
%{
profile:
"<b>#{escape_html(display_name_and_username(@activity.author))}</b>",
event:
"<a href=\"#{Routes.page_url(Mobilizon.Web.Endpoint,
:event,
@activity.subject_params["conversation_event_uuid"]) |> URI.decode()}\">
#{escape_html(@activity.subject_params["conversation_event_title"])}
</a>"
}
)
|> raw %>
<%= dgettext(
"activity",
"It might give details on how to join the event, so make sure to read it appropriately."
) %>
</td>
</tr>
</table>
</td>
</tr>
<tr>
<td bgcolor="#ffffff" align="left">
<table width="100%" border="0" cellspacing="0" cellpadding="0">
<tr>
<td bgcolor="#ffffff" align="center" style="padding: 20px 30px 60px 30px;">
<table border="0" cellspacing="0" cellpadding="0">
<tr>
<td align="center">
<blockquote style="border-left-width: 0.25rem;border-left-color: #e2e8f0;border-left-style: solid;padding-left: 1em;margin: 0;text-align: start;color: #474467;font-family: 'Roboto', Helvetica, Arial, sans-serif; font-size: 16px; font-weight: 400;line-height: 25px;">
<%= @extra["conversation"].last_comment.text
|> sanitize_to_basic_html()
|> raw() %>
</blockquote>
</td>
</tr>
</table>
</td>
</tr>
</table>
</td>
</tr>
<tr>
<td bgcolor="#ffffff" align="left">
<table width="100%" border="0" cellspacing="0" cellpadding="0">
<tr>
<td
align="center"
style="border-radius: 3px; text-align: left; padding: 10px 5% 0px 30px; color: #474467; font-family: 'Roboto', Helvetica, Arial, sans-serif; font-size: 16px; font-weight: 400;line-height: 25px;"
>
<%= dgettext(
"activity",
"This information is sent privately to you as a person who registered for this event. Share the informations above with other people with caution."
) %>
</td>
</tr>
</table>
</td>
</tr>
<tr>
<td bgcolor="#ffffff" align="left">
<table width="100%" border="0" cellspacing="0" cellpadding="0">
<tr>
<td bgcolor="#ffffff" align="center" style="padding: 20px 30px 60px 30px;">
<table border="0" cellspacing="0" cellpadding="0">
<tr>
<td align="center" style="border-radius: 3px;" bgcolor="#3C376E">
<a
href={
"#{Routes.page_url(Mobilizon.Web.Endpoint, :event, @activity.subject_params["conversation_event_uuid"])}"
}
target="_blank"
style="font-size: 20px; font-family: Helvetica, Arial, sans-serif; color: #ffffff; text-decoration: none; padding: 15px 25px; border-radius: 2px; border: 1px solid #3C376E; display: inline-block;"
>
<%= gettext("Visit event page") %>
</a>
</td>
</tr>
</table>
</td>
</tr>
</table>
</td>
</tr>
</table>
<% end %>
<!--[if (gte mso 9)|(IE)]>
</td>
</tr>

View file

@ -1,11 +1,30 @@
<%= @subject %>
==
<%= dgettext("activity", "%{profile} has posted an announcement under event %{event}.",
<%= case @activity.type do %>
<% :comment -> %>
<%= dgettext("activity", "%{profile} has posted a public announcement under event %{event}.",
%{
profile: Mobilizon.Actors.Actor.display_name_and_username(@activity.author),
event: @activity.subject_params["event_title"]
}
) %>
<%= Routes.page_url(Mobilizon.Web.Endpoint, :event, @activity.subject_params["event_uuid"]) |> URI.decode() %>
<% :conversation -> %>
<%= dgettext("activity", "%{profile} has posted a private announcement about event %{event}.",
%{
profile: Mobilizon.Actors.Actor.display_name_and_username(@activity.author),
event: @activity.subject_params["conversation_event_title"]
}
) %>
<%= dgettext("activity", "It might give details on how to join the event, so make sure to read it appropriately.") %>
--
<%= @extra["conversation"].last_comment.text |> html_to_text() |> mail_quote() %>
--
<%= dgettext("activity", "This information is sent privately to you as a person who registered for this event. Share the informations above with other people with caution.") %>
<%= Routes.page_url(Mobilizon.Web.Endpoint, :event, @activity.subject_params["conversation_event_uuid"]) |> URI.decode() %>
<% end %>

View file

@ -167,6 +167,10 @@
<%= render("activity/_discussion_activity_item.html",
activity: activity
) %>
<% :conversation -> %>
<%= render("activity/_conversation_activity_item.html",
activity: activity
) %>
<% :event -> %>
<%= render("activity/_event_activity_item.html",
activity: activity

View file

@ -15,7 +15,7 @@
<% end %>
<%= for activity <- Enum.take(group_activities, 5) do %>
* <%= case activity.type do %><% :discussion -> %><%= render("activity/_discussion_activity_item.text", activity: activity) %><% :event -> %><%= render("activity/_event_activity_item.text", activity: activity) %><% :group -> %><%= render("activity/_group_activity_item.text", activity: activity) %>
* <%= case activity.type do %><% :discussion -> %><%= render("activity/_discussion_activity_item.text", activity: activity) %><% :conversation -> %><%= render("activity/_conversation_activity_item.text", activity: activity) %><% :event -> %><%= render("activity/_event_activity_item.text", activity: activity) %><% :group -> %><%= render("activity/_group_activity_item.text", activity: activity) %>
<% :member -> %><%= render("activity/_member_activity_item.text", activity: activity) %><% :post -> %><%= render("activity/_post_activity_item.text", activity: activity) %><% :resource -> %><%= render("activity/_resource_activity_item.text", activity: activity) %><% :comment -> %><%= render("activity/_comment_activity_item.text", activity: activity) %><% end %>
<%= unless @single_activity do %><%= datetime_to_string(activity.inserted_at, @locale, :short) %><% end %>
<% end %>

View file

@ -7,6 +7,7 @@ defmodule Mobilizon.Web.EmailView do
alias Mobilizon.Actors.Actor
alias Mobilizon.Service.Address
alias Mobilizon.Service.DateTime, as: DateTimeRenderer
alias Mobilizon.Service.Formatter.{HTML, Text}
alias Mobilizon.Web.Router.Helpers, as: Routes
import Mobilizon.Web.Gettext
import Mobilizon.Service.Metadata.Utils, only: [process_description: 1]
@ -35,6 +36,21 @@ defmodule Mobilizon.Web.EmailView do
|> safe_to_string()
end
@spec sanitize_to_basic_html(String.t()) :: String.t()
def sanitize_to_basic_html(html) do
case HTML.basic_html(html) do
{:ok, html} -> html
_ -> ""
end
end
defdelegate html_to_text(html), to: HTML
def mail_quote(text) do
# https://www.emailonacid.com/blog/article/email-development/line-length-in-html-email/
Text.quote_paragraph(text, 78)
end
def escaped_display_name_and_username(actor) do
actor
|> display_name_and_username()

View file

@ -258,17 +258,19 @@ defmodule Mobilizon.Mixfile do
"ecto.drop",
"ecto.setup"
],
test: [
prepare_test: [
"ecto.create",
"ecto.migrate",
"tz_world.update",
"tz_world.update"
],
test: [
&run_test/1
],
"phx.deps_migrate_serve": [
"deps.get",
"ecto.create --quiet",
"ecto.migrate",
"cmd cd js && yarn install && cd ../",
"cmd npm ci",
"phx.server"
]
]

11997
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -5,9 +5,9 @@
"scripts": {
"dev": "vite",
"preview": "vite preview",
"build": "yarn run build:assets && yarn run build:pictures",
"build": "npm run build:assets && npm run build:pictures",
"lint": "eslint --ext .ts,.vue --ignore-path .gitignore --fix src",
"format": "prettier . --write",
"format": "prettier . --write",
"build:assets": "vite build",
"build:pictures": "bash ./scripts/build/pictures.sh",
"story:dev": "histoire dev",
@ -15,7 +15,8 @@
"story:preview": "histoire preview",
"test": "vitest",
"coverage": "vitest run --coverage",
"prepare": "cd ../ && husky install"
"prepare": "husky install",
"pre-commit": "lint-staged"
},
"lint-staged": {
"**/*.{js,ts,vue}": [
@ -24,10 +25,10 @@
]
},
"dependencies": {
"@absinthe/socket": "^0.2.1",
"@absinthe/socket-apollo-link": "^0.2.1",
"@apollo/client": "^3.3.16",
"@oruga-ui/oruga-next": "^0.6.0",
"@framasoft/socket": "^1.0.0",
"@framasoft/socket-apollo-link": "^1.0.0",
"@oruga-ui/oruga-next": "^0.7.0",
"@sentry/tracing": "^7.1",
"@sentry/vue": "^7.1",
"@tiptap/core": "^2.0.0-beta.41",
@ -68,11 +69,11 @@
"date-fns": "^2.16.0",
"date-fns-tz": "^2.0.0",
"floating-vue": "^2.0.0-beta.24",
"graphql": "^15.8.0",
"graphql": "^16.8.1",
"graphql-tag": "^2.10.3",
"hammerjs": "^2.0.8",
"intersection-observer": "^0.12.0",
"jwt-decode": "^3.1.2",
"jwt-decode": "^4.0.0",
"leaflet": "^1.4.0",
"leaflet.locatecontrol": "^0.79",
"leaflet.markercluster": "^1.5.3",
@ -114,7 +115,7 @@
"@vitest/coverage-v8": "^0.34.1",
"@vitest/ui": "^0.34.1",
"@vue/eslint-config-prettier": "^8.0.0",
"@vue/eslint-config-typescript": "^11.0.0",
"@vue/eslint-config-typescript": "^12.0.0",
"@vue/test-utils": "^2.0.2",
"eslint": "^8.21.0",
"eslint-config-prettier": "^9.0.0",
@ -125,16 +126,17 @@
"histoire": "^0.17.0",
"husky": "^8.0.3",
"jsdom": "^22.0.0",
"lint-staged": "^14.0.1",
"lint-staged": "^15.1.0",
"mock-apollo-client": "^1.1.0",
"prettier": "^3.0.0",
"prettier-eslint": "^15.0.1",
"prettier-eslint": "^16.1.2",
"rollup-plugin-visualizer": "^5.7.1",
"sass": "^1.34.1",
"typescript": "~5.1.3",
"typescript": "~5.2.2",
"vite": "^4.0.4",
"vite-plugin-pwa": "^0.16.4",
"vitest": "^0.34.1",
"vue-i18n-extract": "^2.0.4"
"vue-i18n-extract": "^2.0.4",
"vue-router-mock": "^1.0.0"
}
}

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