diff --git a/js/src/components/Comment/Comment.vue b/js/src/components/Comment/Comment.vue index 63cfd50aa..ea904c129 100644 --- a/js/src/components/Comment/Comment.vue +++ b/js/src/components/Comment/Comment.vue @@ -6,32 +6,26 @@ :id="commentId" > <popover-actor-card - class="media-left" :actor="comment.actor" :inline="true" v-if="comment.actor" > <figure - class="image is-48x48" + class="image is-32x32 media-left" v-if="!comment.deletedAt && comment.actor.avatar" > <img class="is-rounded" :src="comment.actor.avatar.url" alt="" /> </figure> - <b-icon - class="media-left" - v-else - size="is-large" - icon="account-circle" - /> + <b-icon class="media-left" v-else icon="account-circle" /> </popover-actor-card> <div v-else class="media-left"> <figure - class="image is-48x48" + class="image is-32x32" v-if="!comment.deletedAt && comment.actor.avatar" > <img class="is-rounded" :src="comment.actor.avatar.url" alt="" /> </figure> - <b-icon v-else size="is-large" icon="account-circle" /> + <b-icon v-else icon="account-circle" /> </div> <div class="media-content"> <div class="content"> @@ -39,19 +33,21 @@ <strong :class="{ organizer: commentFromOrganizer }">{{ comment.actor.name }}</strong> - <small>@{{ usernameWithDomain(comment.actor) }}</small> - <a class="comment-link has-text-grey" :href="commentURL"> - <small>{{ - formatDistanceToNow(new Date(comment.updatedAt), { - locale: $dateFnsLocale, - addSuffix: true, - }) - }}</small> - </a> + <small class="has-text-grey">{{ + usernameWithDomain(comment.actor) + }}</small> </span> <a v-else class="comment-link has-text-grey" :href="commentURL"> <span>{{ $t("[deleted]") }}</span> </a> + <a class="comment-link has-text-grey" :href="commentURL"> + <small>{{ + formatDistanceToNow(new Date(comment.updatedAt), { + locale: $dateFnsLocale, + addSuffix: true, + }) + }}</small> + </a> <span class="icons" v-if="!comment.deletedAt"> <button v-if="comment.actor.id === currentActor.id" @@ -369,8 +365,17 @@ form.reply { } } -.comment-link small:hover { - color: hsl(0, 0%, 21%); +a.comment-link { + text-decoration: none; + margin-left: 5px; + &:hover { + text-decoration: underline; + } + small { + &:hover { + color: hsl(0, 0%, 21%); + } + } } .root-comment .replies { diff --git a/js/src/components/Comment/CommentTree.vue b/js/src/components/Comment/CommentTree.vue index 01fca3375..547674867 100644 --- a/js/src/components/Comment/CommentTree.vue +++ b/js/src/components/Comment/CommentTree.vue @@ -17,26 +17,34 @@ </figure> <div class="media-content"> <div class="field"> - <p class="control"> - <editor - ref="commenteditor" - mode="comment" - v-model="newComment.text" - /> - </p> - <p class="help is-danger" v-if="emptyCommentError"> - {{ $t("Comment text can't be empty") }} - </p> - </div> - <div class="send-comment"> - <b-button - native-type="submit" - type="is-primary" - class="comment-button-submit" - >{{ $t("Post a comment") }}</b-button - > + <div class="field"> + <p class="control"> + <editor + ref="commenteditor" + mode="comment" + v-model="newComment.text" + /> + </p> + <p class="help is-danger" v-if="emptyCommentError"> + {{ $t("Comment text can't be empty") }} + </p> + </div> + <div class="field notify-participants" v-if="isEventOrganiser"> + <b-switch v-model="newComment.isAnnouncement">{{ + $t("Notify participants") + }}</b-switch> + </div> </div> </div> + <div class="send-comment"> + <b-button + native-type="submit" + type="is-primary" + class="comment-button-submit" + icon-left="send" + :aria-label="$t('Post a comment')" + /> + </div> </article> </form> <b-notification v-else-if="isConnected" :closable="false">{{ @@ -157,6 +165,7 @@ export default class CommentTree extends Vue { inReplyToCommentId: comment.inReplyToComment ? comment.inReplyToComment.id : null, + isAnnouncement: comment.isAnnouncement, }, update: (store: ApolloCache<InMemoryCache>, { data }: FetchResult) => { if (data == null) return; @@ -359,6 +368,10 @@ form.new-comment { flex: 1; padding-right: 10px; margin-bottom: 0; + + &.notify-participants { + margin-top: 0.5rem; + } } } } diff --git a/js/src/graphql/comment.ts b/js/src/graphql/comment.ts index 3da23909b..dd6b6df4c 100644 --- a/js/src/graphql/comment.ts +++ b/js/src/graphql/comment.ts @@ -24,6 +24,7 @@ export const COMMENT_FIELDS_FRAGMENT = gql` insertedAt updatedAt deletedAt + isAnnouncement } `; @@ -92,11 +93,13 @@ export const CREATE_COMMENT_FROM_EVENT = gql` $eventId: ID! $text: String! $inReplyToCommentId: ID + $isAnnouncement: Boolean ) { createComment( eventId: $eventId text: $text inReplyToCommentId: $inReplyToCommentId + isAnnouncement: $isAnnouncement ) { ...CommentRecursive } diff --git a/js/src/graphql/user.ts b/js/src/graphql/user.ts index 80a74fd58..0d35778fd 100644 --- a/js/src/graphql/user.ts +++ b/js/src/graphql/user.ts @@ -171,6 +171,38 @@ export const SET_USER_SETTINGS = gql` ${USER_SETTINGS_FRAGMENT} `; +export const USER_NOTIFICATIONS = gql` + query UserNotifications { + loggedUser { + id + locale + settings { + ...UserSettingFragment + } + activitySettings { + key + method + enabled + } + } + } + ${USER_SETTINGS_FRAGMENT} +`; + +export const UPDATE_ACTIVITY_SETTING = gql` + mutation UpdateActivitySetting( + $key: String! + $method: String! + $enabled: Boolean! + ) { + updateActivitySetting(key: $key, method: $method, enabled: $enabled) { + key + method + enabled + } + } +`; + export const LIST_USERS = gql` query ListUsers($email: String, $page: Int, $limit: Int) { users(email: $email, page: $page, limit: $limit) { diff --git a/js/src/registerServiceWorker.ts b/js/src/registerServiceWorker.ts index 0d1a5b0db..141c1daa4 100644 --- a/js/src/registerServiceWorker.ts +++ b/js/src/registerServiceWorker.ts @@ -34,6 +34,6 @@ if ("serviceWorker" in navigator && isProduction()) { } function isProduction(): boolean { - // return true; - return process.env.NODE_ENV === "production"; + return true; + // return process.env.NODE_ENV === "production"; } diff --git a/js/src/types/comment.model.ts b/js/src/types/comment.model.ts index 95de6f3af..5776bb9bc 100644 --- a/js/src/types/comment.model.ts +++ b/js/src/types/comment.model.ts @@ -19,6 +19,7 @@ export interface IComment { totalReplies: number; insertedAt?: Date | string; publishedAt?: Date | string; + isAnnouncement: boolean; } export class CommentModel implements IComment { @@ -50,6 +51,8 @@ export class CommentModel implements IComment { totalReplies = 0; + isAnnouncement = false; + constructor(hash?: IComment) { if (!hash) return; @@ -66,5 +69,6 @@ export class CommentModel implements IComment { this.deletedAt = hash.deletedAt; this.insertedAt = new Date(hash.insertedAt as string); this.totalReplies = hash.totalReplies; + this.isAnnouncement = hash.isAnnouncement; } } diff --git a/js/src/types/current-user.model.ts b/js/src/types/current-user.model.ts index 352b2f2ae..d8f17b038 100644 --- a/js/src/types/current-user.model.ts +++ b/js/src/types/current-user.model.ts @@ -28,6 +28,12 @@ export interface IUserSettings { location?: IUserPreferredLocation; } +export interface IActivitySetting { + key: string; + method: string; + enabled: boolean; +} + export interface IUser extends ICurrentUser { confirmedAt: Date; confirmationSendAt: Date; @@ -37,6 +43,7 @@ export interface IUser extends ICurrentUser { mediaSize: number; drafts: IEvent[]; settings: IUserSettings; + activitySettings: IActivitySetting[]; locale: string; provider?: string; lastSignInAt: string; diff --git a/js/src/views/Settings/Notifications.vue b/js/src/views/Settings/Notifications.vue index 81e7a6ef2..99711c055 100644 --- a/js/src/views/Settings/Notifications.vue +++ b/js/src/views/Settings/Notifications.vue @@ -16,18 +16,63 @@ </nav> <section> <div class="setting-title"> - <h2>{{ $t("Participation notifications") }}</h2> + <h2>{{ $t("Browser notifications") }}</h2> </div> <b-button v-if="subscribed" @click="unsubscribeToWebPush()">{{ - $t("Unsubscribe to WebPush") + $t("Unsubscribe to browser notifications") }}</b-button> <b-button icon-left="rss" @click="subscribeToWebPush" v-else-if="canShowWebPush()" - >{{ $t("WebPush") }}</b-button + >{{ $t("Activate browser notification") }}</b-button > - <span v-else>{{ $t("You can't use webpush in this browser.") }}</span> + <span v-else>{{ + $t("You can't use notifications in this browser.") + }}</span> + </section> + <section> + <div class="setting-title"> + <h2>{{ $t("Notification settings") }}</h2> + </div> + <p> + {{ + $t( + "Select the activities for which you wish to receive an email or a push notification." + ) + }} + </p> + <table class="table"> + <tbody> + <template v-for="notificationType in notificationTypes"> + <tr :key="`${notificationType.label}-title`"> + <th colspan="3"> + {{ notificationType.label }} + </th> + </tr> + <tr :key="`${notificationType.label}-subtitle`"> + <th v-for="(method, key) in notificationMethods" :key="key"> + {{ method }} + </th> + <th></th> + </tr> + <tr v-for="subType in notificationType.subtypes" :key="subType.id"> + <td v-for="(method, key) in notificationMethods" :key="key"> + <b-checkbox + :value="notificationValues[subType.id][key]" + @input="(e) => updateNotificationValue(subType.id, key, e)" + :disabled="notificationValues[subType.id].disabled" + /> + </td> + <td> + {{ subType.label }} + </td> + </tr> + </template> + </tbody> + </table> + </section> + <section> <div class="setting-title"> <h2>{{ $t("Participation notifications") }}</h2> </div> @@ -207,9 +252,10 @@ import { Component, Vue, Watch } from "vue-property-decorator"; import { INotificationPendingEnum } from "@/types/enums"; import { - USER_SETTINGS, SET_USER_SETTINGS, FEED_TOKENS_LOGGED_USER, + USER_NOTIFICATIONS, + UPDATE_ACTIVITY_SETTING, } from "../../graphql/user"; import { IUser } from "../../types/current-user.model"; import RouteName from "../../router/name"; @@ -223,10 +269,14 @@ import { REGISTER_PUSH_MUTATION, UNREGISTER_PUSH_MUTATION, } from "@/graphql/webPush"; +import { merge } from "lodash"; + +type NotificationSubType = { label: string; id: string }; +type NotificationType = { label: string; subtypes: NotificationSubType[] }; @Component({ apollo: { - loggedUser: USER_SETTINGS, + loggedUser: USER_NOTIFICATIONS, feedTokens: { query: FEED_TOKENS_LOGGED_USER, update: (data) => @@ -263,6 +313,201 @@ export default class Notifications extends Vue { subscribed = false; + notificationMethods = { + email: this.$t("Email") as string, + push: this.$t("Push") as string, + }; + + defaultNotificationValues = { + participation_event_updated: { + email: true, + push: true, + disabled: true, + }, + participation_event_comment: { + email: true, + push: true, + }, + event_new_pending_participation: { + email: true, + push: true, + }, + event_new_participation: { + email: false, + push: false, + }, + event_created: { + email: false, + push: false, + }, + event_updated: { + email: false, + push: false, + }, + discussion_updated: { + email: false, + push: false, + }, + post_published: { + email: false, + push: false, + }, + post_updated: { + email: false, + push: false, + }, + resource_updated: { + email: false, + push: false, + }, + member_request: { + email: true, + push: true, + }, + member_updated: { + email: false, + push: false, + }, + user_email_password_updated: { + email: true, + push: false, + disabled: true, + }, + event_comment_mention: { + email: true, + push: true, + }, + discussion_mention: { + email: true, + push: false, + }, + event_new_comment: { + email: true, + push: false, + }, + }; + + notificationTypes: NotificationType[] = [ + { + label: this.$t("Mentions") as string, + subtypes: [ + { + id: "event_comment_mention", + label: this.$t( + "I've been mentionned in a comment under an event" + ) as string, + }, + { + id: "discussion_mention", + label: this.$t( + "I've been mentionned in a group discussion" + ) as string, + }, + ], + }, + { + label: this.$t("Participations") as string, + subtypes: [ + { + id: "participation_event_updated", + label: this.$t("An event I'm going to has been updated") as string, + }, + { + id: "participation_event_comment", + label: this.$t( + "An event I'm going to has posted an announcement" + ) as string, + }, + ], + }, + { + label: this.$t("Organizers") as string, + subtypes: [ + { + id: "event_new_pending_participation", + label: this.$t( + "An event I'm organizing has a new pending participation" + ) as string, + }, + { + id: "event_new_participation", + label: this.$t( + "An event I'm organizing has a new participation" + ) as string, + }, + { + id: "event_new_comment", + label: this.$t("An event I'm organizing has a new comment") as string, + }, + ], + }, + { + label: this.$t("Group activity") as string, + subtypes: [ + { + id: "event_created", + label: this.$t( + "An event from one of my groups has been published" + ) as string, + }, + { + id: "event_updated", + label: this.$t( + "An event from one of my groups has been updated or deleted" + ) as string, + }, + { + id: "discussion_updated", + label: this.$t("A discussion has been created or updated") as string, + }, + { + id: "post_published", + label: this.$t("A post has been published") as string, + }, + { + id: "post_updated", + label: this.$t("A post has been updated") as string, + }, + { + id: "resource_updated", + label: this.$t("A resource has been created or updated") as string, + }, + { + id: "member_request", + label: this.$t( + "A member requested to join one of my groups" + ) as string, + }, + { + id: "member_updated", + label: this.$t("A member has been updated") as string, + }, + ], + }, + { + label: this.$t("User settings") as string, + subtypes: [ + { + id: "user_email_password_updated", + label: this.$t("You changed your email or password") as string, + }, + ], + }, + ]; + + get userNotificationValues(): Record<string, Record<string, boolean>> { + return this.loggedUser.activitySettings.reduce((acc, activitySetting) => { + acc[activitySetting.key] = acc[activitySetting.key] || {}; + acc[activitySetting.key][activitySetting.method] = + activitySetting.enabled; + return acc; + }, {} as Record<string, Record<string, boolean>>); + } + + get notificationValues(): Record<string, Record<string, boolean>> { + return merge(this.defaultNotificationValues, this.userNotificationValues); + } + mounted(): void { this.notificationPendingParticipationValues = { [INotificationPendingEnum.NONE]: this.$t("Do not receive any mail"), @@ -290,7 +535,7 @@ export default class Notifications extends Vue { await this.$apollo.mutate<{ setUserSettings: string }>({ mutation: SET_USER_SETTINGS, variables, - refetchQueries: [{ query: USER_SETTINGS }], + refetchQueries: [{ query: USER_NOTIFICATIONS }], }); } @@ -387,6 +632,22 @@ export default class Notifications extends Vue { this.subscribed = await this.isSubscribed(); } + async updateNotificationValue( + key: string, + method: string, + enabled: boolean + ): Promise<void> { + await this.$apollo.mutate({ + mutation: UPDATE_ACTIVITY_SETTING, + variables: { + key, + method, + enabled, + userId: this.loggedUser.id, + }, + }); + } + private async isSubscribed(): Promise<boolean> { if (!("serviceWorker" in navigator)) return Promise.resolve(false); const registration = await navigator.serviceWorker.getRegistration(); diff --git a/lib/federation/activity_stream/converter/comment.ex b/lib/federation/activity_stream/converter/comment.ex index 16156c1af..d4ec756d9 100644 --- a/lib/federation/activity_stream/converter/comment.ex +++ b/lib/federation/activity_stream/converter/comment.ex @@ -69,7 +69,8 @@ defmodule Mobilizon.Federation.ActivityStream.Converter.Comment do mentions: mentions, local: is_nil(actor_domain), visibility: if(Visibility.is_public?(object), do: :public, else: :private), - published_at: object["published"] + published_at: object["published"], + is_announcement: Map.get(object, "isAnnouncement", false) } Logger.debug("Converted object before fetching parents") @@ -109,7 +110,8 @@ defmodule Mobilizon.Federation.ActivityStream.Converter.Comment do "uuid" => comment.uuid, "id" => comment.url, "tag" => build_mentions(comment.mentions) ++ build_tags(comment.tags), - "published" => comment.published_at |> DateTime.to_iso8601() + "published" => comment.published_at |> DateTime.to_iso8601(), + "isAnnouncement" => comment.is_announcement } object = diff --git a/lib/graphql/resolvers/users/activity_settings.ex b/lib/graphql/resolvers/users/activity_settings.ex new file mode 100644 index 000000000..f3a76e2e3 --- /dev/null +++ b/lib/graphql/resolvers/users/activity_settings.ex @@ -0,0 +1,26 @@ +defmodule Mobilizon.GraphQL.Resolvers.Users.ActivitySettings do + @moduledoc """ + Handles the user activity settings-related GraphQL calls. + """ + + alias Mobilizon.Users + alias Mobilizon.Users.User + + require Logger + + def user_activity_settings(_parent, _args, %{context: %{current_user: %User{} = user}}) do + {:ok, Users.activity_settings_for_user(user)} + end + + def user_activity_settings(_parent, _args, _context) do + {:error, :unauthenticated} + end + + def upsert_user_activity_setting(_parent, args, %{context: %{current_user: %User{id: user_id}}}) do + Users.create_activity_setting(Map.put(args, :user_id, user_id)) + end + + def upsert_user_activity_setting(_parent, _args, _resolution) do + {:error, :unauthenticated} + end +end diff --git a/lib/graphql/schema.ex b/lib/graphql/schema.ex index 0c620b6ae..74f3f9a38 100644 --- a/lib/graphql/schema.ex +++ b/lib/graphql/schema.ex @@ -48,6 +48,7 @@ defmodule Mobilizon.GraphQL.Schema do import_types(Schema.AdminType) import_types(Schema.StatisticsType) import_types(Schema.Users.PushSubscription) + import_types(Schema.Users.ActivitySetting) @desc "A struct containing the id of the deleted object" object :deleted_object do @@ -182,6 +183,7 @@ defmodule Mobilizon.GraphQL.Schema do import_fields(:actor_mutations) import_fields(:follower_mutations) import_fields(:push_mutations) + import_fields(:activity_setting_mutations) end @desc """ diff --git a/lib/graphql/schema/discussions/comment.ex b/lib/graphql/schema/discussions/comment.ex index 779b8e1d1..cef5f5556 100644 --- a/lib/graphql/schema/discussions/comment.ex +++ b/lib/graphql/schema/discussions/comment.ex @@ -50,6 +50,10 @@ defmodule Mobilizon.GraphQL.Schema.Discussions.CommentType do field(:updated_at, :datetime, description: "When was the comment updated") field(:deleted_at, :datetime, description: "When was the comment deleted") field(:published_at, :datetime, description: "When was the comment published") + + field(:is_announcement, non_null(:boolean), + description: "Whether this comment needs to be announced to participants" + ) end @desc "The list of visibility options for a comment" @@ -86,6 +90,8 @@ defmodule Mobilizon.GraphQL.Schema.Discussions.CommentType do arg(:event_id, non_null(:id), description: "The event under which this comment is") arg(:in_reply_to_comment_id, :id, description: "The comment ID this one replies to") + arg(:is_announcement, :boolean, description: "Should this comment be announced to everyone?") + resolve(&Comment.create_comment/3) end @@ -94,6 +100,8 @@ defmodule Mobilizon.GraphQL.Schema.Discussions.CommentType do arg(:text, non_null(:string), description: "The comment updated body") arg(:comment_id, non_null(:id), description: "The comment ID") + arg(:is_announcement, :boolean, description: "Should this comment be announced to everyone?") + resolve(&Comment.update_comment/3) end diff --git a/lib/graphql/schema/user.ex b/lib/graphql/schema/user.ex index 8d713464d..8a5a97aff 100644 --- a/lib/graphql/schema/user.ex +++ b/lib/graphql/schema/user.ex @@ -8,6 +8,7 @@ defmodule Mobilizon.GraphQL.Schema.UserType do alias Mobilizon.Events alias Mobilizon.GraphQL.Resolvers.{Media, User} + alias Mobilizon.GraphQL.Resolvers.Users.ActivitySettings alias Mobilizon.GraphQL.Schema import_types(Schema.SortType) @@ -131,6 +132,11 @@ defmodule Mobilizon.GraphQL.Schema.UserType do resolve: &Media.user_size/3, description: "The total size of all the media from this user (from all their actors)" ) + + field(:activity_settings, list_of(:activity_setting), + resolve: &ActivitySettings.user_activity_settings/3, + description: "The user's activity settings" + ) end @desc "The list of roles an user can have" diff --git a/lib/graphql/schema/users/activity_setting.ex b/lib/graphql/schema/users/activity_setting.ex new file mode 100644 index 000000000..497b7b19b --- /dev/null +++ b/lib/graphql/schema/users/activity_setting.ex @@ -0,0 +1,23 @@ +defmodule Mobilizon.GraphQL.Schema.Users.ActivitySetting do + @moduledoc """ + Schema representation for PushSubscription + """ + use Absinthe.Schema.Notation + alias Mobilizon.GraphQL.Resolvers.Users.ActivitySettings + + object :activity_setting do + field(:key, :string) + field(:method, :string) + field(:enabled, :boolean) + field(:user, :user) + end + + object :activity_setting_mutations do + field :update_activity_setting, :activity_setting do + arg(:key, non_null(:string)) + arg(:method, non_null(:string)) + arg(:enabled, non_null(:boolean)) + resolve(&ActivitySettings.upsert_user_activity_setting/3) + end + end +end diff --git a/lib/mobilizon/activities/activities.ex b/lib/mobilizon/activities/activities.ex index e38fbc15b..71887f2e2 100644 --- a/lib/mobilizon/activities/activities.ex +++ b/lib/mobilizon/activities/activities.ex @@ -17,7 +17,7 @@ defmodule Mobilizon.Activities do very_high: 50 ) - @activity_types ["event", "post", "discussion", "resource", "group", "member"] + @activity_types ["event", "post", "discussion", "resource", "group", "member", "comment"] @event_activity_subjects ["event_created", "event_updated", "event_deleted", "comment_posted"] @post_activity_subjects ["post_created", "post_updated", "post_deleted"] @discussion_activity_subjects [ diff --git a/lib/mobilizon/discussions/comment.ex b/lib/mobilizon/discussions/comment.ex index 391ffb4f5..bba59586c 100644 --- a/lib/mobilizon/discussions/comment.ex +++ b/lib/mobilizon/discussions/comment.ex @@ -45,6 +45,7 @@ defmodule Mobilizon.Discussions.Comment do :attributed_to_id, :deleted_at, :local, + :is_announcement, :discussion_id ] @attrs @required_attrs ++ @optional_attrs @@ -58,6 +59,7 @@ defmodule Mobilizon.Discussions.Comment do field(:total_replies, :integer, virtual: true, default: 0) field(:deleted_at, :utc_datetime) field(:published_at, :utc_datetime) + field(:is_announcement, :boolean, default: false) belongs_to(:actor, Actor, foreign_key: :actor_id) belongs_to(:attributed_to, Actor, foreign_key: :attributed_to_id) diff --git a/lib/mobilizon/users/activity_setting.ex b/lib/mobilizon/users/activity_setting.ex new file mode 100644 index 000000000..12f956c50 --- /dev/null +++ b/lib/mobilizon/users/activity_setting.ex @@ -0,0 +1,34 @@ +defmodule Mobilizon.Users.ActivitySetting do + @moduledoc """ + Module to manage users settings + """ + + use Ecto.Schema + import Ecto.Changeset + alias Mobilizon.Users.User + + @type t :: %__MODULE__{ + key: String.t(), + method: String.t(), + enabled: boolean() + } + + @attrs [:key, :method, :enabled, :user_id] + + @primary_key {:user_id, :id, autogenerate: false} + schema "user_activity_settings" do + field(:key, :string) + field(:method, :string) + field(:enabled, :boolean) + + belongs_to(:user, User, primary_key: true, type: :id, foreign_key: :id, define_field: false) + end + + @doc false + def changeset(activity_setting, attrs) do + activity_setting + |> cast(attrs, @attrs) + |> validate_required(@attrs) + |> unique_constraint([:key, :method], name: :user_activity_settings_user_id_key_method_index) + end +end diff --git a/lib/mobilizon/users/users.ex b/lib/mobilizon/users/users.ex index b20166aef..d747c06d8 100644 --- a/lib/mobilizon/users/users.ex +++ b/lib/mobilizon/users/users.ex @@ -13,7 +13,7 @@ defmodule Mobilizon.Users do alias Mobilizon.{Crypto, Events} alias Mobilizon.Events.FeedToken alias Mobilizon.Storage.{Page, Repo} - alias Mobilizon.Users.{PushSubscription, Setting, User} + alias Mobilizon.Users.{ActivitySetting, PushSubscription, Setting, User} defenum(UserRole, :user_role, [:administrator, :moderator, :user]) @@ -478,6 +478,48 @@ defmodule Mobilizon.Users do Repo.delete(push_subscription) end + @doc """ + Lists the activity settings for an user + + ## Examples + + iex> activity_settings_for_user(user) + [%ActivitySetting{}] + + iex> activity_settings_for_user(user) + [] + + """ + def activity_settings_for_user(%User{id: user_id}) do + ActivitySetting + |> where([a], a.user_id == ^user_id) + |> Repo.all() + end + + def activity_setting(%User{id: user_id}, key, method) do + ActivitySetting + |> where([a], a.user_id == ^user_id and a.key == ^key and a.method == ^method) + |> Repo.one() + end + + @doc """ + Creates an activity setting. Overrides existing values if present + + ## Examples + + iex> create_activity_setting(%{field: value}) + {:ok, %ActivitySetting{}} + + iex> create_activity_setting(%{field: bad_value}) + {:error, %Ecto.Changeset{}} + + """ + def create_activity_setting(attrs \\ %{}) do + %ActivitySetting{} + |> ActivitySetting.changeset(attrs) + |> Repo.insert(on_conflict: :replace_all, conflict_target: [:user_id, :key, :method]) + end + @spec user_by_email_query(String.t(), boolean | nil, boolean()) :: Ecto.Query.t() defp user_by_email_query(email, activated, unconfirmed) do User diff --git a/lib/service/activity/comment.ex b/lib/service/activity/comment.ex index 85c7a596c..af0cd7a13 100644 --- a/lib/service/activity/comment.ex +++ b/lib/service/activity/comment.ex @@ -2,12 +2,12 @@ defmodule Mobilizon.Service.Activity.Comment do @moduledoc """ Insert a comment activity """ - alias Mobilizon.{Actors, Discussions, Events} + alias Mobilizon.{Discussions, Events} alias Mobilizon.Actors.Actor alias Mobilizon.Discussions.Comment alias Mobilizon.Events.Event alias Mobilizon.Service.Activity - alias Mobilizon.Service.Workers.ActivityBuilder + alias Mobilizon.Service.Workers.{ActivityBuilder, LegacyNotifierBuilder} @behaviour Activity @@ -17,33 +17,21 @@ defmodule Mobilizon.Service.Activity.Comment do def insert_activity( %Comment{ actor_id: actor_id, - event_id: event_id, - in_reply_to_comment_id: in_reply_to_comment_id + event_id: event_id } = comment, options ) when not is_nil(actor_id) and not is_nil(event_id) do - with {:ok, %Event{attributed_to: %Actor{type: :Group} = group} = event} <- - Events.get_event_with_preload(event_id), - %Actor{id: actor_id} <- Actors.get_actor(actor_id), - subject <- Keyword.fetch!(options, :subject) do - ActivityBuilder.enqueue(:build_activity, %{ - "type" => "event", - "subject" => subject, - "subject_params" => %{ - event_title: event.title, - event_uuid: event.uuid, - comment_reply_to: !is_nil(in_reply_to_comment_id) - }, - "group_id" => group.id, - "author_id" => actor_id, - "object_type" => "comment", - "object_id" => to_string(comment.id), - "inserted_at" => DateTime.utc_now() - }) - else - # Event not from group - {:ok, %Event{}} -> {:ok, nil} + with {:ok, %Event{} = event} <- + Events.get_event_with_preload(event_id) do + # Notify the actors mentionned + notify_mentionned(comment, event) + + # Notify participants if there's a new announcement + notify_announcement(comment, event) + + # Notify event organizer or group that there's new comments + notify_organizer(comment, event, options) end end @@ -53,4 +41,116 @@ defmodule Mobilizon.Service.Activity.Comment do def get_object(comment_id) do Discussions.get_comment(comment_id) end + + defp notify_mentionned(%Comment{actor_id: actor_id, id: comment_id, mentions: mentions}, %Event{ + uuid: uuid, + title: title + }) + when length(mentions) > 0 do + LegacyNotifierBuilder.enqueue(:legacy_notify, %{ + "type" => :comment, + "subject" => :event_comment_mention, + "subject_params" => %{ + event_uuid: uuid, + event_title: title + }, + "author_id" => actor_id, + "object_type" => :comment, + "object_id" => to_string(comment_id), + "inserted_at" => DateTime.utc_now(), + "mentions" => Enum.map(mentions, & &1.actor_id) + }) + end + + defp notify_mentionned(_, _), do: {:ok, :skipped} + + defp notify_announcement( + %Comment{actor_id: actor_id, is_announcement: true, id: comment_id}, + %Event{ + id: event_id, + uuid: uuid, + title: title + } + ) do + LegacyNotifierBuilder.enqueue(:legacy_notify, %{ + "type" => :comment, + "subject" => :participation_event_comment, + "subject_params" => %{ + event_id: event_id, + event_uuid: uuid, + event_title: title + }, + "author_id" => actor_id, + "object_type" => :comment, + "object_id" => to_string(comment_id), + "inserted_at" => DateTime.utc_now() + }) + end + + defp notify_announcement(_, _), do: {:ok, :skipped} + + @spec notify_organizer(Comment.t(), Event.t(), Keyword.t()) :: + {:ok, Oban.Job.t()} | {:ok, :skipped} + defp notify_organizer( + %Comment{ + actor_id: actor_id, + is_announcement: true, + in_reply_to_comment_id: in_reply_to_comment_id, + id: comment_id + }, + %Event{ + uuid: uuid, + title: title, + attributed_to: %Actor{type: :Group, id: group_id} + }, + options + ) do + ActivityBuilder.enqueue(:build_activity, %{ + "type" => "event", + "subject" => Keyword.fetch!(options, :subject), + "subject_params" => %{ + event_title: title, + event_uuid: uuid, + comment_reply_to: !is_nil(in_reply_to_comment_id) + }, + "group_id" => group_id, + "author_id" => actor_id, + "object_type" => "comment", + "object_id" => to_string(comment_id), + "inserted_at" => DateTime.utc_now() + }) + end + + defp notify_organizer( + %Comment{ + actor_id: actor_id, + is_announcement: true, + in_reply_to_comment_id: in_reply_to_comment_id, + id: comment_id + }, + %Event{ + uuid: uuid, + title: title, + attributed_to: nil, + organizer_actor_id: organizer_actor_id + }, + _options + ) + when actor_id !== organizer_actor_id do + LegacyNotifierBuilder.enqueue(:legacy_notify, %{ + "type" => :comment, + "subject" => :event_new_comment, + "subject_params" => %{ + event_title: title, + event_uuid: uuid, + comment_reply_to: !is_nil(in_reply_to_comment_id) + }, + "author_id" => actor_id, + "object_type" => :comment, + "object_id" => to_string(comment_id), + "inserted_at" => DateTime.utc_now() + }) + end + + defp notify_organizer(_, _, _), do: {:ok, :skipped} end diff --git a/lib/service/activity/renderer/comment.ex b/lib/service/activity/renderer/comment.ex new file mode 100644 index 000000000..31febd367 --- /dev/null +++ b/lib/service/activity/renderer/comment.ex @@ -0,0 +1,111 @@ +defmodule Mobilizon.Service.Activity.Renderer.Comment do + @moduledoc """ + Insert a comment activity + """ + alias Mobilizon.Activities.Activity + alias Mobilizon.Actors.Actor + alias Mobilizon.Service.Activity.Renderer + alias Mobilizon.Web.{Endpoint, Gettext} + 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 + :event_comment_mention -> + %{ + body: + dgettext( + "activity", + "%{profile} mentionned you in a comment under event %{event}.", + %{ + profile: profile, + event: event_title(activity) + } + ), + url: event_url(activity) + } + + :participation_event_comment -> + %{ + body: + dgettext( + "activity", + "%{profile} has posted an announcement under event %{event}.", + %{ + profile: profile, + event: event_title(activity) + } + ), + url: event_url(activity) + } + + :discussion_mention -> + %{ + body: + dgettext("activity", "%{profile} mentionned you in the discussion %{discussion}.", %{ + profile: profile, + discussion: title(activity) + }), + url: discussion_url(activity) + } + + :discussion_renamed -> + %{ + body: + dgettext("activity", "%{profile} renamed the discussion %{discussion}.", %{ + profile: profile, + discussion: title(activity) + }), + url: discussion_url(activity) + } + + :discussion_archived -> + %{ + body: + dgettext("activity", "%{profile} archived the discussion %{discussion}.", %{ + profile: profile, + discussion: title(activity) + }), + url: discussion_url(activity) + } + + :discussion_deleted -> + %{ + body: + dgettext("activity", "%{profile} deleted the discussion %{discussion}.", %{ + profile: profile, + discussion: title(activity) + }), + url: nil + } + end + end + + defp discussion_url(activity) do + Routes.page_url( + Endpoint, + :discussion, + Actor.preferred_username_and_domain(activity.group), + activity.subject_params["discussion_slug"] + ) + end + + defp event_url(activity) do + Routes.page_url( + Endpoint, + :event, + activity.subject_params["event_uuid"] + ) + end + + defp profile(activity), do: Actor.display_name_and_username(activity.author) + defp event_title(activity), do: activity.subject_params["event_title"] + defp title(activity), do: activity.subject_params["discussion_title"] +end diff --git a/lib/service/activity/renderer/renderer.ex b/lib/service/activity/renderer/renderer.ex index 5a56edb52..475db67b5 100644 --- a/lib/service/activity/renderer/renderer.ex +++ b/lib/service/activity/renderer/renderer.ex @@ -5,7 +5,17 @@ defmodule Mobilizon.Service.Activity.Renderer do alias Mobilizon.Config alias Mobilizon.Activities.Activity - alias Mobilizon.Service.Activity.Renderer.{Discussion, Event, Group, Member, Post, Resource} + + alias Mobilizon.Service.Activity.Renderer.{ + Comment, + Discussion, + Event, + Group, + Member, + Post, + Resource + } + require Logger import Mobilizon.Web.Gettext, only: [dgettext: 3] @@ -41,6 +51,7 @@ defmodule Mobilizon.Service.Activity.Renderer do :member -> Member.render(activity, options) :post -> Post.render(activity, options) :resource -> Resource.render(activity, options) + :comment -> Comment.render(activity, options) _ -> nil end end diff --git a/lib/service/notifier/email.ex b/lib/service/notifier/email.ex index 2db6aa0b9..f182e02e8 100644 --- a/lib/service/notifier/email.ex +++ b/lib/service/notifier/email.ex @@ -5,7 +5,7 @@ defmodule Mobilizon.Service.Notifier.Email do alias Mobilizon.Activities.Activity alias Mobilizon.{Config, Users} alias Mobilizon.Service.Notifier - alias Mobilizon.Service.Notifier.Email + alias Mobilizon.Service.Notifier.{Email, Filter} alias Mobilizon.Users.{NotificationPendingNotificationDelay, Setting, User} alias Mobilizon.Web.Email.Activity, as: EmailActivity alias Mobilizon.Web.Email.Mailer @@ -17,6 +17,8 @@ defmodule Mobilizon.Service.Notifier.Email do Config.get(__MODULE__, :enabled) end + def send(user, activity, options \\ []) + @impl Notifier def send(%User{} = user, %Activity{} = activity, options) do Email.send(user, [activity], options) @@ -25,7 +27,9 @@ defmodule Mobilizon.Service.Notifier.Email do @impl Notifier def send(%User{email: email, locale: locale} = user, activities, options) when is_list(activities) do - if can_send?(user) do + activities = Enum.filter(activities, &can_send_activity?(&1, user)) + + if can_send?(user) && length(activities) > 0 do email |> EmailActivity.direct_activity(activities, Keyword.put(options, :locale, locale)) |> Mailer.send_email() @@ -37,6 +41,34 @@ defmodule Mobilizon.Service.Notifier.Email do end end + @spec can_send_activity?(Activity.t(), User.t()) :: boolean() + defp can_send_activity?(%Activity{} = activity, %User{} = user) do + Filter.can_send_activity?(activity, "email", user, &default_activity_behavior/1) + end + + @spec default_activity_behavior(String.t()) :: boolean() + defp default_activity_behavior(activity_setting) do + case activity_setting do + "participation_event_updated" -> true + "participation_event_comment" -> true + "event_new_pending_participation" -> true + "event_new_participation" -> false + "event_created" -> false + "event_updated" -> false + "discussion_updated" -> false + "post_published" -> false + "post_updated" -> false + "resource_updated" -> false + "member_request" -> true + "member_updated" -> false + "user_email_password_updated" -> true + "event_comment_mention" -> true + "discussion_mention" -> true + "event_new_comment" -> true + _ -> false + end + end + @type notification_type :: :group_notifications | :notification_pending_participation diff --git a/lib/service/notifier/filter.ex b/lib/service/notifier/filter.ex new file mode 100644 index 000000000..c763c4d6f --- /dev/null +++ b/lib/service/notifier/filter.ex @@ -0,0 +1,60 @@ +defmodule Mobilizon.Service.Notifier.Filter do + alias Mobilizon.Users + alias Mobilizon.Activities.Activity + alias Mobilizon.Users.{ActivitySetting, User} + + @type method :: String.t() + + @spec can_send_activity?(Activity.t(), method(), User.t(), function()) :: boolean() + def can_send_activity?(%Activity{} = activity, method, %User{} = user, get_default) do + case map_activity_to_activity_setting(activity) do + false -> false + key -> user |> Users.activity_setting(key, method) |> enabled?(key, get_default) + end + end + + @spec enabled?(ActivitySetting.t() | nil, String.t(), function()) :: boolean() + defp enabled?(nil, activity_setting, get_default), do: get_default.(activity_setting) + defp enabled?(%ActivitySetting{enabled: enabled}, _activity_setting, _get_default), do: enabled + + # Comment mention + defp map_activity_to_activity_setting(%Activity{subject: :event_comment_mention}), + do: "event_comment_mention" + + # Participation + @spec map_activity_to_activity_setting(Activity.t()) :: String.t() | false + defp map_activity_to_activity_setting(%Activity{subject: :participation_event_updated}), + do: "participation_event_updated" + + defp map_activity_to_activity_setting(%Activity{subject: :participation_event_comment}), + do: "participation_event_comment" + + # Organizers + defp map_activity_to_activity_setting(%Activity{subject: :event_new_pending_participation}), + do: "event_new_pending_participation" + + defp map_activity_to_activity_setting(%Activity{subject: :event_new_participation}), + do: "event_new_participation" + + # Event + defp map_activity_to_activity_setting(%Activity{subject: :event_created}), do: "event_created" + defp map_activity_to_activity_setting(%Activity{type: :event}), do: "event_updated" + + # Post + defp map_activity_to_activity_setting(%Activity{subject: :post_created}), do: "post_published" + defp map_activity_to_activity_setting(%Activity{type: :post}), do: "post_updated" + + # Discussion + defp map_activity_to_activity_setting(%Activity{type: :discussion}), do: "discussion_updated" + + # Resource + defp map_activity_to_activity_setting(%Activity{type: :resource}), do: "resource_updated" + + # Member + defp map_activity_to_activity_setting(%Activity{subject: :member_request}), + do: "member_request" + + defp map_activity_to_activity_setting(%Activity{type: :member}), do: "member" + + defp map_activity_to_activity_setting(_), do: false +end diff --git a/lib/service/notifier/push.ex b/lib/service/notifier/push.ex index 6043e0ebc..e604fa01f 100644 --- a/lib/service/notifier/push.ex +++ b/lib/service/notifier/push.ex @@ -6,7 +6,7 @@ defmodule Mobilizon.Service.Notifier.Push do alias Mobilizon.{Config, Users} alias Mobilizon.Service.Activity.{Renderer, Utils} alias Mobilizon.Service.Notifier - alias Mobilizon.Service.Notifier.Push + alias Mobilizon.Service.Notifier.{Filter, Push} alias Mobilizon.Storage.Page alias Mobilizon.Users.{PushSubscription, User} @@ -20,11 +20,16 @@ defmodule Mobilizon.Service.Notifier.Push do @impl Notifier def send(user, activity, options \\ []) - def send(%User{id: user_id, locale: locale} = _user, %Activity{} = activity, options) do - options = Keyword.put_new(options, :locale, locale) + def send(%User{id: user_id, locale: locale} = user, %Activity{} = activity, options) do + if can_send_activity?(activity, user) do + options = Keyword.put_new(options, :locale, locale) - %Page{elements: subscriptions} = Users.list_user_push_subscriptions(user_id, 1, 100) - Enum.map(subscriptions, &send_subscription(activity, convert_subscription(&1), options)) + %Page{elements: subscriptions} = Users.list_user_push_subscriptions(user_id, 1, 100) + Enum.each(subscriptions, &send_subscription(activity, convert_subscription(&1), options)) + {:ok, :sent} + else + {:ok, :skipped} + end end @impl Notifier @@ -32,6 +37,34 @@ defmodule Mobilizon.Service.Notifier.Push do Enum.map(activities, &Push.send(user, &1, options)) end + @spec can_send_activity?(Activity.t(), User.t()) :: boolean() + defp can_send_activity?(%Activity{} = activity, %User{} = user) do + Filter.can_send_activity?(activity, "push", user, &default_activity_behavior/1) + end + + @spec default_activity_behavior(String.t()) :: boolean() + defp default_activity_behavior(activity_setting) do + case activity_setting do + "participation_event_updated" -> true + "participation_event_comment" -> true + "event_new_pending_participation" -> true + "event_new_participation" -> false + "event_created" -> false + "event_updated" -> false + "discussion_updated" -> false + "post_published" -> false + "post_updated" -> false + "resource_updated" -> false + "member_request" -> true + "member_updated" -> false + "user_email_password_updated" -> false + "event_comment_mention" -> true + "discussion_mention" -> false + "event_new_comment" -> false + _ -> false + end + end + defp send_subscription(activity, subscription, options) do activity |> payload(options) diff --git a/lib/service/workers/legacy_notifier_builder.ex b/lib/service/workers/legacy_notifier_builder.ex new file mode 100644 index 000000000..af4305e8f --- /dev/null +++ b/lib/service/workers/legacy_notifier_builder.ex @@ -0,0 +1,71 @@ +defmodule Mobilizon.Service.Workers.LegacyNotifierBuilder do + @moduledoc """ + Worker to push legacy notifications + """ + + alias Mobilizon.{Actors, Events, Users} + alias Mobilizon.Activities.Activity + alias Mobilizon.Service.Notifier + + use Mobilizon.Service.Workers.Helper, queue: "activity" + + @impl Oban.Worker + def perform(%Job{args: args}) do + with {"legacy_notify", args} <- Map.pop(args, "op") do + activity = build_activity(args) + + args + |> users_to_notify(args["author_id"]) + |> Enum.each(&Notifier.notify(&1, activity, single_activity: true)) + end + end + + def build_activity(args) do + author = Actors.get_actor(args["author_id"]) + + %Activity{ + type: String.to_existing_atom(args["type"]), + subject: String.to_existing_atom(args["subject"]), + subject_params: args["subject_params"], + inserted_at: DateTime.utc_now(), + object_type: String.to_existing_atom(args["object_type"]), + object_id: args["object_id"], + group: nil, + author: author + } + end + + @spec users_to_notify(map(), integer() | String.t()) :: list(Users.t()) + defp users_to_notify( + %{"subject" => "event_comment_mention", "mentions" => mentionned_actor_ids}, + author_id + ) do + users_from_actor_ids(mentionned_actor_ids, author_id) + end + + defp users_to_notify( + %{ + "subject" => "participation_event_comment", + "subject_params" => subject_params + }, + author_id + ) do + subject_params + |> Map.get("event_id") + |> Events.list_actors_participants_for_event() + |> Enum.map(& &1.id) + |> users_from_actor_ids(author_id) + end + + @spec users_from_actor_ids(list(), integer() | String.t()) :: list(Users.t()) + defp users_from_actor_ids(actor_ids, author_id) do + actor_ids + |> Enum.filter(&(&1 != author_id)) + |> Enum.map(&Actors.get_actor/1) + |> Enum.filter(& &1) + |> Enum.map(& &1.user_id) + |> Enum.filter(& &1) + |> Enum.uniq() + |> Enum.map(&Users.get_user_with_settings!/1) + end +end diff --git a/lib/web/email/activity.ex b/lib/web/email/activity.ex index fecdc0d57..d520d75bd 100644 --- a/lib/web/email/activity.ex +++ b/lib/web/email/activity.ex @@ -43,8 +43,15 @@ defmodule Mobilizon.Web.Email.Activity do @spec chunk_activities(list()) :: map() defp chunk_activities(activities) do activities - |> Enum.reduce(%{}, fn %Activity{group: %Actor{id: group_id}} = activity, acc -> - Map.update(acc, group_id, [activity], fn activities -> activities ++ [activity] end) + |> Enum.reduce(%{}, fn activity, acc -> + case activity do + %Activity{group: %Actor{id: group_id}} -> + Map.update(acc, group_id, [activity], fn activities -> activities ++ [activity] end) + + # Not a group activity + %Activity{} -> + Map.update(acc, nil, [activity], fn activities -> activities ++ [activity] end) + end end) |> Enum.map(fn {key, value} -> {key, Enum.sort(value, &(&1.inserted_at <= &2.inserted_at))} @@ -57,20 +64,34 @@ defmodule Mobilizon.Web.Email.Activity do # so it will probably not catch much things @spec filter_duplicates(list()) :: list() defp filter_duplicates(activities) do - Enum.uniq_by(activities, fn %Activity{ - author: %Actor{id: author_id}, - group: %Actor{id: group_id}, - type: type, - subject: subject, - subject_params: subject_params - } -> - %{ - author_id: author_id, - group_id: group_id, - type: type, - subject: subject, - subject_params: subject_params - } + Enum.uniq_by(activities, fn activity -> + case activity do + %Activity{ + author: %Actor{id: author_id}, + group: %Actor{id: group_id}, + type: type, + subject: subject, + subject_params: subject_params + } -> + %{ + author_id: author_id, + group_id: group_id, + type: type, + subject: subject, + subject_params: subject_params + } + + %Activity{ + type: type, + subject: subject, + subject_params: subject_params + } -> + %{ + type: type, + subject: subject, + subject_params: subject_params + } + end end) end end diff --git a/lib/web/templates/email/activity/_comment_activity_item.html.eex b/lib/web/templates/email/activity/_comment_activity_item.html.eex index 9b779ea4c..135a194e1 100644 --- a/lib/web/templates/email/activity/_comment_activity_item.html.eex +++ b/lib/web/templates/email/activity/_comment_activity_item.html.eex @@ -67,4 +67,35 @@ discussion: "<b>#{@activity.subject_params["discussion_title"]}</b>" } ) |> raw %> + + <% :event_comment_mention -> %> + <%= + dgettext("activity", "%{profile} mentionned you in a comment under event %{event}.", + %{ + profile: "<b>#{Mobilizon.Actors.Actor.display_name_and_username(@activity.author)}</b>", + event: "<a href=\"#{ + page_url( + Mobilizon.Web.Endpoint, + :event, + @activity.subject_params["event_uuid"] + ) |> URI.decode()}\"> + #{@activity.subject_params["event_title"]} + </a>" + } + ) |> raw %> + <% :participation_event_comment -> %> + <%= + dgettext("activity", "%{profile} has posted an announcement under event %{event}.", + %{ + profile: "<b>#{Mobilizon.Actors.Actor.display_name_and_username(@activity.author)}</b>", + event: "<a href=\"#{ + page_url( + Mobilizon.Web.Endpoint, + :event, + @activity.subject_params["event_uuid"] + ) |> URI.decode()}\"> + #{@activity.subject_params["event_title"]} + </a>" + } + ) |> raw %> <% end %> \ No newline at end of file diff --git a/lib/web/templates/email/activity/_comment_activity_item.text.eex b/lib/web/templates/email/activity/_comment_activity_item.text.eex index 463fbde46..a3fdd1a05 100644 --- a/lib/web/templates/email/activity/_comment_activity_item.text.eex +++ b/lib/web/templates/email/activity/_comment_activity_item.text.eex @@ -27,4 +27,17 @@ profile: Mobilizon.Actors.Actor.display_name_and_username(@activity.author), discussion: @activity.subject_params["discussion_title"] } -) %><% end %> \ No newline at end of file +) %> +<%= page_url(Mobilizon.Web.Endpoint, :discussion, Mobilizon.Actors.Actor.preferred_username_and_domain(@activity.group), @activity.subject_params["discussion_slug"]) |> URI.decode() %><% :event_comment_mention -> %><%= dgettext("activity", "%{profile} mentionned you in a comment under %{event}.", + %{ + profile: Mobilizon.Actors.Actor.display_name_and_username(@activity.author), + event: @activity.subject_params["event_title"] + } +) %> +<%= page_url(Mobilizon.Web.Endpoint, :event, @activity.subject_params["event_uuid"]) |> URI.decode() %><% :participation_event_comment -> %><%= dgettext("activity", "%{profile} has posted an announcement under event %{event}.", + %{ + profile: Mobilizon.Actors.Actor.display_name_and_username(@activity.author), + event: @activity.subject_params["event_title"] + } +) %> +<%= page_url(Mobilizon.Web.Endpoint, :event, @activity.subject_params["event_uuid"]) |> URI.decode() %><% end %> \ No newline at end of file diff --git a/lib/web/templates/email/email_direct_activity.html.eex b/lib/web/templates/email/email_direct_activity.html.eex index 109cd37e8..36de71308 100644 --- a/lib/web/templates/email/email_direct_activity.html.eex +++ b/lib/web/templates/email/email_direct_activity.html.eex @@ -47,43 +47,45 @@ <ul style="margin: 0 auto; padding-left: 15px;"> <%= for {_, group_activities} <- @activities do %> <li style="list-style: none;border-bottom: solid 2px #d7d6de;padding: 10px 0;"> - <table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%"> - <tr> - <td align="left"> - <table align="left"> - <tr> - <%= if hd(group_activities).group.avatar do %> - <td width="85"> - <a href="<%= page_url(Mobilizon.Web.Endpoint, :actor, Mobilizon.Actors.Actor.preferred_username_and_domain(hd(group_activities).group)) |> URI.decode() %>" target="_blank" style="text-decoration: none;"> - <img width="80" src="<%= hd(group_activities).group.avatar.url %>" style="width: 80px;max-height: 100px;" style="margin:0; padding:0; border:none; display:block;" border="0" alt="" /> - </a> - </td> - <% end %> - <td width="400"> - <table width="" cellpadding="0" cellspacing="0" border="0" style="max-width: 400px;width: 100%;" align="left"> - <tr> - <td align="left"> - <a href="<%= page_url(Mobilizon.Web.Endpoint, :actor, Mobilizon.Actors.Actor.preferred_username_and_domain(hd(group_activities).group)) |> URI.decode() %>" target="_blank" style="text-decoration: none;color: #474467;font-family: 'Roboto', Helvetica, Arial, sans-serif;font-size: 18px;font-weight: bold;line-height: 25px;"> - <%= hd(group_activities).group.name || "@#{Mobilizon.Actors.Actor.preferred_username_and_domain(hd(group_activities).group)}" %> - </a> - </td> - </tr> - <%= if hd(group_activities).group.name do %> + <%= if hd(group_activities).group do %> + <table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%"> + <tr> + <td align="left"> + <table align="left"> + <tr> + <%= if hd(group_activities).group.avatar do %> + <td width="85"> + <a href="<%= page_url(Mobilizon.Web.Endpoint, :actor, Mobilizon.Actors.Actor.preferred_username_and_domain(hd(group_activities).group)) |> URI.decode() %>" target="_blank" style="text-decoration: none;"> + <img width="80" src="<%= hd(group_activities).group.avatar.url %>" style="width: 80px;max-height: 100px;" style="margin:0; padding:0; border:none; display:block;" border="0" alt="" /> + </a> + </td> + <% end %> + <td width="400"> + <table width="" cellpadding="0" cellspacing="0" border="0" style="max-width: 400px;width: 100%;" align="left"> <tr> <td align="left"> - <a href="<%= page_url(Mobilizon.Web.Endpoint, :actor, Mobilizon.Actors.Actor.preferred_username_and_domain(hd(group_activities).group)) |> URI.decode() %>" target="_blank" style="text-decoration: none;display: block;color: #7a7a7a;font-family: 'Roboto', Helvetica, Arial, sans-serif;font-size: 16px;font-weight: 400;line-height: 25px;"> - @<%= Mobilizon.Actors.Actor.preferred_username_and_domain(hd(group_activities).group) %> + <a href="<%= page_url(Mobilizon.Web.Endpoint, :actor, Mobilizon.Actors.Actor.preferred_username_and_domain(hd(group_activities).group)) |> URI.decode() %>" target="_blank" style="text-decoration: none;color: #474467;font-family: 'Roboto', Helvetica, Arial, sans-serif;font-size: 18px;font-weight: bold;line-height: 25px;"> + <%= hd(group_activities).group.name || "@#{Mobilizon.Actors.Actor.preferred_username_and_domain(hd(group_activities).group)}" %> </a> </td> </tr> - <% end %> - </table> - </td> - </tr> - </table> - </td> - </tr> - </table> + <%= if hd(group_activities).group.name do %> + <tr> + <td align="left"> + <a href="<%= page_url(Mobilizon.Web.Endpoint, :actor, Mobilizon.Actors.Actor.preferred_username_and_domain(hd(group_activities).group)) |> URI.decode() %>" target="_blank" style="text-decoration: none;display: block;color: #7a7a7a;font-family: 'Roboto', Helvetica, Arial, sans-serif;font-size: 16px;font-weight: 400;line-height: 25px;"> + @<%= Mobilizon.Actors.Actor.preferred_username_and_domain(hd(group_activities).group) %> + </a> + </td> + </tr> + <% end %> + </table> + </td> + </tr> + </table> + </td> + </tr> + </table> + <% end %> <ul style="padding-left: 25px;margin-top: 10px;"> <%= for activity <- Enum.take(group_activities, 5) do %> <li style="margin-bottom: 7px;"> diff --git a/lib/web/templates/email/email_direct_activity.text.eex b/lib/web/templates/email/email_direct_activity.text.eex index 4b8a65809..49fb3a607 100644 --- a/lib/web/templates/email/email_direct_activity.text.eex +++ b/lib/web/templates/email/email_direct_activity.text.eex @@ -6,7 +6,9 @@ <%= for {_, group_activities} <- @activities do %> == +<%= if hd(group_activities).group do %> <%= hd(group_activities).group.name || "@#{Mobilizon.Actors.Actor.preferred_username_and_domain(hd(group_activities).group)}" %> +<% end %> <%= for activity <- Enum.take(group_activities, 5) do %> * <%= case activity.type do %><% :discussion -> %><%= render("activity/_discussion_activity_item.text", activity: activity) %><% :event -> %><%= render("activity/_event_activity_item.text", activity: activity) %><% :group -> %><%= render("activity/_group_activity_item.text", activity: activity) %> diff --git a/priv/repo/migrations/20210505172402_add_group_notification_and_last_notification_date_settings.exs b/priv/repo/migrations/20210505172402_add_group_notification_and_last_notification_date_settings.exs index bd22dc9e2..f5261b795 100644 --- a/priv/repo/migrations/20210505172402_add_group_notification_and_last_notification_date_settings.exs +++ b/priv/repo/migrations/20210505172402_add_group_notification_and_last_notification_date_settings.exs @@ -3,8 +3,8 @@ defmodule Mobilizon.Storage.Repo.Migrations.AddGroupNotificationAndLastNotificat def change do alter table(:user_settings) do - add(:group_notifications, :integer, default: 10, nullable: false) - add(:last_notification_sent, :utc_datetime, nullable: true) + add(:group_notifications, :integer, default: 10, null: false) + add(:last_notification_sent, :utc_datetime, null: true) end end end diff --git a/priv/repo/migrations/20210526203337_add_user_activity_settings.exs b/priv/repo/migrations/20210526203337_add_user_activity_settings.exs new file mode 100644 index 000000000..2d28de0c2 --- /dev/null +++ b/priv/repo/migrations/20210526203337_add_user_activity_settings.exs @@ -0,0 +1,15 @@ +defmodule Mobilizon.Storage.Repo.Migrations.AddUserActivitySettings do + use Ecto.Migration + + def change do + create table(:user_activity_settings) do + add(:key, :string, nulla: false) + add(:method, :string, null: false) + add(:enabled, :boolean, null: false) + + add(:user_id, references(:users, on_delete: :delete_all), null: false) + end + + create(unique_index(:user_activity_settings, [:user_id, :key, :method])) + end +end diff --git a/priv/repo/migrations/20210601082412_add_is_announcement_to_comments.exs b/priv/repo/migrations/20210601082412_add_is_announcement_to_comments.exs new file mode 100644 index 000000000..b3e1d312c --- /dev/null +++ b/priv/repo/migrations/20210601082412_add_is_announcement_to_comments.exs @@ -0,0 +1,9 @@ +defmodule Mobilizon.Storage.Repo.Migrations.AddIsAnnouncementToComments do + use Ecto.Migration + + def change do + alter table(:comments) do + add(:is_announcement, :boolean, default: false, null: false) + end + end +end diff --git a/priv/repo/migrations/20210601082613_fix_user_settings_nullable_fields.exs b/priv/repo/migrations/20210601082613_fix_user_settings_nullable_fields.exs new file mode 100644 index 000000000..46438b83c --- /dev/null +++ b/priv/repo/migrations/20210601082613_fix_user_settings_nullable_fields.exs @@ -0,0 +1,10 @@ +defmodule Mobilizon.Storage.Repo.Migrations.FixUserSettingsNullableFields do + use Ecto.Migration + + def change do + alter table(:user_settings) do + modify(:group_notifications, :integer, default: 10, null: false) + modify(:last_notification_sent, :utc_datetime, null: true) + end + end +end