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. # In the latter case `**/*.{ex,exs}` will be used.
# #
included: ["lib/", "src/", "test/"], 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 # If you create your own checks, you must specify the source files for

View file

@ -8,7 +8,8 @@
// Set *default* container specific settings.json values on container create. // Set *default* container specific settings.json values on container create.
"settings": { "settings": {
"sqltools.connections": [{ "sqltools.connections": [
{
"name": "Container database", "name": "Container database",
"driver": "PostgreSQL", "driver": "PostgreSQL",
"previewLimit": 50, "previewLimit": 50,
@ -17,7 +18,8 @@
"database": "postgres", "database": "postgres",
"username": "postgres", "username": "postgres",
"password": "postgres" "password": "postgres"
}] }
]
}, },
// Add the IDs of extensions you want installed when the container is created. // Add the IDs of extensions you want installed when the container is created.

View file

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

9
.gitignore vendored
View file

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

View file

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

View file

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

4
.prettierignore Normal file
View file

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

View file

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

View file

@ -1,6 +1,6 @@
FROM elixir:alpine 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 RUN mix local.hex --force && mix local.rebar --force

View file

@ -4,7 +4,7 @@ init:
setup: stop setup: stop
@bash docker/message.sh "Compiling everything" @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: migrate:
docker-compose run --rm api mix ecto.migrate docker-compose run --rm api mix ecto.migrate
logs: logs:
@ -19,6 +19,7 @@ stop:
@bash docker/message.sh "Mobilizon is stopped" @bash docker/message.sh "Mobilizon is stopped"
test: stop test: stop
@bash docker/message.sh "Running tests" @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) docker-compose -f docker-compose.yml -f docker-compose.test.yml run api mix test $(only)
@bash docker/message.sh "Done running tests" @bash docker/message.sh "Done running tests"
format: format:

View file

@ -7,6 +7,6 @@ module.exports = {
localSchemaFile: "./schema.graphql", localSchemaFile: "./schema.graphql",
}, },
// Files processed by the extension // 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, check_origin: false,
watchers: [ watchers: [
node: [ node: [
"node_modules/.bin/vite", "node_modules/.bin/vite"
cd: Path.expand("../js", __DIR__)
] ]
] ]

View file

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

View file

@ -11,7 +11,7 @@ services:
MIX_ENV: "test" MIX_ENV: "test"
MOBILIZON_DATABASE_DBNAME: mobilizon_test MOBILIZON_DATABASE_DBNAME: mobilizon_test
MOBILIZON_INSTANCE_HOST: mobilizon.test MOBILIZON_INSTANCE_HOST: mobilizon.test
command: "mix test" command: "mix prepare_test && mix test"
volumes: volumes:
pgdata: 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 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/* 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 curl -sL https://deb.nodesource.com/setup_16.x | bash && apt-get install nodejs -yq
# RUN npm install -g yarn
# Install build tools # Install build tools
RUN source /root/.bashrc && \ RUN source /root/.bashrc && \
@ -32,8 +31,8 @@ COPY ./ /mobilizon
WORKDIR /mobilizon WORKDIR /mobilizon
# # Build front-end # # Build front-end
# RUN yarn --cwd "js" install --frozen-lockfile # RUN npm install
# RUN yarn --cwd "js" run build # RUN npm run build
# Elixir release # Elixir release
RUN source /root/.bashrc && \ 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 RUN apk add --no-cache python3 build-base libwebp-tools bash imagemagick ncurses
WORKDIR /build WORKDIR /build
COPY js . COPY . .
# Network timeout because it's slow when cross-compiling # Network timeout because it's slow when cross-compiling
RUN yarn install --network-timeout 100000 \ RUN npm install && npm run build
&& yarn run build
# Then, build the application binary # Then, build the application binary
FROM elixir:1.15-alpine AS builder 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 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 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 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 apt-get clean && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
RUN mix local.hex --force && mix local.rebar --force RUN mix local.hex --force && mix local.rebar --force
RUN pip3 install -Iv weasyprint pyexcel_ods3 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 * `config` backend compile-time and runtime configuration
* `docker` 🐳 * `docker` 🐳
* `js/src` Front-end * `src` Front-end
* `lib/federation` Handling all the federation stuff (sending and receving activities, converting activities, signatures, helpers…) * `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/schema` The schema declarations for the GraphQL API
* `lib/graphql/resolvers` The logic behind 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()], plugins: [HstVue()],
setupFile: path.resolve(__dirname, "./src/histoire.setup.ts"), setupFile: path.resolve(__dirname, "./src/histoire.setup.ts"),
viteNodeInlineDeps: [/date-fns/], viteNodeInlineDeps: [/date-fns/],
// viteIgnorePlugins: ['vite-plugin-pwa', 'vite-plugin-pwa:build', 'vite-plugin-pwa:info'],
tree: { tree: {
groups: [ 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 :: @type create_entities ::
:event | :comment | :discussion | :actor | :todo_list | :todo | :resource | :post :event
| :comment
| :discussion
| :conversation
| :actor
| :todo_list
| :todo
| :resource
| :post
@doc """ @doc """
Create an activity of type `Create` Create an activity of type `Create`
@ -50,18 +58,27 @@ defmodule Mobilizon.Federation.ActivityPub.Actions.Create do
end end
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()) :: @spec do_create(create_entities(), map(), map()) ::
{:ok, Entity.t(), Activity.t()} | {:error, Ecto.Changeset.t() | atom()} {:ok, Entity.t(), Activity.t()} | {:error, Ecto.Changeset.t() | atom()}
defp do_create(type, args, additional) do defp do_create(type, args, additional) do
case type do mod = Map.get(@map_types, type)
:event -> Types.Events.create(args, additional)
:comment -> Types.Comments.create(args, additional) if is_nil(mod) do
:discussion -> Types.Discussions.create(args, additional) {:error, :type_not_supported}
:actor -> Types.Actors.create(args, additional) else
:todo_list -> Types.TodoLists.create(args, additional) mod.create(args, additional)
:todo -> Types.Todos.create(args, additional)
:resource -> Types.Resources.create(args, additional)
:post -> Types.Posts.create(args, additional)
end end
end end

View file

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

View file

@ -177,7 +177,7 @@ defmodule Mobilizon.Federation.ActivityPub.Fetcher do
{:error, :content_not_json} {:error, :content_not_json}
{:ok, %Tesla.Env{} = res} -> {: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, :http_error}
{:error, err} -> {:error, err} ->

View file

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

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"] @actor_types ["Group", "Person", "Application"]
@all_actor_types @actor_types ++ ["Organization", "Service"] @all_actor_types @actor_types ++ ["Organization", "Service"]
@ap_public_audience "https://www.w3.org/ns/activitystreams#Public"
# Wraps an object into an activity # Wraps an object into an activity
@spec create_activity(map(), boolean()) :: {:ok, Activity.t()} @spec create_activity(map(), boolean()) :: {:ok, Activity.t()}
@ -491,8 +492,7 @@ defmodule Mobilizon.Federation.ActivityPub.Utils do
if public do if public do
Logger.debug("Making announce data for a public object") Logger.debug("Making announce data for a public object")
{[actor.followers_url, object_actor_url], {[actor.followers_url, object_actor_url], [@ap_public_audience]}
["https://www.w3.org/ns/activitystreams#Public"]}
else else
Logger.debug("Making announce data for a private object") Logger.debug("Making announce data for a private object")
@ -539,7 +539,7 @@ defmodule Mobilizon.Federation.ActivityPub.Utils do
"actor" => url, "actor" => url,
"object" => activity, "object" => activity,
"to" => [actor.followers_url, actor.url], "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 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 case maybe_fetch_actor_and_attributed_to_id(object) do
{:ok, %Actor{id: actor_id, domain: actor_domain}, attributed_to} -> {:ok, %Actor{id: actor_id, domain: actor_domain}, attributed_to} ->
Logger.debug("Inserting full comment")
Logger.debug(inspect(object))
data = %{ data = %{
text: object["content"], text: object["content"],
url: object["id"], url: object["id"],
@ -70,14 +67,7 @@ defmodule Mobilizon.Federation.ActivityStream.Converter.Comment do
is_announcement: Map.get(object, "isAnnouncement", false) is_announcement: Map.get(object, "isAnnouncement", false)
} }
Logger.debug("Converted object before fetching parents") maybe_fetch_parent_object(object, data)
Logger.debug(inspect(data))
data = maybe_fetch_parent_object(object, data)
Logger.debug("Converted object after fetching parents")
Logger.debug(inspect(data))
data
{:error, err} -> {:error, err} ->
{:error, err} {:error, err}
@ -147,17 +137,20 @@ defmodule Mobilizon.Federation.ActivityStream.Converter.Comment do
end end
@spec determine_to(CommentModel.t()) :: [String.t()] @spec determine_to(CommentModel.t()) :: [String.t()]
defp determine_to(%CommentModel{} = comment) do defp determine_to(%CommentModel{visibility: :private, mentions: mentions} = _comment) do
cond do Enum.map(mentions, fn mention -> mention.actor.url end)
not is_nil(comment.attributed_to) ->
[comment.attributed_to.url]
comment.visibility == :public ->
["https://www.w3.org/ns/activitystreams#Public"]
true ->
[comment.actor.followers_url]
end end
defp 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 end
defp maybe_fetch_parent_object(object, data) do defp maybe_fetch_parent_object(object, data) do
@ -170,9 +163,12 @@ defmodule Mobilizon.Federation.ActivityStream.Converter.Comment do
case ActivityPub.fetch_object_from_url(object["inReplyTo"]) do case ActivityPub.fetch_object_from_url(object["inReplyTo"]) do
# Reply to an event (Event) # Reply to an event (Event)
{:ok, %Event{id: id}} -> {:ok, %Event{id: id} = event} ->
Logger.debug("Parent object is an 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) # Reply to a comment (Comment)
{:ok, %CommentModel{id: id} = 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(:in_reply_to_comment_id, id)
|> Map.put(:origin_comment_id, comment |> CommentModel.get_thread_id()) |> Map.put(:origin_comment_id, comment |> CommentModel.get_thread_id())
|> Map.put(:event_id, comment.event_id) |> Map.put(:event_id, comment.event_id)
|> Map.put(:conversation_id, comment.conversation_id)
# Reply to a discucssion (Discussion) # Reply to a discucssion (Discussion)
{:ok, {: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} @spec domain_from_federated_actor(String.t()) :: {:ok, String.t()} | {:error, :host_not_found}
defp domain_from_federated_actor(actor) do defp domain_from_federated_actor(actor) do
case String.split(actor, "@") do case String.split(actor, "@") do
[_name, ""] ->
{:error, :host_not_found}
[_name, domain] -> [_name, domain] ->
{:ok, domain} {:ok, domain}
_e -> _e ->
host = URI.parse(actor).host 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
end end

View file

@ -4,7 +4,8 @@ defmodule Mobilizon.GraphQL.API.Comments do
""" """
alias Mobilizon.Actors.Actor 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.Federation.ActivityPub.{Actions, Activity}
alias Mobilizon.GraphQL.API.Utils alias Mobilizon.GraphQL.API.Utils
@ -53,6 +54,22 @@ defmodule Mobilizon.GraphQL.API.Comments do
) )
end 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() @spec extract_pictures_from_comment_body(map()) :: map()
defp extract_pictures_from_comment_body(%{text: text, actor_id: actor_id} = args) do defp extract_pictures_from_comment_body(%{text: text, actor_id: actor_id} = args) do
pictures = Utils.extract_pictures_from_body(text, actor_id) 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.Actors.Actor
alias Mobilizon.Discussions.Comment
alias Mobilizon.Events.Event alias Mobilizon.Events.Event
alias Mobilizon.Federation.ActivityPub.{Actions, Activity, Utils} alias Mobilizon.Federation.ActivityPub.{Actions, Activity, Utils}
alias Mobilizon.GraphQL.API.Utils, as: APIUtils alias Mobilizon.GraphQL.API.Utils, as: APIUtils
@ -36,6 +36,12 @@ defmodule Mobilizon.GraphQL.API.Events do
Actions.Delete.delete(event, actor, true) Actions.Delete.delete(event, actor, true)
end 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 @spec prepare_args(map) :: map
defp prepare_args(args) do defp prepare_args(args) do
organizer_actor = Map.get(args, :organizer_actor) 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()) @spec process_from_username(String.t()) :: Page.t(Actor.t())
defp process_from_username(search) do defp process_from_username(search) do
case ActivityPubActor.find_or_make_actor_from_nickname(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]} %Page{total: 1, elements: [actor]}
# Don't return anything else than groups
{:ok, %Actor{}} ->
%Page{total: 0, elements: []}
{:error, _err} -> {:error, _err} ->
Logger.debug(fn -> "Unable to find or make actor '#{search}'" end) Logger.debug(fn -> "Unable to find or make actor '#{search}'" end)

View file

@ -16,11 +16,13 @@ defmodule Mobilizon.GraphQL.Authorization do
@impl true @impl true
def has_user_access?(%User{}, _scope, _rule), do: true def has_user_access?(%User{}, _scope, _rule), do: true
@impl true
def has_user_access?(%ApplicationToken{scope: scope} = _current_app_token, _struct, rule) def has_user_access?(%ApplicationToken{scope: scope} = _current_app_token, _struct, rule)
when rule != :forbid_app_access do when rule != :forbid_app_access do
AppScope.has_app_access?(scope, rule) AppScope.has_app_access?(scope, rule)
end end
@impl true
def has_user_access?(_current_user, _scoped_struct, _rule), do: false def has_user_access?(_current_user, _scoped_struct, _rule), do: false
@impl true @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 """ @moduledoc """
Handles the participation-related GraphQL calls. 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.Actors.Actor
alias Mobilizon.Conversations.{Conversation, ConversationView}
alias Mobilizon.Events.{Event, Participant} alias Mobilizon.Events.{Event, Participant}
alias Mobilizon.GraphQL.API.Comments
alias Mobilizon.GraphQL.API.Participations alias Mobilizon.GraphQL.API.Participations
alias Mobilizon.Service.Export.Participants.{CSV, ODS, PDF} alias Mobilizon.Service.Export.Participants.{CSV, ODS, PDF}
alias Mobilizon.Users.User alias Mobilizon.Users.User
@ -346,6 +348,75 @@ defmodule Mobilizon.GraphQL.Resolvers.Participant do
def export_event_participants(_, _, _), do: {:error, :unauthorized} 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 @spec valid_email?(String.t() | nil) :: boolean
defp valid_email?(email) when is_nil(email), do: false 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.Users.ActivitySetting)
import_types(Schema.FollowedGroupActivityType) import_types(Schema.FollowedGroupActivityType)
import_types(Schema.AuthApplicationType) import_types(Schema.AuthApplicationType)
import_types(Schema.ConversationType)
@desc "A struct containing the id of the deleted object" @desc "A struct containing the id of the deleted object"
object :deleted_object do object :deleted_object do
@ -165,6 +166,7 @@ defmodule Mobilizon.GraphQL.Schema do
import_fields(:todo_list_queries) import_fields(:todo_list_queries)
import_fields(:todo_queries) import_fields(:todo_queries)
import_fields(:discussion_queries) import_fields(:discussion_queries)
import_fields(:conversation_queries)
import_fields(:resource_queries) import_fields(:resource_queries)
import_fields(:post_queries) import_fields(:post_queries)
import_fields(:statistics_queries) import_fields(:statistics_queries)
@ -189,6 +191,7 @@ defmodule Mobilizon.GraphQL.Schema do
import_fields(:todo_list_mutations) import_fields(:todo_list_mutations)
import_fields(:todo_mutations) import_fields(:todo_mutations)
import_fields(:discussion_mutations) import_fields(:discussion_mutations)
import_fields(:conversation_mutations)
import_fields(:resource_mutations) import_fields(:resource_mutations)
import_fields(:post_mutations) import_fields(:post_mutations)
import_fields(:actor_mutations) import_fields(:actor_mutations)
@ -204,6 +207,7 @@ defmodule Mobilizon.GraphQL.Schema do
subscription do subscription do
import_fields(:person_subscriptions) import_fields(:person_subscriptions)
import_fields(:discussion_subscriptions) import_fields(:discussion_subscriptions)
import_fields(:conversation_subscriptions)
end end
@spec middleware(list(module()), any(), map()) :: list(module()) @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] import Absinthe.Resolution.Helpers, only: [dataloader: 2]
alias Mobilizon.Events alias Mobilizon.Events
alias Mobilizon.GraphQL.Resolvers.{Media, Person} alias Mobilizon.GraphQL.Resolvers.{Conversation, Media, Person}
alias Mobilizon.GraphQL.Schema alias Mobilizon.GraphQL.Schema
import_types(Schema.Events.FeedTokenType) 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") arg(:limit, :integer, default_value: 10, description: "The limit of follows per page")
resolve(&Person.person_follows/3) resolve(&Person.person_follows/3)
end 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 end
@desc """ @desc """
@ -353,5 +372,16 @@ defmodule Mobilizon.GraphQL.Schema.Actors.PersonType do
{:ok, topic: [args.group, args.person_id]} {:ok, topic: [args.group, args.person_id]}
end) end)
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
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" 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") field(:language, :string, description: "The comment language")
end end

View file

@ -8,7 +8,7 @@ defmodule Mobilizon.GraphQL.Schema.EventType do
import Absinthe.Resolution.Helpers, only: [dataloader: 1, dataloader: 2] import Absinthe.Resolution.Helpers, only: [dataloader: 1, dataloader: 2]
alias Mobilizon.{Actors, Addresses, Discussions} alias Mobilizon.{Actors, Addresses, Discussions}
alias Mobilizon.GraphQL.Resolvers.{Event, Media, Tag} alias Mobilizon.GraphQL.Resolvers.{Conversation, Event, Media, Tag}
alias Mobilizon.GraphQL.Schema alias Mobilizon.GraphQL.Schema
import_types(Schema.AddressType) import_types(Schema.AddressType)
@ -113,6 +113,18 @@ defmodule Mobilizon.GraphQL.Schema.EventType do
field(:options, :event_options, description: "The event options") field(:options, :event_options, description: "The event options")
field(:metadata, list_of(:event_metadata), description: "A key-value list of metadata") field(:metadata, list_of(:event_metadata), description: "A key-value list of metadata")
field(:language, :string, description: "The event language") 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 end
@desc "The list of visibility options for an event" @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) resolve(&Participant.export_event_participants/3)
end 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
end end

View file

@ -8,7 +8,7 @@ defmodule Mobilizon.GraphQL.Schema.UserType do
alias Mobilizon.Events alias Mobilizon.Events
alias Mobilizon.GraphQL.Resolvers.Application, as: ApplicationResolver 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.Resolvers.Users.ActivitySettings
alias Mobilizon.GraphQL.Schema alias Mobilizon.GraphQL.Schema
@ -191,6 +191,19 @@ defmodule Mobilizon.GraphQL.Schema.UserType do
) do ) do
resolve(&ApplicationResolver.get_user_applications/3) resolve(&ApplicationResolver.get_user_applications/3)
end 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 end
@desc "The list of roles an user can have" @desc "The list of roles an user can have"

View file

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

View file

@ -10,6 +10,7 @@ defmodule Mobilizon.Actors.Actor do
alias Mobilizon.{Actors, Addresses, Config, Crypto, Mention, Share} alias Mobilizon.{Actors, Addresses, Config, Crypto, Mention, Share}
alias Mobilizon.Actors.{ActorOpenness, ActorType, ActorVisibility, Follower, Member} alias Mobilizon.Actors.{ActorOpenness, ActorType, ActorVisibility, Follower, Member}
alias Mobilizon.Addresses.Address alias Mobilizon.Addresses.Address
alias Mobilizon.Conversations.Conversation
alias Mobilizon.Discussions.Comment alias Mobilizon.Discussions.Comment
alias Mobilizon.Events.{Event, FeedToken, Participant} alias Mobilizon.Events.{Event, FeedToken, Participant}
alias Mobilizon.Medias.File alias Mobilizon.Medias.File
@ -196,6 +197,11 @@ defmodule Mobilizon.Actors.Actor do
has_many(:owner_shares, Share, foreign_key: :owner_actor_id) has_many(:owner_shares, Share, foreign_key: :owner_actor_id)
many_to_many(:memberships, __MODULE__, join_through: Member) 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() timestamps()
end 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] import Mobilizon.Storage.Ecto, only: [maybe_add_published_at: 1]
alias Mobilizon.Actors.Actor alias Mobilizon.Actors.Actor
alias Mobilizon.Conversations.Conversation
alias Mobilizon.Discussions.{Comment, CommentVisibility, Discussion} alias Mobilizon.Discussions.{Comment, CommentVisibility, Discussion}
alias Mobilizon.Events.{Event, Tag} alias Mobilizon.Events.{Event, Tag}
alias Mobilizon.Medias.Media alias Mobilizon.Medias.Media
@ -49,7 +50,9 @@ defmodule Mobilizon.Discussions.Comment do
:local, :local,
:is_announcement, :is_announcement,
:discussion_id, :discussion_id,
:language :conversation_id,
:language,
:visibility
] ]
@attrs @required_attrs ++ @optional_attrs @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(:in_reply_to_comment, Comment, foreign_key: :in_reply_to_comment_id)
belongs_to(:origin_comment, Comment, foreign_key: :origin_comment_id) belongs_to(:origin_comment, Comment, foreign_key: :origin_comment_id)
belongs_to(:discussion, Discussion, type: :binary_id) belongs_to(:discussion, Discussion, type: :binary_id)
belongs_to(:conversation, Conversation)
has_many(:replies, Comment, foreign_key: :origin_comment_id) has_many(:replies, Comment, foreign_key: :origin_comment_id)
many_to_many(:tags, Tag, join_through: "comments_tags", on_replace: :delete) 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) many_to_many(:media, Media, join_through: "comments_medias", on_replace: :delete)
timestamps(type: :utc_datetime) timestamps(type: :utc_datetime)
end end
@doc """ @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 @spec get_thread_id(t) :: integer
def get_thread_id(%__MODULE__{id: id, origin_comment_id: origin_comment_id}) do 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) Tag.changeset(%Tag{}, tag)
end end
defp process_mention(tag) do defp process_mention(mention) do
Mention.changeset(%Mention{}, tag) Mention.changeset(%Mention{}, mention)
end end
end end

View file

@ -42,9 +42,9 @@ defmodule Mobilizon.Discussions do
:origin_comment, :origin_comment,
:replies, :replies,
:tags, :tags,
:mentions,
:discussion, :discussion,
:media :media,
mentions: [:actor]
] ]
@discussion_preloads [ @discussion_preloads [
@ -76,6 +76,7 @@ defmodule Mobilizon.Discussions do
Comment Comment
|> join(:left, [c], r in Comment, on: r.origin_comment_id == c.id) |> 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, _], 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. # 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 # However, it also excludes all top-level comments with deleted replies from being selected
# |> where([_, r], is_nil(r.deleted_at)) # |> 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()} @spec update_comment(Comment.t(), map) :: {:ok, Comment.t()} | {:error, Changeset.t()}
def update_comment(%Comment{} = comment, attrs) do def update_comment(%Comment{} = comment, attrs) do
with {:ok, %Comment{} = comment} <-
comment comment
|> Comment.update_changeset(attrs) |> Comment.update_changeset(attrs)
|> Repo.update() |> Repo.update(),
%Comment{} = comment <- Repo.preload(comment, @comment_preloads) do
{:ok, comment}
end
end end
@doc """ @doc """
@ -272,6 +277,19 @@ defmodule Mobilizon.Discussions do
|> Page.build_page(page, limit) |> Page.build_page(page, limit)
end 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 """ @doc """
Counts local comments under events 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, Events, Medias, Mention}
alias Mobilizon.Addresses.Address alias Mobilizon.Addresses.Address
alias Mobilizon.Conversations.Conversation
alias Mobilizon.Discussions.Comment alias Mobilizon.Discussions.Comment
alias Mobilizon.Events.{ alias Mobilizon.Events.{
@ -126,6 +127,7 @@ defmodule Mobilizon.Events.Event do
has_many(:sessions, Session) has_many(:sessions, Session)
has_many(:mentions, Mention) has_many(:mentions, Mention)
has_many(:comments, Comment) has_many(:comments, Comment)
has_many(:conversations, Conversation)
many_to_many(:contacts, Actor, join_through: "event_contacts", on_replace: :delete) 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(:tags, Tag, join_through: "events_tags", on_replace: :delete)
many_to_many(:participants, Actor, join_through: Participant) many_to_many(:participants, Actor, join_through: Participant)

View file

@ -871,6 +871,21 @@ defmodule Mobilizon.Events do
|> Page.build_page(page, limit) |> Page.build_page(page, limit)
end 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()] @spec list_actors_participants_for_event(String.t()) :: [Actor.t()]
def list_actors_participants_for_event(id) do def list_actors_participants_for_event(id) do
id id

View file

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

View file

@ -21,7 +21,14 @@ defmodule Mobilizon.Reports do
def get_report(id) do def get_report(id) do
Report Report
|> Repo.get(id) |> Repo.get(id)
|> Repo.preload([:reported, :reporter, :manager, :events, :comments, :notes]) |> Repo.preload([
:reported,
:reporter,
:manager,
:events,
:notes,
comments: [conversation: [:participants]]
])
end end
@doc """ @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 res
end 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() @spec do_render(Activity.t(), Keyword.t()) :: common_render()
defp do_render(%Activity{type: type} = activity, options) do defp do_render(%Activity{type: type} = activity, options) do
case type do case Map.get(@types_map, type) do
:discussion -> Discussion.render(activity, options) nil ->
:event -> Event.render(activity, options) nil
:group -> Group.render(activity, options)
:member -> Member.render(activity, options) mod ->
:post -> Post.render(activity, options) mod.render(activity, options)
:resource -> Resource.render(activity, options)
:comment -> Comment.render(activity, options)
_ -> nil
end end
end end
end end

View file

@ -14,6 +14,8 @@ defmodule Mobilizon.Service.Formatter.HTML do
def filter_tags(html), do: Sanitizer.scrub(html, DefaultScrubbler) def filter_tags(html), do: Sanitizer.scrub(html, DefaultScrubbler)
defdelegate basic_html(html), to: FastSanitize
@spec strip_tags(String.t()) :: String.t() | no_return() @spec strip_tags(String.t()) :: String.t() | no_return()
def strip_tags(html) do def strip_tags(html) do
case FastSanitize.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 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) def filter_tags_for_oembed(html), do: Sanitizer.scrub(html, OEmbed)
end 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 [ @always_direct_subjects [
:participation_event_comment, :participation_event_comment,
:event_comment_mention, :event_comment_mention,
:conversation_mention,
:conversation_created,
:conversation_replied,
:discussion_mention, :discussion_mention,
:event_new_comment :event_new_comment
] ]
@ -175,6 +178,9 @@ defmodule Mobilizon.Service.Notifier.Email do
"member_updated" => false, "member_updated" => false,
"user_email_password_updated" => true, "user_email_password_updated" => true,
"event_comment_mention" => true, "event_comment_mention" => true,
"conversation_mention" => true,
"conversation_created" => true,
"conversation_replied" => true,
"discussion_mention" => true, "discussion_mention" => true,
"event_new_comment" => 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}), defp map_activity_to_activity_setting(%Activity{subject: :event_comment_mention}),
do: "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}), defp map_activity_to_activity_setting(%Activity{subject: :discussion_mention}),
do: "discussion_mention" do: "discussion_mention"

View file

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

View file

@ -8,6 +8,7 @@ defmodule Mobilizon.Service.Workers.LegacyNotifierBuilder do
alias Mobilizon.Actors.Actor alias Mobilizon.Actors.Actor
alias Mobilizon.Events.{Event, Participant} alias Mobilizon.Events.{Event, Participant}
alias Mobilizon.Service.Notifier alias Mobilizon.Service.Notifier
require Logger
use Mobilizon.Service.Workers.Helper, queue: "activity" use Mobilizon.Service.Workers.Helper, queue: "activity"
@ -15,14 +16,22 @@ defmodule Mobilizon.Service.Workers.LegacyNotifierBuilder do
def perform(%Job{args: args}) do def perform(%Job{args: args}) do
{"legacy_notify", args} = Map.pop(args, "op") {"legacy_notify", args} = Map.pop(args, "op")
activity = build_activity(args) activity = build_activity(args)
Logger.debug("Handling activity #{activity.subject} to notify in LegacyNotifierBuilder")
if args["subject"] == "participation_event_comment" do if args["subject"] == "participation_event_comment" do
notify_anonymous_participants(get_in(args, ["subject_params", "event_id"]), activity) notify_anonymous_participants(get_in(args, ["subject_params", "event_id"]), activity)
end end
if args["subject"] == "conversation_created" do
notify_anonymous_participants(
get_in(args, ["subject_params", "conversation_event_id"]),
activity
)
end
args args
|> users_to_notify(author_id: args["author_id"], group_id: Map.get(args, "group_id")) |> 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 end
defp build_activity(args) do 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)) users_from_actor_ids(mentionned_actor_ids, Keyword.fetch!(options, :author_id))
end 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( defp users_to_notify(
%{"subject" => "discussion_mention", "mentions" => mentionned_actor_ids}, %{"subject" => "discussion_mention", "mentions" => mentionned_actor_ids},
options options
@ -114,4 +132,9 @@ defmodule Mobilizon.Service.Workers.LegacyNotifierBuilder do
) )
end) end)
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 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) 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 end
defp set_user_context({conn, context}, %User{id: user_id, email: user_email} = user) do 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. 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.Actor, as: ActorModel
alias Mobilizon.Actors.Member alias Mobilizon.Actors.Member
alias Mobilizon.Conversations.Conversation
alias Mobilizon.Discussions.{Comment, Discussion} alias Mobilizon.Discussions.{Comment, Discussion}
alias Mobilizon.Events.Event alias Mobilizon.Events.Event
alias Mobilizon.Federation.ActivityPub.{Actor, Relay} alias Mobilizon.Federation.ActivityPub.{Actor, Relay}
@ -184,6 +185,23 @@ defmodule Mobilizon.Web.Cache.ActivityPub do
end) end)
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 """ @doc """
Gets a member by its UUID, with all associations loaded. 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.Actors.{Actor, Member}
alias Mobilizon.Conversations.Conversation
alias Mobilizon.Discussions.{Comment, Discussion} alias Mobilizon.Discussions.{Comment, Discussion}
alias Mobilizon.Events.Event alias Mobilizon.Events.Event
alias Mobilizon.Posts.Post alias Mobilizon.Posts.Post
@ -27,6 +28,10 @@ defmodule Mobilizon.Web.Cache do
defdelegate get_todo_list_by_uuid_with_preload(uuid), to: ActivityPub defdelegate get_todo_list_by_uuid_with_preload(uuid), to: ActivityPub
@spec get_todo_by_uuid_with_preload(binary) :: {:commit, Todo.t()} | {:ignore, nil} @spec get_todo_by_uuid_with_preload(binary) :: {:commit, Todo.t()} | {:ignore, nil}
defdelegate get_todo_by_uuid_with_preload(uuid), to: ActivityPub 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} @spec get_member_by_uuid_with_preload(binary) :: {:commit, Member.t()} | {:ignore, nil}
defdelegate get_member_by_uuid_with_preload(uuid), to: ActivityPub defdelegate get_member_by_uuid_with_preload(uuid), to: ActivityPub
@spec get_post_by_slug_with_preload(binary) :: {:commit, Post.t()} | {:ignore, nil} @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} <- with {:ok, authed_socket} <-
Guardian.Phoenix.Socket.authenticate(socket, Mobilizon.Web.Auth.Guardian, token), Guardian.Phoenix.Socket.authenticate(socket, Mobilizon.Web.Auth.Guardian, token),
resource <- Guardian.Phoenix.Socket.current_resource(authed_socket) do resource <- Guardian.Phoenix.Socket.current_resource(authed_socket) do
set_context(authed_socket, resource) {:ok, set_context(authed_socket, resource)}
{:ok, authed_socket}
else else
{:error, _} -> {:error, _} ->
:error :error
@ -24,8 +22,17 @@ defmodule Mobilizon.Web.GraphQLSocket do
def connect(_args, _socket), do: :error def connect(_args, _socket), do: :error
@spec id(any) :: nil @spec id(Phoenix.Socket.t()) :: String.t() | nil
def id(_socket), do: 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() @spec set_context(Phoenix.Socket.t(), User.t() | ApplicationToken.t()) :: Phoenix.Socket.t()
defp set_context(socket, %User{} = user) do 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) render_or_error(conn, &checks?/3, status, :todo, todo)
end 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 @typep collections :: :resources | :posts | :discussions | :events | :todos
@spec resources(Plug.Conn.t(), map()) :: Plug.Conn.t() @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.Actors.Actor
alias Mobilizon.Config alias Mobilizon.Config
alias Mobilizon.Web.Email alias Mobilizon.Web.Email
require Logger
@spec direct_activity(String.t(), list(), Keyword.t()) :: Swoosh.Email.t() @spec direct_activity(String.t(), list(), Keyword.t()) :: Swoosh.Email.t()
def direct_activity( def direct_activity(
@ -39,6 +40,36 @@ defmodule Mobilizon.Web.Email.Activity do
end end
@spec anonymous_activity(String.t(), Activity.t(), Keyword.t()) :: Swoosh.Email.t() @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 def anonymous_activity(email, %Activity{subject_params: subject_params} = activity, options) do
locale = Keyword.get(options, :locale, "en") locale = Keyword.get(options, :locale, "en")
@ -49,6 +80,8 @@ defmodule Mobilizon.Web.Email.Activity do
event: subject_params["event_title"] event: subject_params["event_title"]
) )
Logger.debug("Going to send anonymous activity of type #{activity.type} to #{email}")
[to: email, subject: subject] [to: email, subject: subject]
|> Email.base_email() |> Email.base_email()
|> render_body(:email_anonymous_activity, %{ |> render_body(:email_anonymous_activity, %{

View file

@ -10,6 +10,21 @@ defmodule Mobilizon.Web.Endpoint do
use Phoenix.Endpoint, otp_app: :mobilizon use Phoenix.Endpoint, otp_app: :mobilizon
use Absinthe.Phoenix.Endpoint 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) plug(Mobilizon.Web.Plugs.DetectLocalePlug)
if Application.compile_env(:mobilizon, :env) !== :dev do if Application.compile_env(:mobilizon, :env) !== :dev do
@ -37,21 +52,6 @@ defmodule Mobilizon.Web.Endpoint do
do: RemoteIp 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 reloading can be explicitly enabled under the
# :code_reloader configuration of your endpoint. # :code_reloader configuration of your endpoint.
if code_reloading? do if code_reloading? do

View file

@ -72,7 +72,6 @@ defmodule Mobilizon.Web.Router do
pipeline :browser do pipeline :browser do
plug(:put_request_context) plug(:put_request_context)
plug(Plug.Static, at: "/", from: "priv/static")
plug(Mobilizon.Web.Plugs.SetLocalePlug) plug(Mobilizon.Web.Plugs.SetLocalePlug)
@ -132,6 +131,7 @@ defmodule Mobilizon.Web.Router do
get("/@:name/discussions", PageController, :discussions) get("/@:name/discussions", PageController, :discussions)
get("/@:name/events", PageController, :events) get("/@:name/events", PageController, :events)
get("/p/:slug", PageController, :post) get("/p/:slug", PageController, :post)
get("/conversations/:id", PageController, :conversation)
get("/@:name/c/:slug", PageController, :discussion) get("/@:name/c/:slug", PageController, :discussion)
end end
@ -176,6 +176,7 @@ defmodule Mobilizon.Web.Router do
forward("/", Absinthe.Plug.GraphiQL, forward("/", Absinthe.Plug.GraphiQL,
schema: Mobilizon.GraphQL.Schema, schema: Mobilizon.GraphQL.Schema,
socket: Mobilizon.Web.GraphQLSocket,
interface: :playground interface: :playground
) )
end 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,6 +35,8 @@
<tr> <tr>
<td align="center" valign="top" width="600"> <td align="center" valign="top" width="600">
<![endif]--> <![endif]-->
<%= case @activity.type do %>
<% :comment -> %>
<table border="0" cellpadding="0" cellspacing="0" width="100%" style="max-width: 600px;"> <table border="0" cellpadding="0" cellspacing="0" width="100%" style="max-width: 600px;">
<!-- COPY --> <!-- COPY -->
<tr> <tr>
@ -47,9 +49,10 @@
> >
<%= dgettext( <%= dgettext(
"activity", "activity",
"%{profile} has posted an announcement under event %{event}.", "%{profile} has posted a public announcement under event %{event}.",
%{ %{
profile: "<b>#{escape_html(display_name_and_username(@activity.author))}</b>", profile:
"<b>#{escape_html(display_name_and_username(@activity.author))}</b>",
event: event:
"<a href=\"#{Routes.page_url(Mobilizon.Web.Endpoint, "<a href=\"#{Routes.page_url(Mobilizon.Web.Endpoint,
:event, :event,
@ -90,6 +93,106 @@
</td> </td>
</tr> </tr>
</table> </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)]> <!--[if (gte mso 9)|(IE)]>
</td> </td>
</tr> </tr>

View file

@ -1,11 +1,30 @@
<%= @subject %> <%= @subject %>
== ==
<%= case @activity.type do %>
<%= dgettext("activity", "%{profile} has posted an announcement under event %{event}.", <% :comment -> %>
<%= dgettext("activity", "%{profile} has posted a public announcement under event %{event}.",
%{ %{
profile: Mobilizon.Actors.Actor.display_name_and_username(@activity.author), profile: Mobilizon.Actors.Actor.display_name_and_username(@activity.author),
event: @activity.subject_params["event_title"] event: @activity.subject_params["event_title"]
} }
) %> ) %>
<%= Routes.page_url(Mobilizon.Web.Endpoint, :event, @activity.subject_params["event_uuid"]) |> URI.decode() %> <%= 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", <%= render("activity/_discussion_activity_item.html",
activity: activity activity: activity
) %> ) %>
<% :conversation -> %>
<%= render("activity/_conversation_activity_item.html",
activity: activity
) %>
<% :event -> %> <% :event -> %>
<%= render("activity/_event_activity_item.html", <%= render("activity/_event_activity_item.html",
activity: activity activity: activity

View file

@ -15,7 +15,7 @@
<% end %> <% end %>
<%= for activity <- Enum.take(group_activities, 5) do %> <%= 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 %> <% :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 %> <%= unless @single_activity do %><%= datetime_to_string(activity.inserted_at, @locale, :short) %><% end %>
<% end %> <% end %>

View file

@ -7,6 +7,7 @@ defmodule Mobilizon.Web.EmailView do
alias Mobilizon.Actors.Actor alias Mobilizon.Actors.Actor
alias Mobilizon.Service.Address alias Mobilizon.Service.Address
alias Mobilizon.Service.DateTime, as: DateTimeRenderer alias Mobilizon.Service.DateTime, as: DateTimeRenderer
alias Mobilizon.Service.Formatter.{HTML, Text}
alias Mobilizon.Web.Router.Helpers, as: Routes alias Mobilizon.Web.Router.Helpers, as: Routes
import Mobilizon.Web.Gettext import Mobilizon.Web.Gettext
import Mobilizon.Service.Metadata.Utils, only: [process_description: 1] import Mobilizon.Service.Metadata.Utils, only: [process_description: 1]
@ -35,6 +36,21 @@ defmodule Mobilizon.Web.EmailView do
|> safe_to_string() |> safe_to_string()
end 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 def escaped_display_name_and_username(actor) do
actor actor
|> display_name_and_username() |> display_name_and_username()

View file

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

11997
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

View file

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