diff --git a/.gitignore b/.gitignore index 4804240ad..fb8ad471f 100644 --- a/.gitignore +++ b/.gitignore @@ -26,6 +26,7 @@ priv/data/* !priv/data/.gitkeep priv/errors/* !priv/errors/.gitkeep +priv/cert/ .vscode/ cover/ site/ diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 2b386e9a8..4aec00d19 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -16,7 +16,7 @@ variables: # DB Variables for Postgres / Postgis POSTGRES_DB: mobilizon_test POSTGRES_USER: postgres - POSTGRES_PASSWORD: "" + POSTGRES_PASSWORD: postgres POSTGRES_HOST: postgres # DB Variables for Mobilizon MOBILIZON_DATABASE_USERNAME: $POSTGRES_USER @@ -61,7 +61,7 @@ lint-elixir: - exit $EXITVALUE lint-front: - image: node:14 + image: node:16 stage: check before_script: - export EXITVALUE=0 @@ -73,7 +73,7 @@ lint-front: build-frontend: stage: build-js - image: node:14 + image: node:16 before_script: - apt update - apt install -y --no-install-recommends python build-essential webp imagemagick gifsicle jpegoptim optipng pngquant @@ -100,10 +100,27 @@ deps: needs: - install +exunit-1.11: + stage: test + image: tcitworld/mobilizon-ci:legacy + services: + - name: postgis/postgis:11-3.0 + alias: postgres + variables: + MIX_ENV: test + before_script: + - mix deps.clean --all + - mix deps.get + - mix ecto.create + - mix ecto.migrate + script: + - mix coveralls + allow_failure: true + exunit: stage: test services: - - name: mdillon/postgis:11 + - name: postgis/postgis:13-3.1 alias: postgres variables: MIX_ENV: test @@ -140,7 +157,7 @@ jest: # cypress: # stage: test # services: -# - name: mdillon/postgis:11 +# - name: postgis/postgis:13.3 # alias: postgres # variables: # MIX_ENV=e2e @@ -197,7 +214,7 @@ build-docker-master: build-docker-tag: <<: *docker - rules: + rules: &tag-rules - if: '$CI_PROJECT_NAMESPACE != "framasoft"' when: never - if: $CI_COMMIT_TAG @@ -235,34 +252,38 @@ package-app-dev: release-upload: stage: upload - image: curlimages/curl:latest - rules: - - if: $CI_COMMIT_TAG - script: | - APP_VERSION="${CI_COMMIT_TAG}" - APP_ASSET="${CI_PROJECT_NAME}_${APP_VERSION}_${ARCH}.tar.gz" + image: framasoft/yakforms-assets-deploy:latest + rules: *tag-rules + script: + - APP_VERSION="${CI_COMMIT_TAG}" + - APP_ASSET="${CI_PROJECT_NAME}_${APP_VERSION}_${ARCH}.tar.gz" - echo "Artifact: ${APP_ASSET}" - tar czf ${APP_ASSET} -C release mobilizon - ls -al ${APP_ASSET} + - 'echo "Artifact: ${APP_ASSET}"' + - tar czf ${APP_ASSET} -C release mobilizon + - ls -al ${APP_ASSET} - curl --silent --show-error --header "JOB-TOKEN: ${CI_JOB_TOKEN}" --upload-file "${APP_ASSET}" ${PACKAGE_REGISTRY_URL}/${APP_VERSION}/${APP_ASSET} + - eval `ssh-agent -s` + - ssh-add <(echo "${DEPLOYEMENT_KEY}" | base64 --decode -i) + - echo "put -r ${APP_ASSET}" | sftp -o "VerifyHostKeyDNS yes" ${DEPLOYEMENT_USER}@${DEPLOYEMENT_HOST}:public/ artifacts: expire_in: 1 day when: on_success paths: - mobilizon_*.tar.gz -# release-create: -# stage: deploy -# image: registry.gitlab.com/gitlab-org/release-cli:latest -# rules: -# - if: $CI_COMMIT_TAG -# dependencies: [] -# cache: {} -# script: | -# APP_VERSION="${CI_COMMIT_TAG}" -# APP_ASSET="${CI_PROJECT_NAME}_${APP_VERSION}_${ARCH}.tar.gz" -# release-cli create --name "$CI_PROJECT_TITLE v$CI_COMMIT_TAG" \ -# --tag-name "$CI_COMMIT_TAG" \ -# --assets-link "{\"name\":\"${APP_ASSET}\",\"url\":\"${PACKAGE_REGISTRY_URL}/${APP_VERSION}/${APP_ASSET}\"}" +release-create: + stage: deploy + image: registry.gitlab.com/gitlab-org/release-cli:latest + rules: *tag-rules + before_script: + - apk --no-cache add gawk sed grep + script: | + APP_VERSION="${CI_COMMIT_TAG}" + APP_ASSET="${CI_PROJECT_NAME}_${APP_VERSION}_${ARCH}.tar.gz" + CHANGELOG=$(awk -v version="$APP_VERSION" '/^## / { printit = $2 == version }; printit' CHANGELOG.md | grep -v "## $APP_VERSION" | sed '1{/^$/d}') + ENDPOINT="https://packages.joinmobilizon.org" + + release-cli create --name "$CI_COMMIT_TAG" \ + --description "$CHANGELOG" \ + --tag-name "$CI_COMMIT_TAG" \ + --assets-link "{\"name\":\"${APP_ASSET}\",\"url\":\"${ENDPOINT}/${APP_ASSET}\"}" diff --git a/.sobelow-skips b/.sobelow-skips index 0d0acdd80..70978cfa5 100644 --- a/.sobelow-skips +++ b/.sobelow-skips @@ -3,4 +3,8 @@ 752C0E897CA81ACD81F4BB215FA5F8E4 23412CF16549E4E88366DC9DECF39071 -81C1F600C5809C7029EE32DE4818CD7D \ No newline at end of file +81C1F600C5809C7029EE32DE4818CD7D +155A1FB53DE39EC8EFCFD7FB94EA823D +73B351E4CB3AF715AD450A085F5E6304 +BBACD7F0BACD4A6D3010C26604671692 +6D4D4A4821B93BCFAC9CDBB367B34C4B \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 7faf4c0a9..4d4ebbe30 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,179 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## 1.1.4 - 19-05-2021 +## 1.2.3 - 2021-07-02 + +### Changed + +- Improved list discussion items UI on the group panel + +### Fixed + +- Fixed 'unsafe-inline' being in CSP +- Fixed group discussions with deleted comments + +## 1.2.2 - 2021-07-01 +### Changed + +- Improved UI for participations when message is too long + +### Fixed + +- Fixed pictures without metadata information in post display +- Fixed crash when trying to notify activities not from groups +- Fixed imagemagick missing from Dockerfile +- Fixed push notifications for group, members & post activities +- Fixed ellipsis in DiscussionListView +- Fixed submission button for posts not visible on mobile +- Fixed remote profile suspension + +### Translations + +- Spanish + +## 1.2.1 - 2021-06-29 + +### Fixed + +- Fixed Docker image missing libc (which is required by newer OTP versions at runtime) +- Fixed compatibility check in Notification section for service workers + +## 1.2.0 - 2021-06-29 +### Added + +- **Notifications for various group and event activity, both by email and browser push notifications. Daily and weekly digests are also available.** +- Possibility for an event organizer to announce a (public) comment, triggering notifications for participants +- Add a snackbar message to manually reload the UI when updates are available +- Add blurhash support for some banners +- Added basic metadata (start time & physical address) in the opengraph preview + +### Changed + +- **Interface improvements to events, comments, homepage and group pages** +- **Various improvements to mobile views** +- Make JWT access tokens short-lived +- Disabled Cldr warning that the `Cldr.Plug.AcceptLanguage` plug didn't many any known locale +- Replaced GraphiQL web interface with graphql-playground + +### Removed + +- Internet Explorer and other older browsers support. This allows us to provide lighter builds. + +### Fixed + +- Fixed compatibility for previous OTP versions +- Fixed the "member joined" activity event not being displayed in the group activity timeline +- Fixed relay and anonymous actor telling they automatically approve followers +- Fixed mix tasks showing output from all error levels +- Fixed missing metadata on some pages +- Fixed some config values being defined at compile-time instead of runtime +- Fixed missing pagination for group resources +- Fixed missing `.ics` suffix for email event attachments +- Fixed missing unique index on posts URL +- Fixed creating events from group page not always auto-selecting the correct organizer actor +- Fixed error when deleting actor with type different from Person or Group +- Fixed not defaulting to UTC timezone when user has no tz setting in their activity recaps +- Fixed Sentry loading itself even if not configured +- Fixed showing proper message when anonymous participation was confirmed but just wasn't saved in browser +- Fixed editing some event properties +- Fixed group image ratio in admin dashboard +- Fix GraphiQL CSP headers + +### Translations + +- Finnish +- French +- Galician +- Italian +- Occitan +- Russian +- Spanish +- Swedish + +## 1.2.0-beta.3 - 2021-06-27 + +### Added + +- Allow sending notifications to event organizer when new comment is posted +- Allow sending comment announcements notifications to anonymous participants as well + +### Changed + +- Disabled Cldr warning that the `Cldr.Plug.AcceptLanguage` plug didn't many any known locale + +### Fixed + +- Fixed error when deleting actor with type different from Person or Group +- Fixed not defaulting to UTC timezone when user has no tz setting in their activity recaps +- Fixed Sentry loading itself even if not configured +- Fixed showing proper message when anonymous participation was confirmed but just wasn't saved in browser +- Fixed editing some event properties + +### Translations + +- Persian (New!) +- Spanish + +## 1.2.0-beta.2 - 2021-06-26 + +### Added + +- Added basic metadata (start time & physical address) in the opengraph preview +- Made mentions trigger notifications +- Allow to send activity digests +- Mix task to generate web push keypair + +### Fixed + +- Fixed missing unique index on posts URL +- Fixed creating events from group page not always auto-selecting the correct organizer actor + +### Translations + +- French +- Spanish + +## 1.2.0-beta.1 - 2021-06-21 + +### Added + +- **Notifications for various group and event activity, both by email and browser push notifications** +- Possibility for an event organizer to announce a (public) comment, triggering notifications for participants +- Add a snackbar message to manually reload the UI when updates are available +- Add blurhash support for some banners + +### Changed + +- **Interface improvements to events, comments, homepage and group pages** +- **Various improvements to mobile views** +- Make JWT access tokens short-lived + +### Removed + +- Internet Explorer and other older browsers support. This allows us to provide lighter builds. + +### Fixed + +- Fixed compatibility for previous OTP versions +- Fixed the "member joined" activity event not being displayed in the group activity timeline +- Fixed relay and anonymous actor telling they automatically approve followers +- Fixed mix tasks showing output from all error levels +- Fixed missing metadata on some pages +- Fixed some config values being defined at compile-time instead of runtime +- Fixed missing pagination for group resources +- Fixed missing `.ics` suffix for email event attachments + +### Translations + +- Finnish +- Galician +- Italian +- Occitan +- Russian +- Spanish +- Swedish + +## 1.1.4 - 2021-05-19 ### Fixes @@ -21,7 +193,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Galician - Italian -## 1.1.3 - 03-05-2021 +## 1.1.3 - 2021-05-03 ### Changed @@ -40,7 +212,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Russian - Spanish -## 1.1.2 - 28-04-2021 +## 1.1.2 - 2021-04-28 ### Changed @@ -65,7 +237,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Slovenian - Russian -## 1.1.1 - 22-04-2021 +## 1.1.1 - 2021-04-22 ### Changed @@ -97,7 +269,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Fixed editing an user's email in CLI - Fixed suspended actors being refreshed - ### Translations - Gaelic @@ -108,7 +279,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Slovenian - Spanish -## 1.1.0 - 31-03-2021 +## 1.1.0 - 2021-03-31 This version introduces a new way to install and host Mobilizon : Elixir releases. This is the new default way of installing Mobilizon. Please read [UPGRADE.md](./UPGRADE.md#upgrading-from-10-to-11) for details on how to migrate to Elixir binary releases or stay on source install. @@ -204,7 +375,7 @@ This version introduces a new way to install and host Mobilizon : Elixir releas - Slovenian - Spanish -## 1.1.0-rc.3 - 30-03-2021 +## 1.1.0-rc.3 - 2021-03-30 ### Changed @@ -215,7 +386,7 @@ This version introduces a new way to install and host Mobilizon : Elixir releas - Fixed parsing the IP from the MOBILIZON_INSTANCE_LISTEN_IP env variable for Docker - Fixed release startup in Docker container -## 1.1.0-rc.2 - 30-03-2021 +## 1.1.0-rc.2 - 2021-03-30 ### Added @@ -239,7 +410,7 @@ This version introduces a new way to install and host Mobilizon : Elixir releas - Hungarian - Russian - Spanish -## 1.1.0-rc.1 - 29-03-2021 +## 1.1.0-rc.1 - 2021-03-29 ### Added @@ -283,17 +454,17 @@ This version introduces a new way to install and host Mobilizon : Elixir releas - Slovenian - Spanish -## 1.1.0-beta.6 - 17-03-2021 +## 1.1.0-beta.6 - 2021-03-17 ### Fixed - Fixed a typo in range/radius showing the wrong radius for close events on homepage -## 1.1.0-beta.5 - 17-03-2021 +## 1.1.0-beta.5 - 2021-03-17 ### Fixed - Fixed a typo in range/radius preventing close events from showing up -## 1.1.0-beta.4 - 17-03-2021 +## 1.1.0-beta.4 - 2021-03-17 ### Fixed @@ -301,13 +472,13 @@ This version introduces a new way to install and host Mobilizon : Elixir releas - Fixed location field not showing in preferences if setting not already set - Fixed lasts events published order on the homepage -## 1.1.0-beta.3 - 16-03-2021 +## 1.1.0-beta.3 - 2021-03-16 ### Fixed - Handle ActivityPub Fetcher returning text that's not JSON - Fix accessing a group profile when not a member -## 1.1.0-beta.2 - 16-03-2021 +## 1.1.0-beta.2 - 2021-03-16 ### Fixed - Fixed geospatial configuration only being evaluated at compile-time, not at runtime @@ -315,7 +486,7 @@ This version introduces a new way to install and host Mobilizon : Elixir releas ### Translations - Slovenian -## 1.1.0-beta.1 - 10-03-2021 +## 1.1.0-beta.1 - 2021-03-10 This version introduces a new way to install and host Mobilizon : Elixir releases. This is the new default way of installing Mobilizon. Please read [UPGRADE.md](./UPGRADE.md#upgrading-from-10-to-11) for details on how to migrate to Elixir binary releases or stay on source install. @@ -371,7 +542,7 @@ This version introduces a new way to install and host Mobilizon : Elixir releas - Spanish - Russian -## 1.0.7 - 27-02-2021 +## 1.0.7 - 2021-02-27 ### Fixed @@ -381,7 +552,7 @@ This version introduces a new way to install and host Mobilizon : Elixir releas - Fixed search form display - Fixed wrong year in CHANGELOG.md -## 1.0.6 - 04-02-2021 +## 1.0.6 - 2021-02-04 ### Added @@ -393,13 +564,13 @@ This version introduces a new way to install and host Mobilizon : Elixir releas - Fixed sending events & posts to group followers - Fixed redirection after deleting an event -## 1.0.5 - 27-01-2021 +## 1.0.5 - 2021-01-27 ### Fixed - Fixed duplicate entries in search with empty search query -## 1.0.4 - 26-01-2021 +## 1.0.4 - 2021-02-26 ### Added @@ -446,7 +617,7 @@ This version introduces a new way to install and host Mobilizon : Elixir releas - Spanish - Swedish -## 1.0.3 - 18-12-2020 +## 1.0.3 - 2020-12-18 **This release adds new migrations, be sure to run them before restarting Mobilizon** diff --git a/js/apollo.config.js b/apollo.config.js similarity index 64% rename from js/apollo.config.js rename to apollo.config.js index ed909b74a..23853968f 100644 --- a/js/apollo.config.js +++ b/apollo.config.js @@ -4,9 +4,9 @@ module.exports = { service: { name: "Mobilizon", // URL to the GraphQL API - url: "http://localhost:4000/api", + localSchemaFile: "./schema.graphql", }, // Files processed by the extension - includes: ["src/**/*.vue", "src/**/*.js"], + includes: ["js/src/**/*.vue", "js/src/**/*.js"], }, }; diff --git a/config/config.exs b/config/config.exs index cc511bef3..2924abe68 100644 --- a/config/config.exs +++ b/config/config.exs @@ -44,9 +44,6 @@ config :mobilizon, :events, creation: true # Configures the endpoint config :mobilizon, Mobilizon.Web.Endpoint, - http: [ - transport_options: [socket_opts: [:inet6]] - ], url: [ host: "mobilizon.local", scheme: "https" @@ -69,6 +66,7 @@ config :mobilizon, Mobilizon.Web.Upload, uploader: Mobilizon.Web.Upload.Uploader.Local, filters: [ Mobilizon.Web.Upload.Filter.Dedupe, + Mobilizon.Web.Upload.Filter.AnalyzeMetadata, Mobilizon.Web.Upload.Filter.Optimize ], allow_list_mime_types: ["image/gif", "image/jpeg", "image/png", "image/webp"], @@ -115,7 +113,7 @@ config :mobilizon, Mobilizon.Web.Email.Mailer, # Configures Elixir's Logger config :logger, :console, - backends: [:console, Sentry.LoggerBackend], + backends: [:console], format: "$time $metadata[$level] $message\n", metadata: [:request_id] @@ -123,14 +121,19 @@ config :logger, Sentry.LoggerBackend, level: :warn, capture_log_messages: true -config :mobilizon, Mobilizon.Web.Auth.Guardian, issuer: "mobilizon" +config :mobilizon, Mobilizon.Web.Auth.Guardian, + issuer: "mobilizon", + token_ttl: %{ + "access" => {15, :minutes}, + "refresh" => {60, :days} + } config :guardian, Guardian.DB, repo: Mobilizon.Storage.Repo, # default schema_name: "guardian_tokens", # store all token types if not set - # token_types: ["refresh_token"], + token_types: ["refresh"], # default: 60 minutes sweep_interval: 60 @@ -170,6 +173,9 @@ config :phoenix, :format_encoders, json: Jason, "activity-json": Jason config :phoenix, :json_library, Jason config :phoenix, :filter_parameters, ["password", "token"] +config :absinthe, schema: Mobilizon.GraphQL.Schema +config :absinthe, Absinthe.Logger, filter_variables: ["token", "password", "secret"] + config :ex_cldr, default_locale: "en", default_backend: Mobilizon.Cldr @@ -265,15 +271,15 @@ config :mobilizon, :anonymous, config :mobilizon, Oban, repo: Mobilizon.Storage.Repo, log: false, - queues: [default: 10, search: 5, mailers: 10, background: 5, activity: 5], + queues: [default: 10, search: 5, mailers: 10, background: 5, activity: 5, notifications: 5], plugins: [ {Oban.Plugins.Cron, crontab: [ {"@hourly", Mobilizon.Service.Workers.BuildSiteMap, queue: :background}, {"17 4 * * *", Mobilizon.Service.Workers.RefreshGroups, queue: :background}, - # To be activated in Mobilizon 1.2 - # {"@hourly", Mobilizon.Service.Workers.CleanOrphanMediaWorker, queue: :background}, + {"@hourly", Mobilizon.Service.Workers.CleanOrphanMediaWorker, queue: :background}, {"@hourly", Mobilizon.Service.Workers.CleanUnconfirmedUsersWorker, queue: :background}, + {"@hourly", Mobilizon.Service.Workers.SendActivityRecapWorker, queue: :notifications}, {"@daily", Mobilizon.Service.Workers.CleanOldActivityWorker, queue: :background} ]}, {Oban.Plugins.Pruner, max_age: 300} @@ -298,6 +304,16 @@ config :mobilizon, :external_resource_providers, %{ "https://docs.google.com/spreadsheets/" => :google_spreadsheets } +config :mobilizon, Mobilizon.Service.Notifier, + notifiers: [ + Mobilizon.Service.Notifier.Email, + Mobilizon.Service.Notifier.Push + ] + +config :mobilizon, Mobilizon.Service.Notifier.Email, enabled: true + +config :mobilizon, Mobilizon.Service.Notifier.Push, enabled: true + # Import environment specific config. This must remain at the bottom # of this file so it overrides the configuration defined above. import_config "#{config_env()}.exs" diff --git a/config/dev.exs b/config/dev.exs index a932818ac..23e63beb0 100644 --- a/config/dev.exs +++ b/config/dev.exs @@ -24,7 +24,8 @@ config :mobilizon, Mobilizon.Web.Endpoint, "node_modules/webpack/bin/webpack.js", "--mode", "development", - "--watch-stdin", + "--watch", + "--watch-options-stdin", "--config", "node_modules/@vue/cli-service/webpack.config.js", cd: Path.expand("../js", __DIR__) diff --git a/config/prod.exs b/config/prod.exs index 198148c1b..382d36559 100644 --- a/config/prod.exs +++ b/config/prod.exs @@ -43,9 +43,6 @@ cond do File.exists?("./config/#{System.get_env("INSTANCE_CONFIG")}") -> import_config System.get_env("INSTANCE_CONFIG") - File.exists?("./config/prod.secret.exs") -> - import_config "prod.secret.exs" - true -> :ok end diff --git a/docker/production/Dockerfile b/docker/production/Dockerfile index 1f9155a4c..46447aaf8 100644 --- a/docker/production/Dockerfile +++ b/docker/production/Dockerfile @@ -2,13 +2,13 @@ FROM node:16-alpine as assets RUN apk add --no-cache python3 build-base libwebp-tools bash imagemagick ncurses - +WORKDIR /build COPY js . RUN yarn install \ && yarn run build # Then, build the application binary -FROM elixir:1.11-alpine AS builder +FROM elixir:1.12-alpine AS builder RUN apk add --no-cache build-base git cmake @@ -45,7 +45,7 @@ LABEL org.opencontainers.image.title="mobilizon" \ org.opencontainers.image.revision=$VCS_REF \ org.opencontainers.image.created=$BUILD_DATE -RUN apk add --no-cache openssl ncurses-libs file postgresql-client +RUN apk add --no-cache openssl ncurses-libs file postgresql-client libgcc libstdc++ imagemagick RUN mkdir -p /app/uploads && chown nobody:nobody /app/uploads RUN mkdir -p /etc/mobilizon && chown nobody:nobody /etc/mobilizon diff --git a/docker/tests/Dockerfile b/docker/tests/Dockerfile index 73f0d6c8c..3a6673b1a 100644 --- a/docker/tests/Dockerfile +++ b/docker/tests/Dockerfile @@ -1,7 +1,7 @@ FROM elixir:latest LABEL maintainer="Thomas Citharel " -ENV REFRESHED_AT=2021-05-19 +ENV REFRESHED_AT=2021-06-07 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 RUN curl -sL https://deb.nodesource.com/setup_16.x | bash && apt-get install nodejs -yq RUN npm install -g yarn wait-on diff --git a/docker/tests/Dockerfile-legacy b/docker/tests/Dockerfile-legacy new file mode 100644 index 000000000..a90806409 --- /dev/null +++ b/docker/tests/Dockerfile-legacy @@ -0,0 +1,28 @@ +# We build Elixir manually to have the oldest acceptable version of OTP +FROM erlang:21 +LABEL maintainer="Thomas Citharel " + +# elixir expects utf8. +ENV ELIXIR_VERSION="v1.11.4" \ + LANG=C.UTF-8 + +RUN set -xe \ + && ELIXIR_DOWNLOAD_URL="https://github.com/elixir-lang/elixir/archive/${ELIXIR_VERSION}.tar.gz" \ + && ELIXIR_DOWNLOAD_SHA256="85c7118a0db6007507313db5bddf370216d9394ed7911fe80f21e2fbf7f54d29" \ + && curl -fSL -o elixir-src.tar.gz $ELIXIR_DOWNLOAD_URL \ + && echo "$ELIXIR_DOWNLOAD_SHA256 elixir-src.tar.gz" | sha256sum -c - \ + && mkdir -p /usr/local/src/elixir \ + && tar -xzC /usr/local/src/elixir --strip-components=1 -f elixir-src.tar.gz \ + && rm elixir-src.tar.gz \ + && cd /usr/local/src/elixir \ + && make install clean + +CMD ["iex"] + +ENV REFRESHED_AT=2021-06-07 +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 +RUN curl -sL https://deb.nodesource.com/setup_12.x | bash && apt-get install nodejs -yq +RUN npm install -g yarn wait-on +RUN apt-get clean && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* +RUN mix local.hex --force && mix local.rebar --force +RUN curl https://dbip.mirror.framasoft.org/files/dbip-city-lite-latest.mmdb --output GeoLite2-City.mmdb -s && mkdir -p /usr/share/GeoIP && mv GeoLite2-City.mmdb /usr/share/GeoIP/ diff --git a/js/.browserslistrc b/js/.browserslistrc index 214388fe4..9e4706310 100644 --- a/js/.browserslistrc +++ b/js/.browserslistrc @@ -1,3 +1 @@ -> 1% -last 2 versions -not dead +> 0.25% and last 2 versions, not dead, not ie 11, not op_mini all, Firefox ESR \ No newline at end of file diff --git a/js/.eslintrc.js b/js/.eslintrc.js index a5dd51f78..7ee28a445 100644 --- a/js/.eslintrc.js +++ b/js/.eslintrc.js @@ -8,8 +8,8 @@ module.exports = { extends: [ "plugin:vue/essential", "eslint:recommended", - "@vue/prettier", "@vue/typescript/recommended", + "@vue/prettier", "@vue/prettier/@typescript-eslint", ], diff --git a/js/package.json b/js/package.json index b8dc795ed..bbecc39af 100644 --- a/js/package.json +++ b/js/package.json @@ -1,6 +1,6 @@ { "name": "mobilizon", - "version": "1.1.4", + "version": "1.2.3", "private": true, "scripts": { "serve": "vue-cli-service serve", @@ -8,12 +8,13 @@ "test:unit": "LANG=en_US.UTF-8 LANGUAGE=en_US:en LC_ALL=en_US.UTF-8 vue-cli-service test:unit", "test:e2e": "vue-cli-service test:e2e", "lint": "vue-cli-service lint", - "build:assets": "vue-cli-service build --modern", + "build:assets": "vue-cli-service build", "build:pictures": "bash ./scripts/build/pictures.sh" }, "dependencies": { "@absinthe/socket": "^0.2.1", "@absinthe/socket-apollo-link": "^0.2.1", + "@apollo/client": "^3.3.16", "@mdi/font": "^5.0.45", "@tiptap/core": "^2.0.0-beta.41", "@tiptap/extension-blockquote": "^2.0.0-beta.6", @@ -28,15 +29,9 @@ "@tiptap/extension-underline": "^2.0.0-beta.7", "@tiptap/starter-kit": "^2.0.0-beta.37", "@tiptap/vue-2": "^2.0.0-beta.21", + "@vue/apollo-option": "^4.0.0-alpha.11", "apollo-absinthe-upload-link": "^1.5.0", - "apollo-cache": "^1.3.5", - "apollo-cache-inmemory": "^1.6.6", - "apollo-client": "^2.6.10", - "apollo-link": "^1.2.14", - "apollo-link-error": "^1.1.13", - "apollo-link-http": "^1.5.17", - "apollo-link-ws": "^1.0.19", - "apollo-utilities": "^1.3.2", + "blurhash": "^1.1.3", "buefy": "^0.9.0", "bulma-divider": "^0.2.0", "core-js": "^3.6.4", @@ -44,18 +39,18 @@ "graphql": "^15.0.0", "graphql-tag": "^2.10.3", "intersection-observer": "^0.12.0", + "jwt-decode": "^3.1.2", "leaflet": "^1.4.0", - "leaflet.locatecontrol": "^0.73.0", + "leaflet.locatecontrol": "^0.74.0", "lodash": "^4.17.11", "ngeohash": "^0.6.3", "p-debounce": "^4.0.0", "phoenix": "^1.4.11", - "register-service-worker": "^1.7.1", + "register-service-worker": "^1.7.2", "tippy.js": "^6.2.3", "unfetch": "^4.2.0", "v-tooltip": "^2.1.3", "vue": "^2.6.11", - "vue-apollo": "^3.0.3", "vue-class-component": "^7.2.3", "vue-i18n": "^8.14.0", "vue-meta": "^2.3.1", @@ -75,39 +70,36 @@ "@types/prosemirror-model": "^1.7.2", "@types/prosemirror-state": "^1.2.4", "@types/prosemirror-view": "^1.11.4", - "@types/vuedraggable": "^2.23.0", "@typescript-eslint/eslint-plugin": "^4.18.0", "@typescript-eslint/parser": "^4.18.0", - "@vue/cli-plugin-babel": "~4.5.13", - "@vue/cli-plugin-e2e-cypress": "~4.5.13", - "@vue/cli-plugin-eslint": "~4.5.13", - "@vue/cli-plugin-pwa": "~4.5.13", - "@vue/cli-plugin-router": "~4.5.13", - "@vue/cli-plugin-typescript": "~4.5.13", - "@vue/cli-plugin-unit-jest": "~4.5.13", - "@vue/cli-service": "~4.5.13", + "@vue/cli-plugin-babel": "~5.0.0-beta.2", + "@vue/cli-plugin-e2e-cypress": "~5.0.0-beta.2", + "@vue/cli-plugin-eslint": "~5.0.0-beta.2", + "@vue/cli-plugin-pwa": "~5.0.0-beta.2", + "@vue/cli-plugin-router": "~5.0.0-beta.2", + "@vue/cli-plugin-typescript": "~5.0.0-beta.2", + "@vue/cli-plugin-unit-jest": "~5.0.0-beta.2", + "@vue/cli-service": "~5.0.0-beta.2", "@vue/eslint-config-prettier": "^6.0.0", "@vue/eslint-config-typescript": "^7.0.0", "@vue/test-utils": "^1.1.0", - "eslint": "^6.7.2", + "eslint": "^7.20.0", "eslint-plugin-cypress": "^2.10.3", "eslint-plugin-import": "^2.20.2", "eslint-plugin-prettier": "^3.3.1", - "eslint-plugin-vue": "^6.2.2", + "eslint-plugin-vue": "^7.6.0", "flush-promises": "^1.0.2", "jest-junit": "^12.0.0", - "mock-apollo-client": "^0.6", + "mock-apollo-client": "^1.1.0", "prettier": "^2.2.1", "prettier-eslint": "^12.0.0", - "sass": "^1.29.0", - "sass-loader": "^8.0.2", + "sass": "^1.34.1", + "sass-loader": "^12.0.0", + "ts-jest": "^26.5.3", "typescript": "~4.1.5", - "vue-cli-plugin-svg": "~0.1.3", "vue-i18n-extract": "^1.0.2", + "vue-jest": "^4.0.1", "vue-template-compiler": "^2.6.11", - "webpack-cli": "^3.3" - }, - "resolutions": { - "workbox-webpack-plugin": "5.1.3" + "webpack-cli": "^4.7.0" } } diff --git a/js/public/favicon.ico b/js/public/favicon.ico index df36fcfb7..1b76c9c2b 100644 Binary files a/js/public/favicon.ico and b/js/public/favicon.ico differ diff --git a/js/public/img/icons/android-chrome-192x192 (copie).png b/js/public/img/icons/android-chrome-192x192 (copie).png deleted file mode 100644 index 52399911b..000000000 Binary files a/js/public/img/icons/android-chrome-192x192 (copie).png and /dev/null differ diff --git a/js/public/img/icons/android-chrome-192x192.png b/js/public/img/icons/android-chrome-192x192.png index 52399911b..61ec17788 100644 Binary files a/js/public/img/icons/android-chrome-192x192.png and b/js/public/img/icons/android-chrome-192x192.png differ diff --git a/js/public/img/icons/android-chrome-512x512.png b/js/public/img/icons/android-chrome-512x512.png index 4a6182504..7f61d8b0c 100644 Binary files a/js/public/img/icons/android-chrome-512x512.png and b/js/public/img/icons/android-chrome-512x512.png differ diff --git a/js/public/img/icons/android-chrome-maskable-192x192.png b/js/public/img/icons/android-chrome-maskable-192x192.png new file mode 100644 index 000000000..10739386c Binary files /dev/null and b/js/public/img/icons/android-chrome-maskable-192x192.png differ diff --git a/js/public/img/icons/android-chrome-maskable-512x512.png b/js/public/img/icons/android-chrome-maskable-512x512.png index 52399911b..51d10c3a4 100644 Binary files a/js/public/img/icons/android-chrome-maskable-512x512.png and b/js/public/img/icons/android-chrome-maskable-512x512.png differ diff --git a/js/public/img/icons/apple-touch-icon-120x120.png b/js/public/img/icons/apple-touch-icon-120x120.png index 7cd046b20..0b79015fc 100644 Binary files a/js/public/img/icons/apple-touch-icon-120x120.png and b/js/public/img/icons/apple-touch-icon-120x120.png differ diff --git a/js/public/img/icons/apple-touch-icon-152x152.png b/js/public/img/icons/apple-touch-icon-152x152.png index 47780197d..db0c5ac09 100644 Binary files a/js/public/img/icons/apple-touch-icon-152x152.png and b/js/public/img/icons/apple-touch-icon-152x152.png differ diff --git a/js/public/img/icons/apple-touch-icon-180x180.png b/js/public/img/icons/apple-touch-icon-180x180.png index 94f32ff70..e59afba6d 100644 Binary files a/js/public/img/icons/apple-touch-icon-180x180.png and b/js/public/img/icons/apple-touch-icon-180x180.png differ diff --git a/js/public/img/icons/apple-touch-icon-60x60.png b/js/public/img/icons/apple-touch-icon-60x60.png index 9e60f86aa..ebf8c0ee7 100644 Binary files a/js/public/img/icons/apple-touch-icon-60x60.png and b/js/public/img/icons/apple-touch-icon-60x60.png differ diff --git a/js/public/img/icons/apple-touch-icon-76x76.png b/js/public/img/icons/apple-touch-icon-76x76.png index 49d1d3190..5dba221ee 100644 Binary files a/js/public/img/icons/apple-touch-icon-76x76.png and b/js/public/img/icons/apple-touch-icon-76x76.png differ diff --git a/js/public/img/icons/apple-touch-icon.png b/js/public/img/icons/apple-touch-icon.png index 94f32ff70..214d8df67 100644 Binary files a/js/public/img/icons/apple-touch-icon.png and b/js/public/img/icons/apple-touch-icon.png differ diff --git a/js/public/img/icons/badge-128x128.png b/js/public/img/icons/badge-128x128.png new file mode 100644 index 000000000..41328edec Binary files /dev/null and b/js/public/img/icons/badge-128x128.png differ diff --git a/js/public/img/icons/favicon-16x16.png b/js/public/img/icons/favicon-16x16.png index ba0fe61d0..f6933a3e6 100644 Binary files a/js/public/img/icons/favicon-16x16.png and b/js/public/img/icons/favicon-16x16.png differ diff --git a/js/public/img/icons/favicon-32x32.png b/js/public/img/icons/favicon-32x32.png index ba0fe61d0..0f07a6a04 100644 Binary files a/js/public/img/icons/favicon-32x32.png and b/js/public/img/icons/favicon-32x32.png differ diff --git a/js/public/img/icons/favicon.svg b/js/public/img/icons/favicon.svg new file mode 100644 index 000000000..f83b3666e --- /dev/null +++ b/js/public/img/icons/favicon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/js/public/img/icons/icon-128x128.png b/js/public/img/icons/icon-128x128.png deleted file mode 100644 index 8813cc5f9..000000000 Binary files a/js/public/img/icons/icon-128x128.png and /dev/null differ diff --git a/js/public/img/icons/icon-144x144.png b/js/public/img/icons/icon-144x144.png new file mode 100644 index 000000000..675af6d36 Binary files /dev/null and b/js/public/img/icons/icon-144x144.png differ diff --git a/js/public/img/icons/icon-168x168.png b/js/public/img/icons/icon-168x168.png new file mode 100644 index 000000000..c28edb93b Binary files /dev/null and b/js/public/img/icons/icon-168x168.png differ diff --git a/js/public/img/icons/icon-256x256.png b/js/public/img/icons/icon-256x256.png new file mode 100644 index 000000000..473e081d7 Binary files /dev/null and b/js/public/img/icons/icon-256x256.png differ diff --git a/js/public/img/icons/icon-384x384.png b/js/public/img/icons/icon-384x384.png deleted file mode 100644 index 8813cc5f9..000000000 Binary files a/js/public/img/icons/icon-384x384.png and /dev/null differ diff --git a/js/public/img/icons/icon-48x48.png b/js/public/img/icons/icon-48x48.png new file mode 100644 index 000000000..4d03fa090 Binary files /dev/null and b/js/public/img/icons/icon-48x48.png differ diff --git a/js/public/img/icons/icon-512x512.png b/js/public/img/icons/icon-512x512.png deleted file mode 100644 index 8813cc5f9..000000000 Binary files a/js/public/img/icons/icon-512x512.png and /dev/null differ diff --git a/js/public/img/icons/icon-72x72.png b/js/public/img/icons/icon-72x72.png new file mode 100644 index 000000000..5f30a5c98 Binary files /dev/null and b/js/public/img/icons/icon-72x72.png differ diff --git a/js/public/img/icons/icon-96x96.png b/js/public/img/icons/icon-96x96.png index 8813cc5f9..1d00aebc4 100644 Binary files a/js/public/img/icons/icon-96x96.png and b/js/public/img/icons/icon-96x96.png differ diff --git a/js/public/img/icons/msapplication-icon-144x144.png b/js/public/img/icons/msapplication-icon-144x144.png index 8813cc5f9..ce68fa205 100644 Binary files a/js/public/img/icons/msapplication-icon-144x144.png and b/js/public/img/icons/msapplication-icon-144x144.png differ diff --git a/js/public/img/icons/mstile-150x150.png b/js/public/img/icons/mstile-150x150.png index 3b37a43ae..b0514194d 100644 Binary files a/js/public/img/icons/mstile-150x150.png and b/js/public/img/icons/mstile-150x150.png differ diff --git a/js/public/img/icons/safari-pinned-tab.svg b/js/public/img/icons/safari-pinned-tab.svg index 732afd8eb..f83b3666e 100644 --- a/js/public/img/icons/safari-pinned-tab.svg +++ b/js/public/img/icons/safari-pinned-tab.svg @@ -1,149 +1 @@ - - - - -Created by potrace 1.11, written by Peter Selinger 2001-2013 - - - - - + \ No newline at end of file diff --git a/js/public/img/pics/event_creation-1024w.jpg b/js/public/img/pics/event_creation-1024w.jpg new file mode 100644 index 000000000..57c879268 Binary files /dev/null and b/js/public/img/pics/event_creation-1024w.jpg differ diff --git a/js/public/img/pics/event_creation-1024w.webp b/js/public/img/pics/event_creation-1024w.webp new file mode 100644 index 000000000..887137e16 Binary files /dev/null and b/js/public/img/pics/event_creation-1024w.webp differ diff --git a/js/public/img/pics/event_creation-480w.jpg b/js/public/img/pics/event_creation-480w.jpg new file mode 100644 index 000000000..004c872c0 Binary files /dev/null and b/js/public/img/pics/event_creation-480w.jpg differ diff --git a/js/public/img/pics/event_creation-480w.webp b/js/public/img/pics/event_creation-480w.webp new file mode 100644 index 000000000..7b6c6fda8 Binary files /dev/null and b/js/public/img/pics/event_creation-480w.webp differ diff --git a/js/public/img/pics/group-1024w.jpg b/js/public/img/pics/group-1024w.jpg new file mode 100644 index 000000000..5e7e9c329 Binary files /dev/null and b/js/public/img/pics/group-1024w.jpg differ diff --git a/js/public/img/pics/group-1024w.webp b/js/public/img/pics/group-1024w.webp new file mode 100644 index 000000000..3e9463f90 Binary files /dev/null and b/js/public/img/pics/group-1024w.webp differ diff --git a/js/public/img/pics/group-480w.jpg b/js/public/img/pics/group-480w.jpg new file mode 100644 index 000000000..b003dd502 Binary files /dev/null and b/js/public/img/pics/group-480w.jpg differ diff --git a/js/public/img/pics/group-480w.webp b/js/public/img/pics/group-480w.webp new file mode 100644 index 000000000..8e8380625 Binary files /dev/null and b/js/public/img/pics/group-480w.webp differ diff --git a/js/public/img/pics/homepage_background-1024w.png b/js/public/img/pics/homepage_background-1024w.png new file mode 100644 index 000000000..bc15d185e Binary files /dev/null and b/js/public/img/pics/homepage_background-1024w.png differ diff --git a/js/public/img/pics/homepage_background-1024w.webp b/js/public/img/pics/homepage_background-1024w.webp new file mode 100644 index 000000000..9f63b55e5 Binary files /dev/null and b/js/public/img/pics/homepage_background-1024w.webp differ diff --git a/js/src/App.vue b/js/src/App.vue index b8c29a98a..f9818269d 100644 --- a/js/src/App.vue +++ b/js/src/App.vue @@ -50,6 +50,8 @@ import { initializeCurrentActor } from "./utils/auth"; import { CONFIG } from "./graphql/config"; import { IConfig } from "./types/config.model"; import { ICurrentUser } from "./types/current-user.model"; +import jwt_decode, { JwtPayload } from "jwt-decode"; +import { refreshAccessToken } from "./apollo/utils"; @Component({ apollo: { @@ -63,6 +65,11 @@ import { ICurrentUser } from "./types/current-user.model"; import(/* webpackChunkName: "editor" */ "./components/Error.vue"), "mobilizon-footer": Footer, }, + metaInfo() { + return { + titleTemplate: "%s | Mobilizon", + }; + }, }) export default class App extends Vue { config!: IConfig; @@ -71,6 +78,10 @@ export default class App extends Vue { error: Error | null = null; + online = true; + + interval: number | undefined = undefined; + async created(): Promise { if (await this.initializeCurrentUser()) { await initializeCurrentActor(this.$apollo.provider.defaultClient); @@ -100,6 +111,92 @@ export default class App extends Vue { } return false; } + + mounted(): void { + this.online = window.navigator.onLine; + window.addEventListener("offline", () => { + this.online = false; + this.showOfflineNetworkWarning(); + console.debug("offline"); + }); + window.addEventListener("online", () => { + this.online = true; + console.debug("online"); + }); + document.addEventListener("refreshApp", (event: Event) => { + this.$buefy.snackbar.open({ + queue: false, + indefinite: true, + type: "is-secondary", + actionText: this.$t("Update app") as string, + cancelText: this.$t("Ignore") as string, + message: this.$t("A new version is available.") as string, + onAction: async () => { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + const detail = event.detail; + const registration = detail as ServiceWorkerRegistration; + try { + await this.refreshApp(registration); + window.location.reload(); + } catch (err) { + console.error(err); + this.$notifier.error( + this.$t( + "An error has occured while refreshing the page." + ) as string + ); + } + }, + }); + }); + + this.interval = setInterval(async () => { + const accessToken = localStorage.getItem(AUTH_ACCESS_TOKEN); + if (accessToken) { + const token = jwt_decode(accessToken); + if ( + token?.exp !== undefined && + new Date(token.exp * 1000 - 60000) < new Date() + ) { + refreshAccessToken(this.$apollo.getClient()); + } + } + }, 60000); + } + + private async refreshApp( + registration: ServiceWorkerRegistration + ): Promise { + const worker = registration.waiting; + if (!worker) { + return Promise.resolve(); + } + console.debug("Doing worker.skipWaiting()."); + return new Promise((resolve, reject) => { + const channel = new MessageChannel(); + + channel.port1.onmessage = (event) => { + console.debug("Done worker.skipWaiting()."); + if (event.data.error) { + reject(event.data); + } else { + resolve(event.data); + } + }; + console.debug("calling skip waiting"); + worker?.postMessage({ type: "skip-waiting" }, [channel.port2]); + }); + } + + showOfflineNetworkWarning(): void { + this.$notifier.error(this.$t("You are offline") as string); + } + + unmounted(): void { + clearInterval(this.interval); + this.interval = undefined; + } } diff --git a/js/src/apollo/user.ts b/js/src/apollo/user.ts index f063ad2ea..2eb20ed6e 100644 --- a/js/src/apollo/user.ts +++ b/js/src/apollo/user.ts @@ -1,12 +1,14 @@ +import { CURRENT_ACTOR_CLIENT } from "@/graphql/actor"; +import { CURRENT_USER_CLIENT } from "@/graphql/user"; import { ICurrentUserRole } from "@/types/enums"; -import { ApolloCache } from "apollo-cache"; -import { NormalizedCacheObject } from "apollo-cache-inmemory"; -import { Resolvers } from "apollo-client/core/types"; +import { ApolloCache, NormalizedCacheObject } from "@apollo/client/cache"; +import { Resolvers } from "@apollo/client/core/types"; export default function buildCurrentUserResolver( cache: ApolloCache ): Resolvers { - cache.writeData({ + cache.writeQuery({ + query: CURRENT_USER_CLIENT, data: { currentUser: { __typename: "CurrentUser", @@ -15,6 +17,12 @@ export default function buildCurrentUserResolver( isLoggedIn: false, role: ICurrentUserRole.USER, }, + }, + }); + + cache.writeQuery({ + query: CURRENT_ACTOR_CLIENT, + data: { currentActor: { __typename: "CurrentActor", id: null, @@ -47,7 +55,7 @@ export default function buildCurrentUserResolver( }, }; - localCache.writeData({ data }); + localCache.writeQuery({ data, query: CURRENT_USER_CLIENT }); }, updateCurrentActor: ( _: any, @@ -74,7 +82,7 @@ export default function buildCurrentUserResolver( }, }; - localCache.writeData({ data }); + localCache.writeQuery({ data, query: CURRENT_ACTOR_CLIENT }); }, }, }; diff --git a/js/src/apollo/utils.ts b/js/src/apollo/utils.ts index 92cdcd585..917936c3a 100644 --- a/js/src/apollo/utils.ts +++ b/js/src/apollo/utils.ts @@ -1,16 +1,100 @@ -import { - IntrospectionFragmentMatcher, - NormalizedCacheObject, -} from "apollo-cache-inmemory"; import { AUTH_ACCESS_TOKEN, AUTH_REFRESH_TOKEN } from "@/constants"; import { REFRESH_TOKEN } from "@/graphql/auth"; +import { IFollower } from "@/types/actor/follower.model"; +import { IParticipant } from "@/types/participant.model"; +import { Paginate } from "@/types/paginate"; import { saveTokenData } from "@/utils/auth"; -import { ApolloClient } from "apollo-client"; +import { + ApolloClient, + FieldPolicy, + NormalizedCacheObject, + Reference, + TypePolicies, +} from "@apollo/client/core"; import introspectionQueryResultData from "../../fragmentTypes.json"; +import { IMember } from "@/types/actor/member.model"; +import { IComment } from "@/types/comment.model"; +import { IEvent } from "@/types/event.model"; +import { IActivity } from "@/types/activity.model"; +import uniqBy from "lodash/uniqBy"; -export const fragmentMatcher = new IntrospectionFragmentMatcher({ - introspectionQueryResultData, -}); +type possibleTypes = { name: string }; +type schemaType = { + kind: string; + name: string; + possibleTypes: possibleTypes[]; +}; + +// eslint-disable-next-line no-underscore-dangle +const types = introspectionQueryResultData.__schema.types as schemaType[]; +export const possibleTypes = types.reduce((acc, type) => { + if (type.kind === "INTERFACE") { + acc[type.name] = type.possibleTypes.map(({ name }) => name); + } + return acc; +}, {} as Record); + +const replaceMergePolicy = ( + _existing: TExisting, + incoming: TIncoming +): TIncoming => incoming; + +export const typePolicies: TypePolicies = { + Discussion: { + fields: { + comments: paginatedLimitPagination(), + }, + }, + Group: { + fields: { + organizedEvents: paginatedLimitPagination([ + "afterDatetime", + "beforeDatetime", + ]), + activity: paginatedLimitPagination(["type", "author"]), + }, + }, + Person: { + fields: { + organizedEvents: pageLimitPagination(), + participations: paginatedLimitPagination(["eventId"]), + memberships: paginatedLimitPagination(["group"]), + }, + }, + Event: { + fields: { + participants: paginatedLimitPagination(["roles"]), + comments: pageLimitPagination(), + relatedEvents: pageLimitPagination(), + options: { merge: replaceMergePolicy }, + participantStats: { merge: replaceMergePolicy }, + }, + }, + RootQueryType: { + fields: { + relayFollowers: paginatedLimitPagination(), + relayFollowings: paginatedLimitPagination([ + "orderBy", + "direction", + ]), + events: paginatedLimitPagination(), + groups: paginatedLimitPagination([ + "preferredUsername", + "name", + "domain", + "local", + "suspended", + ]), + persons: paginatedLimitPagination([ + "preferredUsername", + "name", + "domain", + "local", + "suspended", + ]), + }, + }, +}; export async function refreshAccessToken( apolloClient: ApolloClient @@ -37,3 +121,66 @@ export async function refreshAccessToken( return false; } } + +type KeyArgs = FieldPolicy["keyArgs"]; + +export function pageLimitPagination( + keyArgs: KeyArgs = false +): FieldPolicy { + return { + keyArgs, + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + merge(existing, incoming, { args }) { + if (!incoming) return existing; + if (!existing) return incoming; // existing will be empty the first time + + return doMerge(existing as Array, incoming as Array, args); + }, + }; +} + +export function paginatedLimitPagination>( + keyArgs: KeyArgs = false +): FieldPolicy> { + return { + keyArgs, + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + merge(existing, incoming, { args }) { + if (!incoming) return existing; + if (!existing) return incoming; // existing will be empty the first time + + return { + total: incoming.total, + elements: doMerge(existing.elements, incoming.elements, args), + }; + }, + }; +} + +function doMerge( + existing: Array, + incoming: Array, + args: Record | null +): Array { + const merged = existing && Array.isArray(existing) ? existing.slice(0) : []; + let res; + if (args) { + // Assume an page of 1 if args.page omitted. + const { page = 1, limit = 10 } = args; + for (let i = 0; i < incoming.length; ++i) { + merged[(page - 1) * limit + i] = incoming[i]; + } + res = merged; + } else { + // It's unusual (probably a mistake) for a paginated field not + // to receive any arguments, so you might prefer to throw an + // exception here, instead of recovering by appending incoming + // onto the existing array. + res = [...merged, ...incoming]; + // eslint-disable-next-line no-underscore-dangle + res = uniqBy(res, (elem: any) => elem.__ref); + } + return res; +} diff --git a/js/src/assets/diaspora-icon.svg b/js/src/assets/diaspora-icon.svg deleted file mode 100644 index 7a5b5cf3a..000000000 --- a/js/src/assets/diaspora-icon.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/js/src/assets/logo.png b/js/src/assets/logo.png deleted file mode 100644 index f3d2503fc..000000000 Binary files a/js/src/assets/logo.png and /dev/null differ diff --git a/js/src/common.scss b/js/src/common.scss index d6f44d29f..f199d5c4a 100644 --- a/js/src/common.scss +++ b/js/src/common.scss @@ -19,14 +19,6 @@ margin: 15px auto 30px; } -main > .container { - background: $whitest; - min-height: 70vh; -} -.step-content { - height: auto; -} - a.out, .content a, .ProseMirror a { @@ -35,7 +27,12 @@ a.out, text-decoration-thickness: 2px; } +.step-content { + height: auto; +} + main { + > section > .columns { min-height: 50vh; } @@ -44,6 +41,10 @@ main { min-height: 80vh; } } + > .container { + background: $whitest; + min-height: 70vh; + } > #homepage { background: $whitest; #featured_events { @@ -73,7 +74,6 @@ $color-black: #000; .mention { background: rgba($color-black, 0.1); - color: rgba($color-black, 0.6); font-size: 0.9rem; font-weight: bold; border-radius: 5px; @@ -108,6 +108,8 @@ body { background: $body-background-color; font-family: BlinkMacSystemFont, Roboto, Oxygen, Ubuntu, Cantarell, "Segoe UI", "Fira Sans", "Droid Sans", "Helvetica Neue", Helvetica, Arial, sans-serif; + + overflow-x: hidden; } #mobilizon { diff --git a/js/src/components/Account/ActorAutoComplete.vue b/js/src/components/Account/ActorAutoComplete.vue index a7f14334e..50a77e1b4 100644 --- a/js/src/components/Account/ActorAutoComplete.vue +++ b/js/src/components/Account/ActorAutoComplete.vue @@ -45,7 +45,7 @@ + diff --git a/js/src/components/Activity/DiscussionActivityItem.vue b/js/src/components/Activity/DiscussionActivityItem.vue index c05c145fd..036ef128f 100644 --- a/js/src/components/Activity/DiscussionActivityItem.vue +++ b/js/src/components/Activity/DiscussionActivityItem.vue @@ -41,7 +41,7 @@ > - {{ + {{ activity.insertedAt | formatTimeString }} diff --git a/js/src/components/Activity/EventActivityItem.vue b/js/src/components/Activity/EventActivityItem.vue index 573ee11a7..8fd38305a 100644 --- a/js/src/components/Activity/EventActivityItem.vue +++ b/js/src/components/Activity/EventActivityItem.vue @@ -27,7 +27,7 @@ > - {{ + {{ activity.insertedAt | formatTimeString }} diff --git a/js/src/components/Activity/GroupActivityItem.vue b/js/src/components/Activity/GroupActivityItem.vue index df90a8692..ba8d0d19c 100644 --- a/js/src/components/Activity/GroupActivityItem.vue +++ b/js/src/components/Activity/GroupActivityItem.vue @@ -8,7 +8,9 @@ slot="group" :to="{ name: RouteName.GROUP, - params: { preferredUsername: usernameWithDomain(activity.object) }, + params: { + preferredUsername: subjectParams.group_federated_username, + }, }" >{{ subjectParams.group_name }} @@ -32,7 +34,7 @@ v-for="detail in details" :key="detail" tag="p" - class="has-text-grey" + class="has-text-grey-dark" > - {{ + {{ activity.insertedAt | formatTimeString }} diff --git a/js/src/components/Activity/MemberActivityItem.vue b/js/src/components/Activity/MemberActivityItem.vue index 1916a2ff9..3434c4dfc 100644 --- a/js/src/components/Activity/MemberActivityItem.vue +++ b/js/src/components/Activity/MemberActivityItem.vue @@ -18,7 +18,7 @@ > {{ - subjectParams.member_preferred_username + subjectParams.member_actor_federated_username }} - {{ + {{ activity.insertedAt | formatTimeString }} @@ -83,6 +83,8 @@ export default class MemberActivityItem extends mixins(ActivityMixin) { return "You added the member {member}."; } return "{profile} added the member {member}."; + case ActivityMemberSubject.MEMBER_JOINED: + return "{member} joined the group."; case ActivityMemberSubject.MEMBER_UPDATED: if (this.subjectParams.member_role && this.subjectParams.old_role) { return this.roleUpdate; diff --git a/js/src/components/Activity/PostActivityItem.vue b/js/src/components/Activity/PostActivityItem.vue index a2a5a650c..07597f7cb 100644 --- a/js/src/components/Activity/PostActivityItem.vue +++ b/js/src/components/Activity/PostActivityItem.vue @@ -27,7 +27,7 @@ > - {{ + {{ activity.insertedAt | formatTimeString }} diff --git a/js/src/components/Activity/ResourceActivityItem.vue b/js/src/components/Activity/ResourceActivityItem.vue index d9de2c24b..f546571b5 100644 --- a/js/src/components/Activity/ResourceActivityItem.vue +++ b/js/src/components/Activity/ResourceActivityItem.vue @@ -37,7 +37,7 @@ > - {{ + {{ activity.insertedAt | formatTimeString }} diff --git a/js/src/components/Admin/Followers.vue b/js/src/components/Admin/Followers.vue index f8472c042..80c7b43df 100644 --- a/js/src/components/Admin/Followers.vue +++ b/js/src/components/Admin/Followers.vue @@ -10,8 +10,13 @@ :show-detail-icon="false" paginated backend-pagination + :current-page.sync="page" + :aria-next-label="$t('Next page')" + :aria-previous-label="$t('Previous page')" + :aria-page-label="$t('Page')" + :aria-current-label="$t('Current page')" :total="relayFollowers.total" - :per-page="perPage" + :per-page="FOLLOWERS_PER_PAGE" @page-change="onFollowersPageChange" checkable checkbox-position="left" @@ -123,14 +128,33 @@ diff --git a/js/src/components/Admin/Followings.vue b/js/src/components/Admin/Followings.vue index 18a9fbb9f..46944e0cc 100644 --- a/js/src/components/Admin/Followings.vue +++ b/js/src/components/Admin/Followings.vue @@ -32,8 +32,13 @@ :show-detail-icon="false" paginated backend-pagination + :current-page.sync="page" + :aria-next-label="$t('Next page')" + :aria-previous-label="$t('Previous page')" + :aria-page-label="$t('Page')" + :aria-current-label="$t('Current page')" :total="relayFollowings.total" - :per-page="perPage" + :per-page="FOLLOWINGS_PER_PAGE" @page-change="onFollowingsPageChange" checkable checkbox-position="left" @@ -127,7 +132,7 @@ - {{ + {{ $t("You don't follow any instances yet.") }} @@ -139,8 +144,26 @@ import { formatDistanceToNow } from "date-fns"; import { ADD_RELAY, REMOVE_RELAY } from "../../graphql/admin"; import { IFollower } from "../../types/actor/follower.model"; import RelayMixin from "../../mixins/relay"; +import { RELAY_FOLLOWINGS } from "@/graphql/admin"; +import { Paginate } from "@/types/paginate"; +import RouteName from "@/router/name"; +import { ApolloCache, FetchResult, Reference } from "@apollo/client/core"; +import gql from "graphql-tag"; + +const FOLLOWINGS_PER_PAGE = 10; @Component({ + apollo: { + relayFollowings: { + query: RELAY_FOLLOWINGS, + variables() { + return { + page: this.page, + limit: FOLLOWINGS_PER_PAGE, + }; + }, + }, + }, metaInfo() { return { title: this.$t("Followings") as string, @@ -155,16 +178,81 @@ export default class Followings extends Mixins(RelayMixin) { formatDistanceToNow = formatDistanceToNow; + relayFollowings: Paginate = { elements: [], total: 0 }; + + FOLLOWINGS_PER_PAGE = FOLLOWINGS_PER_PAGE; + + checkedRows: IFollower[] = []; + + get page(): number { + return parseInt((this.$route.query.page as string) || "1", 10); + } + + set page(page: number) { + this.pushRouter(RouteName.RELAY_FOLLOWINGS, { + page: page.toString(), + }); + } + + async onFollowingsPageChange(page: number): Promise { + this.page = page; + try { + await this.$apollo.queries.relayFollowings.fetchMore({ + variables: { + page: this.page, + limit: FOLLOWINGS_PER_PAGE, + }, + }); + } catch (err) { + console.error(err); + } + } + async followRelay(e: Event): Promise { e.preventDefault(); try { - await this.$apollo.mutate({ + await this.$apollo.mutate<{ relayFollowings: Paginate }>({ mutation: ADD_RELAY, variables: { address: this.newRelayAddress.trim(), // trim to fix copy and paste domain name spaces and tabs }, + update( + cache: ApolloCache<{ relayFollowings: Paginate }>, + { data }: FetchResult + ) { + cache.modify({ + fields: { + relayFollowings( + existingFollowings = { elements: [], total: 0 }, + { readField } + ) { + const newFollowingRef = cache.writeFragment({ + id: `${data?.addRelay.__typename}:${data?.addRelay.id}`, + data: data?.addRelay, + fragment: gql` + fragment NewFollowing on Follower { + id + } + `, + }); + if ( + existingFollowings.elements.some( + (ref: Reference) => + readField("id", ref) === data?.addRelay.id + ) + ) { + return existingFollowings; + } + return { + total: existingFollowings.total + 1, + elements: [newFollowingRef, ...existingFollowings.elements], + }; + }, + }, + broadcast: false, + }); + }, }); - await this.$apollo.queries.relayFollowings.refetch(); this.newRelayAddress = ""; } catch (err) { Snackbar.open({ @@ -175,21 +263,35 @@ export default class Followings extends Mixins(RelayMixin) { } } - async removeRelays(): Promise { - await this.checkedRows.forEach((row: IFollower) => { - this.removeRelay( - `${row.targetActor.preferredUsername}@${row.targetActor.domain}` - ); + removeRelays(): void { + this.checkedRows.forEach((row: IFollower) => { + this.removeRelay(row); }); } - async removeRelay(address: string): Promise { + async removeRelay(follower: IFollower): Promise { + const address = `${follower.targetActor.preferredUsername}@${follower.targetActor.domain}`; try { - await this.$apollo.mutate({ + await this.$apollo.mutate<{ removeRelay: IFollower }>({ mutation: REMOVE_RELAY, variables: { address, }, + update(cache: ApolloCache<{ removeRelay: IFollower }>) { + cache.modify({ + fields: { + relayFollowings(existingFollowingRefs, { readField }) { + return { + total: existingFollowingRefs.total - 1, + elements: existingFollowingRefs.elements.filter( + (followingRef: Reference) => + follower.id !== readField("id", followingRef) + ), + }; + }, + }, + }); + }, }); await this.$apollo.queries.relayFollowings.refetch(); this.checkedRows = []; diff --git a/js/src/components/Comment/Comment.vue b/js/src/components/Comment/Comment.vue index 8d5810b8f..00994462c 100644 --- a/js/src/components/Comment/Comment.vue +++ b/js/src/components/Comment/Comment.vue @@ -1,37 +1,34 @@ diff --git a/js/src/components/Share/MastodonLogo.vue b/js/src/components/Share/MastodonLogo.vue new file mode 100644 index 000000000..c35ace269 --- /dev/null +++ b/js/src/components/Share/MastodonLogo.vue @@ -0,0 +1,25 @@ + + diff --git a/js/src/components/Todo/FullTodo.vue b/js/src/components/Todo/FullTodo.vue index b56a8c54b..c0451b2af 100644 --- a/js/src/components/Todo/FullTodo.vue +++ b/js/src/components/Todo/FullTodo.vue @@ -18,7 +18,8 @@ diff --git a/js/src/views/Admin/AdminProfile.vue b/js/src/views/Admin/AdminProfile.vue index 68b0221b7..96ebf0a4f 100644 --- a/js/src/views/Admin/AdminProfile.vue +++ b/js/src/views/Admin/AdminProfile.vue @@ -74,6 +74,11 @@ :loading="$apollo.queries.person.loading" paginated backend-pagination + :current-page.sync="organizedEventsPage" + :aria-next-label="$t('Next page')" + :aria-previous-label="$t('Previous page')" + :aria-page-label="$t('Page')" + :aria-current-label="$t('Current page')" :total="person.organizedEvents.total" :per-page="EVENTS_PER_PAGE" @page-change="onOrganizedEventsPageChange" @@ -93,11 +98,9 @@ @@ -115,9 +118,14 @@ (participation) => participation.event ) " - :loading="$apollo.queries.person.loading" + :loading="$apollo.loading" paginated backend-pagination + :current-page.sync="participationsPage" + :aria-next-label="$t('Next page')" + :aria-previous-label="$t('Previous page')" + :aria-page-label="$t('Page')" + :aria-current-label="$t('Current page')" :total="person.participations.total" :per-page="EVENTS_PER_PAGE" @page-change="onParticipationsPageChange" @@ -137,11 +145,115 @@ + + +
+

+ {{ + $tc("{number} memberships", person.memberships.total, { + number: person.memberships.total, + }) + }} +

+ + +
+
+ +
+ +
+
+ {{ + props.row.parent.name + }}
+ @{{ usernameWithDomain(props.row.parent) }} +
-
+ + + + + {{ $t("Administrator") }} + + + {{ $t("Moderator") }} + + + {{ $t("Member") }} + + + {{ $t("Not approved") }} + + + {{ $t("Rejected") }} + + + {{ $t("Invited") }} + + + + + {{ props.row.insertedAt | formatDateString }}
{{ + props.row.insertedAt | formatTimeString + }} +
+
+ @@ -159,8 +271,16 @@ import { IPerson } from "../../types/actor"; import { usernameWithDomain } from "../../types/actor/actor.model"; import RouteName from "../../router/name"; import ActorCard from "../../components/Account/ActorCard.vue"; +import EmptyContent from "../../components/Utils/EmptyContent.vue"; +import { ApolloCache, FetchResult } from "@apollo/client/core"; +import VueRouter from "vue-router"; +import { MemberRole } from "@/types/enums"; +import cloneDeep from "lodash/cloneDeep"; +const { isNavigationFailure, NavigationFailureType } = VueRouter; const EVENTS_PER_PAGE = 10; +const PARTICIPATIONS_PER_PAGE = 10; +const MEMBERSHIPS_PER_PAGE = 10; @Component({ apollo: { @@ -170,8 +290,12 @@ const EVENTS_PER_PAGE = 10; variables() { return { actorId: this.id, - organizedEventsPage: 1, + organizedEventsPage: this.organizedEventsPage, organizedEventsLimit: EVENTS_PER_PAGE, + participationsPage: this.participationsPage, + participationLimit: PARTICIPATIONS_PER_PAGE, + membershipsPage: this.membershipsPage, + membershipsLimit: MEMBERSHIPS_PER_PAGE, }; }, skip() { @@ -181,6 +305,7 @@ const EVENTS_PER_PAGE = 10; }, components: { ActorCard, + EmptyContent, }, }) export default class AdminProfile extends Vue { @@ -194,9 +319,41 @@ export default class AdminProfile extends Vue { EVENTS_PER_PAGE = EVENTS_PER_PAGE; - organizedEventsPage = 1; + PARTICIPATIONS_PER_PAGE = PARTICIPATIONS_PER_PAGE; - participationsPage = 1; + MEMBERSHIPS_PER_PAGE = MEMBERSHIPS_PER_PAGE; + + MemberRole = MemberRole; + + get organizedEventsPage(): number { + return parseInt( + (this.$route.query.organizedEventsPage as string) || "1", + 10 + ); + } + + set organizedEventsPage(page: number) { + this.pushRouter({ organizedEventsPage: page.toString() }); + } + + get participationsPage(): number { + return parseInt( + (this.$route.query.participationsPage as string) || "1", + 10 + ); + } + + set participationsPage(page: number) { + this.pushRouter({ participationsPage: page.toString() }); + } + + get membershipsPage(): number { + return parseInt((this.$route.query.membershipsPage as string) || "1", 10); + } + + set membershipsPage(page: number) { + this.pushRouter({ membershipsPage: page.toString() }); + } get metadata(): Array> { if (!this.person) return []; @@ -233,7 +390,10 @@ export default class AdminProfile extends Vue { variables: { id: this.id, }, - update: (store, { data }) => { + update: ( + store: ApolloCache<{ suspendProfile: { id: string } }>, + { data }: FetchResult + ) => { if (data == null) return; const profileId = this.id; @@ -243,21 +403,30 @@ export default class AdminProfile extends Vue { actorId: profileId, organizedEventsPage: 1, organizedEventsLimit: EVENTS_PER_PAGE, + participationsPage: 1, + participationLimit: PARTICIPATIONS_PER_PAGE, + membershipsPage: 1, + membershipsLimit: MEMBERSHIPS_PER_PAGE, }, }); if (!profileData) return; const { person } = profileData; - person.suspended = true; - person.avatar = null; - person.name = ""; - person.summary = ""; store.writeQuery({ query: GET_PERSON, variables: { actorId: profileId, }, - data: { person }, + data: { + person: { + ...cloneDeep(person), + participations: { total: 0, elements: [] }, + suspended: true, + avatar: null, + name: "", + summary: "", + }, + }, }); }, }); @@ -283,63 +452,48 @@ export default class AdminProfile extends Vue { }); } - async onOrganizedEventsPageChange(page: number): Promise { - this.organizedEventsPage = page; + async onOrganizedEventsPageChange(): Promise { await this.$apollo.queries.person.fetchMore({ variables: { actorId: this.id, organizedEventsPage: this.organizedEventsPage, organizedEventsLimit: EVENTS_PER_PAGE, }, - updateQuery: (previousResult, { fetchMoreResult }) => { - if (!fetchMoreResult) return previousResult; - const newOrganizedEvents = - fetchMoreResult.person.organizedEvents.elements; - return { - person: { - ...previousResult.person, - organizedEvents: { - __typename: previousResult.person.organizedEvents.__typename, - total: previousResult.person.organizedEvents.total, - elements: [ - ...previousResult.person.organizedEvents.elements, - ...newOrganizedEvents, - ], - }, - }, - }; - }, }); } - async onParticipationsPageChange(page: number): Promise { - this.participationsPage = page; + async onParticipationsPageChange(): Promise { await this.$apollo.queries.person.fetchMore({ variables: { actorId: this.id, participationPage: this.participationsPage, - participationLimit: EVENTS_PER_PAGE, - }, - updateQuery: (previousResult, { fetchMoreResult }) => { - if (!fetchMoreResult) return previousResult; - const newParticipations = - fetchMoreResult.person.participations.elements; - return { - person: { - ...previousResult.person, - participations: { - __typename: previousResult.person.participations.__typename, - total: previousResult.person.participations.total, - elements: [ - ...previousResult.person.participations.elements, - ...newParticipations, - ], - }, - }, - }; + participationLimit: PARTICIPATIONS_PER_PAGE, }, }); } + + async onMembershipsPageChange(): Promise { + await this.$apollo.queries.person.fetchMore({ + variables: { + actorId: this.id, + membershipsPage: this.participationsPage, + membershipsLimit: MEMBERSHIPS_PER_PAGE, + }, + }); + } + + private async pushRouter(args: Record): Promise { + try { + await this.$router.push({ + name: RouteName.ADMIN_PROFILE, + query: { ...this.$route.query, ...args }, + }); + } catch (e) { + if (isNavigationFailure(e, NavigationFailureType.redirected)) { + throw Error(e.toString()); + } + } + } } diff --git a/js/src/views/Admin/Dashboard.vue b/js/src/views/Admin/Dashboard.vue index 9c2d4d613..d2894f0c1 100644 --- a/js/src/views/Admin/Dashboard.vue +++ b/js/src/views/Admin/Dashboard.vue @@ -158,7 +158,6 @@ import RouteName from "../../router/name"; metaInfo() { return { title: this.$t("Administration") as string, - titleTemplate: "%s | Mobilizon", }; }, }) @@ -184,4 +183,8 @@ article.tile a { color: #4a4a4a; text-decoration: none; } + +.image.is-4by3 img { + object-fit: cover; +} diff --git a/js/src/views/Admin/Follows.vue b/js/src/views/Admin/Follows.vue index 4a7d007f3..2d9b6e599 100644 --- a/js/src/views/Admin/Follows.vue +++ b/js/src/views/Admin/Follows.vue @@ -32,7 +32,6 @@ tag="li" active-class="is-active" :to="{ name: RouteName.RELAY_FOLLOWINGS }" - exact > @@ -46,7 +45,6 @@ tag="li" active-class="is-active" :to="{ name: RouteName.RELAY_FOLLOWERS }" - exact > diff --git a/js/src/views/Admin/GroupProfiles.vue b/js/src/views/Admin/GroupProfiles.vue index 9d2553317..3005cd486 100644 --- a/js/src/views/Admin/GroupProfiles.vue +++ b/js/src/views/Admin/GroupProfiles.vue @@ -23,6 +23,12 @@ paginated backend-pagination backend-filtering + :debounce-search="200" + :current-page.sync="page" + :aria-next-label="$t('Next page')" + :aria-previous-label="$t('Previous page')" + :aria-page-label="$t('Page')" + :aria-current-label="$t('Current page')" :total="groups.total" :per-page="PROFILES_PER_PAGE" @page-change="onPageChange" @@ -81,20 +87,21 @@ diff --git a/js/src/views/Admin/Profiles.vue b/js/src/views/Admin/Profiles.vue index 71384cf5a..edde5d05b 100644 --- a/js/src/views/Admin/Profiles.vue +++ b/js/src/views/Admin/Profiles.vue @@ -23,6 +23,12 @@ paginated backend-pagination backend-filtering + :debounce-search="200" + :current-page.sync="page" + :aria-next-label="$t('Next page')" + :aria-previous-label="$t('Previous page')" + :aria-page-label="$t('Page')" + :aria-current-label="$t('Current page')" :total="persons.total" :per-page="PROFILES_PER_PAGE" @page-change="onPageChange" @@ -81,20 +87,21 @@ diff --git a/js/src/views/Admin/Settings.vue b/js/src/views/Admin/Settings.vue index 10214a49c..714dbb5d9 100644 --- a/js/src/views/Admin/Settings.vue +++ b/js/src/views/Admin/Settings.vue @@ -347,6 +347,11 @@ import RouteName from "../../router/name"; adminSettings: ADMIN_SETTINGS, languages: LANGUAGES, }, + metaInfo() { + return { + title: this.$t("Settings") as string, + }; + }, }) export default class Settings extends Vue { adminSettings!: IAdminSettings; diff --git a/js/src/views/Admin/Users.vue b/js/src/views/Admin/Users.vue index 60ffb817b..8f4de705b 100644 --- a/js/src/views/Admin/Users.vue +++ b/js/src/views/Admin/Users.vue @@ -22,6 +22,11 @@ backend-pagination backend-filtering detailed + :current-page.sync="page" + :aria-next-label="$t('Next page')" + :aria-previous-label="$t('Previous page')" + :aria-page-label="$t('Page')" + :aria-current-label="$t('Current page')" :show-detail-icon="true" :total="users.total" :per-page="USERS_PER_PAGE" @@ -108,6 +113,8 @@ import { Component, Vue } from "vue-property-decorator"; import { LIST_USERS } from "../../graphql/user"; import RouteName from "../../router/name"; +import VueRouter from "vue-router"; +const { isNavigationFailure, NavigationFailureType } = VueRouter; const USERS_PER_PAGE = 10; @@ -119,22 +126,39 @@ const USERS_PER_PAGE = 10; variables() { return { email: this.email, - page: 1, + page: this.page, limit: USERS_PER_PAGE, }; }, }, }, + metaInfo() { + return { + title: this.$t("Users") as string, + }; + }, }) export default class Users extends Vue { - page = 1; - - email = ""; - USERS_PER_PAGE = USERS_PER_PAGE; RouteName = RouteName; + get page(): number { + return parseInt((this.$route.query.page as string) || "1", 10); + } + + set page(page: number) { + this.pushRouter({ page: page.toString() }); + } + + get email(): string { + return (this.$route.query.email as string) || ""; + } + + set email(email: string) { + this.pushRouter({ email }); + } + async onPageChange(page: number): Promise { this.page = page; await this.$apollo.queries.users.fetchMore({ @@ -143,23 +167,25 @@ export default class Users extends Vue { page: this.page, limit: USERS_PER_PAGE, }, - updateQuery: (previousResult, { fetchMoreResult }) => { - if (!fetchMoreResult) return previousResult; - const newFollowings = fetchMoreResult.users.elements; - return { - users: { - __typename: previousResult.users.__typename, - total: previousResult.users.total, - elements: [...previousResult.users.elements, ...newFollowings], - }, - }; - }, }); } onFiltersChange({ email }: { email: string }): void { this.email = email; } + + private async pushRouter(args: Record): Promise { + try { + await this.$router.push({ + name: RouteName.USERS, + query: { ...this.$route.query, ...args }, + }); + } catch (e) { + if (isNavigationFailure(e, NavigationFailureType.redirected)) { + throw Error(e.toString()); + } + } + } } diff --git a/js/src/views/Discussions/Create.vue b/js/src/views/Discussions/Create.vue index f7ff343e2..a5ae11bf0 100644 --- a/js/src/views/Discussions/Create.vue +++ b/js/src/views/Discussions/Create.vue @@ -51,11 +51,7 @@ import RouteName from "../../router/name"; }, metaInfo() { return { - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore title: this.$t("Create a discussion") as string, - // all titles will be injected into this template - titleTemplate: "%s | Mobilizon", }; }, }) diff --git a/js/src/views/Discussions/Discussion.vue b/js/src/views/Discussions/Discussion.vue index da3b1e9f3..aa4999b6e 100644 --- a/js/src/views/Discussions/Discussion.vue +++ b/js/src/views/Discussions/Discussion.vue @@ -127,7 +127,6 @@ diff --git a/js/src/views/Error.vue b/js/src/views/Error.vue index e68e246e7..fb69a23ab 100644 --- a/js/src/views/Error.vue +++ b/js/src/views/Error.vue @@ -14,7 +14,13 @@ import { ErrorCode } from "@/types/enums"; import { Component, Vue } from "vue-property-decorator"; -@Component +@Component({ + metaInfo() { + return { + title: this.$t("Error") as string, + }; + }, +}) export default class ErrorPage extends Vue { code: ErrorCode | null = null; diff --git a/js/src/views/Event/Edit.vue b/js/src/views/Event/Edit.vue index e3402dbee..6f759e264 100644 --- a/js/src/views/Event/Edit.vue +++ b/js/src/views/Event/Edit.vue @@ -441,7 +441,7 @@ section { @@ -415,8 +454,24 @@ section { } .table { + .column-message { + vertical-align: middle; + } .ellipsed-message { cursor: pointer; + display: flex; + align-items: center; + flex-wrap: wrap; + justify-content: center; + + p { + flex: 1; + min-width: 200px; + } + + button { + display: inline; + } } span.tag { diff --git a/js/src/views/Group/Create.vue b/js/src/views/Group/Create.vue index fc4961730..871b19de0 100644 --- a/js/src/views/Group/Create.vue +++ b/js/src/views/Group/Create.vue @@ -92,10 +92,11 @@ import { MemberRole } from "@/types/enums"; import RouteName from "../../router/name"; import { convertToUsername } from "../../utils/username"; import PictureUpload from "../../components/PictureUpload.vue"; -import { ErrorResponse } from "apollo-link-error"; -import { ServerParseError } from "apollo-link-http-common"; import { CONFIG } from "@/graphql/config"; import { IConfig } from "@/types/config.model"; +import { ErrorResponse } from "@apollo/client/link/error"; +import { ServerParseError } from "@apollo/client/link/http"; +import { ApolloCache, FetchResult, InMemoryCache } from "@apollo/client/core"; @Component({ components: { @@ -107,6 +108,11 @@ import { IConfig } from "@/types/config.model"; }, config: CONFIG, }, + metaInfo() { + return { + title: this.$t("Create a new group") as string, + }; + }, }) export default class CreateGroup extends mixins(IdentityEditionMixin) { currentActor!: IPerson; @@ -129,7 +135,7 @@ export default class CreateGroup extends mixins(IdentityEditionMixin) { await this.$apollo.mutate({ mutation: CREATE_GROUP, variables: this.buildVariables(), - update: (store, { data: { createGroup } }) => { + update: (store: ApolloCache, { data }: FetchResult) => { const query = { query: PERSON_MEMBERSHIPS, variables: { @@ -140,7 +146,7 @@ export default class CreateGroup extends mixins(IdentityEditionMixin) { if (!membershipData) return; const { person } = membershipData; person.memberships.elements.push({ - parent: createGroup, + parent: data?.createGroup, role: MemberRole.ADMINISTRATOR, actor: this.currentActor, insertedAt: new Date().toString(), diff --git a/js/src/views/Group/Group.vue b/js/src/views/Group/Group.vue index 9906f1ef7..cbe3e0caf 100644 --- a/js/src/views/Group/Group.vue +++ b/js/src/views/Group/Group.vue @@ -10,7 +10,7 @@
  • -
    - -
    +
    @@ -275,9 +270,9 @@ :discussion="discussion" />
    -
    -

    {{ $t("No discussions yet") }}

    -
    + + {{ $t("No discussions yet") }} + diff --git a/js/src/views/Interact.vue b/js/src/views/Interact.vue index 0e28c87de..a320bd5bf 100644 --- a/js/src/views/Interact.vue +++ b/js/src/views/Interact.vue @@ -33,6 +33,7 @@ import { INTERACT } from "@/graphql/search"; import { IEvent } from "@/types/event.model"; import { IGroup, usernameWithDomain } from "@/types/actor"; import RouteName from "../router/name"; +import { GraphQLError } from "graphql"; @Component({ apollo: { @@ -56,7 +57,7 @@ import RouteName from "../router/name"; if (networkError) { this.errors = [networkError.message]; } - this.errors = graphQLErrors.map((error) => error.message); + this.errors = graphQLErrors.map((error: GraphQLError) => error.message); }, async result({ data: { interact } }) { switch (interact.__typename) { @@ -82,6 +83,11 @@ import RouteName from "../router/name"; }, }, }, + metaInfo() { + return { + title: this.$t("Interact with a remote content") as string, + }; + }, }) export default class Interact extends Vue { interact!: IEvent | IGroup; diff --git a/js/src/views/Moderation/Report.vue b/js/src/views/Moderation/Report.vue index 7ce48a807..59a94bf8b 100644 --- a/js/src/views/Moderation/Report.vue +++ b/js/src/views/Moderation/Report.vue @@ -295,12 +295,14 @@ import { IReport, IReportNote } from "@/types/report.model"; import { CURRENT_ACTOR_CLIENT } from "@/graphql/actor"; import { IPerson, displayNameAndUsername } from "@/types/actor"; import { DELETE_EVENT } from "@/graphql/event"; -import { uniq } from "lodash"; +import uniq from "lodash/uniq"; import { nl2br } from "@/utils/html"; import { DELETE_COMMENT } from "@/graphql/comment"; import { IComment } from "@/types/comment.model"; import { ActorType, ReportStatusEnum } from "@/types/enums"; import RouteName from "../../router/name"; +import { GraphQLError } from "graphql"; +import { ApolloCache, FetchResult } from "@apollo/client/core"; @Component({ apollo: { @@ -313,7 +315,9 @@ import RouteName from "../../router/name"; }; }, error({ graphQLErrors }) { - this.errors = uniq(graphQLErrors.map(({ message }) => message)); + this.errors = uniq( + graphQLErrors.map(({ message }: GraphQLError) => message) + ); }, }, currentActor: { @@ -356,7 +360,10 @@ export default class Report extends Vue { reportId: this.report.id, content: this.noteContent, }, - update: (store, { data }) => { + update: ( + store: ApolloCache<{ createReportNote: IReportNote }>, + { data }: FetchResult + ) => { if (data == null) return; const cachedData = store.readQuery<{ report: IReport }>({ query: REPORT, @@ -456,13 +463,16 @@ export default class Report extends Vue { async updateReport(status: ReportStatusEnum): Promise { try { - await this.$apollo.mutate({ + await this.$apollo.mutate<{ updateReportStatus: IReport }>({ mutation: UPDATE_REPORT, variables: { reportId: this.report.id, status, }, - update: (store, { data }) => { + update: ( + store: ApolloCache<{ updateReportStatus: IReport }>, + { data }: FetchResult + ) => { if (data == null) return; const reportCachedData = store.readQuery<{ report: IReport }>({ query: REPORT, @@ -476,13 +486,15 @@ export default class Report extends Vue { ); return; } - const updatedReport = data.updateReportStatus; - report.status = updatedReport.status; + const updatedReport = { + ...report, + status: data.updateReportStatus.status, + }; store.writeQuery({ query: REPORT, variables: { id: this.report.id }, - data: { report }, + data: { report: updatedReport }, }); }, }); diff --git a/js/src/views/Moderation/ReportList.vue b/js/src/views/Moderation/ReportList.vue index d2b9856ba..928e19c35 100644 --- a/js/src/views/Moderation/ReportList.vue +++ b/js/src/views/Moderation/ReportList.vue @@ -17,23 +17,23 @@
    {{ $t("Open") }} {{ $t("Resolved") }} {{ $t("Closed") }} -
      -
    • +
        +
      • @@ -41,48 +41,71 @@
      -
      - + {{ $t("No open reports yet") }} - - + {{ $t("No resolved reports yet") }} - - + {{ $t("No closed reports yet") }} - +
      + +
    diff --git a/js/src/views/PageNotFound.vue b/js/src/views/PageNotFound.vue index afce22e21..dc0412ad7 100644 --- a/js/src/views/PageNotFound.vue +++ b/js/src/views/PageNotFound.vue @@ -67,6 +67,11 @@ import RouteName from "../router/name"; components: { BField, }, + metaInfo() { + return { + title: this.$t("Page not found") as string, + }; + }, }) export default class PageNotFound extends Vue { searchText = ""; diff --git a/js/src/views/Posts/Edit.vue b/js/src/views/Posts/Edit.vue index b976b336b..97d9d9305 100644 --- a/js/src/views/Posts/Edit.vue +++ b/js/src/views/Posts/Edit.vue @@ -49,7 +49,7 @@ - +

    {{ errors.body }}

    - +
    {{ $t("Who can view this post") }}
    {{ $t("Visible everywhere on the web") }}
    {{ $t("Only accessible through link") }}
    {{ $t("Only accessible to members of the group") }} { @@ -270,6 +270,7 @@ export default class EditPost extends mixins(GroupMixin) { if (oldPost.picture !== newPost.picture) { this.pictureFile = await buildFileFromIMedia(this.post.picture); } + this.editablePost = { ...this.post }; } // eslint-disable-next-line consistent-return @@ -280,11 +281,11 @@ export default class EditPost extends mixins(GroupMixin) { const { data } = await this.$apollo.mutate({ mutation: UPDATE_POST, variables: { - id: this.post.id, - title: this.post.title, - body: this.post.body, - tags: (this.post.tags || []).map(({ title }) => title), - visibility: this.post.visibility, + id: this.editablePost.id, + title: this.editablePost.title, + body: this.editablePost.body, + tags: (this.editablePost.tags || []).map(({ title }) => title), + visibility: this.editablePost.visibility, draft, ...(await this.buildPicture()), }, @@ -300,9 +301,9 @@ export default class EditPost extends mixins(GroupMixin) { const { data } = await this.$apollo.mutate({ mutation: CREATE_POST, variables: { - ...this.post, + ...this.editablePost, ...(await this.buildPicture()), - tags: (this.post.tags || []).map(({ title }) => title), + tags: (this.editablePost.tags || []).map(({ title }) => title), attributedToId: this.actualGroup.id, draft, }, @@ -362,16 +363,16 @@ export default class EditPost extends mixins(GroupMixin) { obj = { ...obj, ...pictureObj }; } try { - if (this.post.picture && this.pictureFile) { + if (this.editablePost.picture && this.pictureFile) { const oldPictureFile = (await buildFileFromIMedia( - this.post.picture + this.editablePost.picture )) as File; const oldPictureFileContent = await readFileAsync(oldPictureFile); const newPictureFileContent = await readFileAsync( this.pictureFile as File ); if (oldPictureFileContent === newPictureFileContent) { - obj.picture = { mediaId: this.post.picture.id }; + obj.picture = { mediaId: this.editablePost.picture.id }; } } } catch (e) { @@ -381,7 +382,7 @@ export default class EditPost extends mixins(GroupMixin) { } get actualGroup(): IActor { - if (!this.group.id) { + if (!this.group?.id) { return this.post.attributedTo as IActor; } return this.group; @@ -394,12 +395,23 @@ export default class EditPost extends mixins(GroupMixin) { } form { nav.navbar { - position: sticky; - bottom: 0; - min-height: 2rem; + min-height: 2rem !important; + background: lighten($secondary, 10%); .container { min-height: 2rem; + + .navbar-menu, + .navbar-end { + display: flex !important; + background: lighten($secondary, 10%); + flex-wrap: wrap; + } + + .navbar-end { + justify-content: flex-end; + margin-left: auto; + } } } } diff --git a/js/src/views/Posts/List.vue b/js/src/views/Posts/List.vue index d186bcba2..1fb176f33 100644 --- a/js/src/views/Posts/List.vue +++ b/js/src/views/Posts/List.vue @@ -86,7 +86,7 @@ import { IMember } from "@/types/actor/member.model"; import { FETCH_GROUP_POSTS } from "../../graphql/post"; import { Paginate } from "../../types/paginate"; import { IPost } from "../../types/post.model"; -import { IGroup, IPerson, usernameWithDomain } from "../../types/actor"; +import { usernameWithDomain } from "../../types/actor"; import RouteName from "../../router/name"; import PostElementItem from "../../components/Post/PostElementItem.vue"; @@ -138,14 +138,10 @@ const POSTS_PAGE_LIMIT = 10; export default class PostList extends mixins(GroupMixin) { @Prop({ required: true, type: String }) preferredUsername!: string; - group!: IGroup; - posts!: Paginate; memberships!: IMember[]; - currentActor!: IPerson; - postsPage = 1; RouteName = RouteName; diff --git a/js/src/views/Posts/Post.vue b/js/src/views/Posts/Post.vue index 53b736837..bd83c47c3 100644 --- a/js/src/views/Posts/Post.vue +++ b/js/src/views/Posts/Post.vue @@ -1,61 +1,72 @@ diff --git a/js/src/views/Resources/ResourceFolder.vue b/js/src/views/Resources/ResourceFolder.vue index 645b03ffb..d668b4f2d 100644 --- a/js/src/views/Resources/ResourceFolder.vue +++ b/js/src/views/Resources/ResourceFolder.vue @@ -145,6 +145,17 @@

    {{ $t("No resources in this folder") }}

    + + - +