From 6d80bf43ea75d93cb7987792fa46974f77d330e8 Mon Sep 17 00:00:00 2001 From: Thomas Citharel Date: Fri, 26 Jul 2019 11:30:28 +0200 Subject: [PATCH] Refactor adding tags to an event Also refactor extracting tags from content, now uses Pleroma's Formatter Signed-off-by: Thomas Citharel --- config/config.exs | 12 + js/src/App.vue | 3 + js/src/components/Event/TagInput.vue | 54 + js/src/graphql/event.ts | 12 +- js/src/graphql/tags.ts | 16 + js/src/types/event.model.ts | 3 + js/src/views/Event/Create.vue | 19 +- lib/mobilizon/events/event.ex | 1 - lib/mobilizon/events/events.ex | 37 +- lib/mobilizon_web/api/comments.ex | 18 +- lib/mobilizon_web/api/events.ex | 20 +- lib/mobilizon_web/api/groups.ex | 17 +- lib/mobilizon_web/api/utils.ex | 108 +- lib/mobilizon_web/resolvers/tag.ex | 11 +- lib/mobilizon_web/schema/event.ex | 5 + lib/service/activity_pub/converters/event.ex | 20 +- lib/service/activity_pub/utils.ex | 4 +- lib/service/formatter/formatter.ex | 205 +-- mix.exs | 3 + mix.lock | 1 + schema.graphql | 1595 ++++++++++------- .../service/formatter/formatter_test.exs | 205 +++ .../resolvers/event_resolver_test.exs | 38 + 23 files changed, 1543 insertions(+), 864 deletions(-) create mode 100644 js/src/components/Event/TagInput.vue create mode 100644 js/src/graphql/tags.ts create mode 100644 test/mobilizon/service/formatter/formatter_test.exs diff --git a/config/config.exs b/config/config.exs index 9982acc95..5626222b5 100644 --- a/config/config.exs +++ b/config/config.exs @@ -94,6 +94,18 @@ config :geolix, } ] +config :auto_linker, + opts: [ + scheme: true, + extra: true, + # TODO: Set to :no_scheme when it works properly + validate_tld: true, + class: false, + strip_prefix: false, + new_window: false, + rel: false + ] + config :phoenix, :format_encoders, json: Jason, "activity-json": Jason config :phoenix, :json_library, Jason diff --git a/js/src/App.vue b/js/src/App.vue index ef438c0c6..183adb48c 100644 --- a/js/src/App.vue +++ b/js/src/App.vue @@ -72,6 +72,7 @@ export default class App extends Vue { @import "~bulma/sass/components/modal.sass"; @import "~bulma/sass/components/navbar.sass"; @import "~bulma/sass/components/pagination.sass"; + @import "~bulma/sass/components/dropdown.sass"; @import "~bulma/sass/elements/box.sass"; @import "~bulma/sass/elements/button.sass"; @import "~bulma/sass/elements/container.sass"; @@ -91,9 +92,11 @@ export default class App extends Vue { @import "~buefy/src/scss/components/datepicker"; @import "~buefy/src/scss/components/notices"; @import "~buefy/src/scss/components/dropdown"; + @import "~buefy/src/scss/components/autocomplete"; @import "~buefy/src/scss/components/form"; @import "~buefy/src/scss/components/modal"; @import "~buefy/src/scss/components/tag"; + @import "~buefy/src/scss/components/taginput"; @import "~buefy/src/scss/components/upload"; .router-enter-active, diff --git a/js/src/components/Event/TagInput.vue b/js/src/components/Event/TagInput.vue new file mode 100644 index 000000000..0a5f556df --- /dev/null +++ b/js/src/components/Event/TagInput.vue @@ -0,0 +1,54 @@ + + diff --git a/js/src/graphql/event.ts b/js/src/graphql/event.ts index 563419a0b..a7174dcb5 100644 --- a/js/src/graphql/event.ts +++ b/js/src/graphql/event.ts @@ -143,7 +143,8 @@ export const CREATE_EVENT = gql` $organizerActorId: ID!, $category: String!, $beginsOn: DateTime!, - $picture: PictureInput! + $picture: PictureInput, + $tags: [String] ) { createEvent( title: $title, @@ -151,7 +152,8 @@ export const CREATE_EVENT = gql` beginsOn: $beginsOn, organizerActorId: $organizerActorId, category: $category, - picture: $picture + picture: $picture, + tags: $tags ) { id, uuid, @@ -203,8 +205,10 @@ export const LEAVE_EVENT = gql` export const DELETE_EVENT = gql` mutation DeleteEvent($id: Int!, $actorId: Int!) { deleteEvent( - id: $id, + eventId: $id, actorId: $actorId - ) + ) { + id + } } `; diff --git a/js/src/graphql/tags.ts b/js/src/graphql/tags.ts new file mode 100644 index 000000000..86da14f34 --- /dev/null +++ b/js/src/graphql/tags.ts @@ -0,0 +1,16 @@ +import gql from 'graphql-tag'; + +export const TAGS = gql` +query { + tags { + id, + related { + id, + slug, + title + } + slug, + title + } +} +`; diff --git a/js/src/types/event.model.ts b/js/src/types/event.model.ts index bcd702e24..50f1eda79 100644 --- a/js/src/types/event.model.ts +++ b/js/src/types/event.model.ts @@ -75,6 +75,8 @@ export interface IEvent { onlineAddress?: string; phoneAddress?: string; physicalAddress?: IAddress; + + tags: ITag[]; } @@ -99,4 +101,5 @@ export class EventModel implements IEvent { onlineAddress: string = ''; phoneAddress: string = ''; picture: IAbstractPicture|null = null; + tags: ITag[] = []; } diff --git a/js/src/views/Event/Create.vue b/js/src/views/Event/Create.vue index c79991b0d..4c0ce83e5 100644 --- a/js/src/views/Event/Create.vue +++ b/js/src/views/Event/Create.vue @@ -6,10 +6,14 @@
Loading...
+ + + + @@ -28,8 +32,6 @@ - - @@ -52,13 +54,19 @@ import { IPerson, Person } from '@/types/actor'; import PictureUpload from '@/components/PictureUpload.vue'; import Editor from '@/components/Editor.vue'; import DateTimePicker from '@/components/Event/DateTimePicker.vue'; +import TagInput from '@/components/Event/TagInput.vue'; +import { TAGS } from '@/graphql/tags'; +import { ITag } from '@/types/tag.model'; @Component({ - components: { DateTimePicker, PictureUpload, Editor }, + components: { TagInput, DateTimePicker, PictureUpload, Editor }, apollo: { loggedPerson: { query: LOGGED_PERSON, }, + tags: { + query: TAGS, + }, }, }) export default class CreateEvent extends Vue { @@ -123,11 +131,12 @@ export default class CreateEvent extends Vue { * Transform general variables */ let pictureObj = {}; - let obj = { + const obj = { organizerActorId: this.loggedPerson.id, beginsOn: this.event.beginsOn.toISOString(), + tags: this.event.tags.map((tag: ITag) => tag.title), }; - let res = Object.assign({}, this.event, obj); + const res = Object.assign({}, this.event, obj); /** * Transform picture files diff --git a/lib/mobilizon/events/event.ex b/lib/mobilizon/events/event.ex index 2bc7442fd..95005bb72 100644 --- a/lib/mobilizon/events/event.ex +++ b/lib/mobilizon/events/event.ex @@ -86,7 +86,6 @@ defmodule Mobilizon.Events.Event do :uuid, :picture_id ]) - |> cast_assoc(:tags) |> cast_assoc(:physical_address) |> validate_required([ :title, diff --git a/lib/mobilizon/events/events.ex b/lib/mobilizon/events/events.ex index f02a998bb..31908182d 100644 --- a/lib/mobilizon/events/events.ex +++ b/lib/mobilizon/events/events.ex @@ -367,7 +367,7 @@ defmodule Mobilizon.Events do """ def create_event(attrs \\ %{}) do - with {:ok, %Event{} = event} <- %Event{} |> Event.changeset(attrs) |> Repo.insert(), + with %Event{} = event <- do_create_event(attrs), {:ok, %Participant{} = _participant} <- %Participant{} |> Participant.changeset(%{ @@ -376,7 +376,24 @@ defmodule Mobilizon.Events do event_id: event.id }) |> Repo.insert() do - {:ok, Repo.preload(event, [:organizer_actor])} + {:ok, event} + end + end + + defp do_create_event(attrs) do + with {:ok, %Event{} = event} <- %Event{} |> Event.changeset(attrs) |> Repo.insert(), + %Event{} = event <- event |> Repo.preload([:tags, :organizer_actor]), + {:has_tags, true, _} <- {:has_tags, Map.has_key?(attrs, "tags"), event} do + event + |> Ecto.Changeset.change() + |> Ecto.Changeset.put_assoc(:tags, attrs["tags"]) + |> Repo.update() + else + {:has_tags, false, event} -> + event + + error -> + error end end @@ -491,6 +508,22 @@ defmodule Mobilizon.Events do """ def get_tag!(id), do: Repo.get!(Tag, id) + def get_tag(id), do: Repo.get(Tag, id) + + @doc """ + Get an existing tag or create one + """ + @spec get_or_create_tag(String.t()) :: {:ok, Tag.t()} | {:error, any()} + def get_or_create_tag(title) do + case Repo.get_by(Tag, title: title) do + %Tag{} = tag -> + {:ok, tag} + + nil -> + create_tag(%{"title" => title}) + end + end + @doc """ Creates a tag. diff --git a/lib/mobilizon_web/api/comments.ex b/lib/mobilizon_web/api/comments.ex index ad310de74..6680bd420 100644 --- a/lib/mobilizon_web/api/comments.ex +++ b/lib/mobilizon_web/api/comments.ex @@ -6,10 +6,9 @@ defmodule MobilizonWeb.API.Comments do alias Mobilizon.Actors alias Mobilizon.Actors.Actor alias Mobilizon.Events.Comment - alias Mobilizon.Service.Formatter alias Mobilizon.Service.ActivityPub alias Mobilizon.Service.ActivityPub.Utils, as: ActivityPubUtils - import MobilizonWeb.API.Utils + alias MobilizonWeb.API.Utils @doc """ Create a comment @@ -20,23 +19,14 @@ defmodule MobilizonWeb.API.Comments do def create_comment( from_username, status, - visibility \\ "public", + visibility \\ :public, in_reply_to_comment_URL \\ nil ) do with {:local_actor, %Actor{url: url} = actor} <- {:local_actor, Actors.get_local_actor_by_name(from_username)}, - status <- String.trim(status), - mentions <- Formatter.parse_mentions(status), in_reply_to_comment <- get_in_reply_to_comment(in_reply_to_comment_URL), - {to, cc} <- to_for_actor_and_mentions(actor, mentions, in_reply_to_comment, visibility), - tags <- Formatter.parse_tags(status), - content_html <- - make_content_html( - status, - mentions, - tags, - "text/plain" - ), + {content_html, tags, to, cc} <- + Utils.prepare_content(actor, status, visibility, [], in_reply_to_comment), comment <- ActivityPubUtils.make_comment_data( url, diff --git a/lib/mobilizon_web/api/events.ex b/lib/mobilizon_web/api/events.ex index e5873800a..aface5cf2 100644 --- a/lib/mobilizon_web/api/events.ex +++ b/lib/mobilizon_web/api/events.ex @@ -4,10 +4,9 @@ defmodule MobilizonWeb.API.Events do """ alias Mobilizon.Actors alias Mobilizon.Actors.Actor - alias Mobilizon.Service.Formatter alias Mobilizon.Service.ActivityPub alias Mobilizon.Service.ActivityPub.Utils, as: ActivityPubUtils - import MobilizonWeb.API.Utils + alias MobilizonWeb.API.Utils @doc """ Create an event @@ -19,24 +18,19 @@ defmodule MobilizonWeb.API.Events do description: description, organizer_actor_id: organizer_actor_id, begins_on: begins_on, - category: category + category: category, + tags: tags } = args ) do + require Logger + with %Actor{url: url} = actor <- Actors.get_local_actor_with_everything(organizer_actor_id), title <- String.trim(title), - mentions <- Formatter.parse_mentions(description), visibility <- Map.get(args, :visibility, :public), - {to, cc} <- to_for_actor_and_mentions(actor, mentions, nil, Atom.to_string(visibility)), - tags <- Formatter.parse_tags(description), picture <- Map.get(args, :picture, nil), - content_html <- - make_content_html( - description, - mentions, - tags, - "text/plain" - ), + {content_html, tags, to, cc} <- + Utils.prepare_content(actor, description, visibility, tags, nil), event <- ActivityPubUtils.make_event_data( url, diff --git a/lib/mobilizon_web/api/groups.ex b/lib/mobilizon_web/api/groups.ex index 71c556a8f..7daf2aa7b 100644 --- a/lib/mobilizon_web/api/groups.ex +++ b/lib/mobilizon_web/api/groups.ex @@ -4,10 +4,9 @@ defmodule MobilizonWeb.API.Groups do """ alias Mobilizon.Actors alias Mobilizon.Actors.Actor - alias Mobilizon.Service.Formatter alias Mobilizon.Service.ActivityPub alias Mobilizon.Service.ActivityPub.Utils, as: ActivityPubUtils - import MobilizonWeb.API.Utils + alias MobilizonWeb.API.Utils @doc """ Create a group @@ -24,17 +23,9 @@ defmodule MobilizonWeb.API.Groups do {:bad_actor, Actors.get_local_actor_by_name(admin_actor_username)}, {:existing_group, nil} <- {:existing_group, Actors.get_group_by_title(title)}, title <- String.trim(title), - mentions <- Formatter.parse_mentions(description), - visibility <- Map.get(args, :visibility, "public"), - {to, cc} <- to_for_actor_and_mentions(actor, mentions, nil, visibility), - tags <- Formatter.parse_tags(description), - content_html <- - make_content_html( - description, - mentions, - tags, - "text/plain" - ), + visibility <- Map.get(args, :visibility, :public), + {content_html, tags, to, cc} <- + Utils.prepare_content(actor, description, visibility, [], nil), group <- ActivityPubUtils.make_group_data( url, diff --git a/lib/mobilizon_web/api/utils.ex b/lib/mobilizon_web/api/utils.ex index c06e641a4..49d3cfb1a 100644 --- a/lib/mobilizon_web/api/utils.ex +++ b/lib/mobilizon_web/api/utils.ex @@ -12,11 +12,9 @@ defmodule MobilizonWeb.API.Utils do * `to` : the mentionned actors, the eventual actor we're replying to and the public * `cc` : the actor's followers """ - @spec to_for_actor_and_mentions(Actor.t(), list(), map(), String.t()) :: {list(), list()} - def to_for_actor_and_mentions(%Actor{} = actor, mentions, inReplyTo, "public") do - mentioned_actors = Enum.map(mentions, fn {_, %{url: url}} -> url end) - - to = ["https://www.w3.org/ns/activitystreams#Public" | mentioned_actors] + @spec get_to_and_cc(Actor.t(), list(), map(), String.t()) :: {list(), list()} + def get_to_and_cc(%Actor{} = actor, mentions, inReplyTo, :public) do + to = ["https://www.w3.org/ns/activitystreams#Public" | mentions] cc = [actor.followers_url] if inReplyTo do @@ -33,11 +31,9 @@ defmodule MobilizonWeb.API.Utils do * `to` : the mentionned actors, actor's followers and the eventual actor we're replying to * `cc` : public """ - @spec to_for_actor_and_mentions(Actor.t(), list(), map(), String.t()) :: {list(), list()} - def to_for_actor_and_mentions(%Actor{} = actor, mentions, inReplyTo, "unlisted") do - mentioned_actors = Enum.map(mentions, fn {_, %{url: url}} -> url end) - - to = [actor.followers_url | mentioned_actors] + @spec get_to_and_cc(Actor.t(), list(), map(), String.t()) :: {list(), list()} + def get_to_and_cc(%Actor{} = actor, mentions, inReplyTo, :unlisted) do + to = [actor.followers_url | mentions] cc = ["https://www.w3.org/ns/activitystreams#Public"] if inReplyTo do @@ -54,9 +50,9 @@ defmodule MobilizonWeb.API.Utils do * `to` : the mentionned actors, actor's followers and the eventual actor we're replying to * `cc` : none """ - @spec to_for_actor_and_mentions(Actor.t(), list(), map(), String.t()) :: {list(), list()} - def to_for_actor_and_mentions(%Actor{} = actor, mentions, inReplyTo, "private") do - {to, cc} = to_for_actor_and_mentions(actor, mentions, inReplyTo, "direct") + @spec get_to_and_cc(Actor.t(), list(), map(), String.t()) :: {list(), list()} + def get_to_and_cc(%Actor{} = actor, mentions, inReplyTo, :private) do + {to, cc} = get_to_and_cc(actor, mentions, inReplyTo, :direct) {[actor.followers_url | to], cc} end @@ -67,59 +63,62 @@ defmodule MobilizonWeb.API.Utils do * `to` : the mentionned actors and the eventual actor we're replying to * `cc` : none """ - @spec to_for_actor_and_mentions(Actor.t(), list(), map(), String.t()) :: {list(), list()} - def to_for_actor_and_mentions(_actor, mentions, inReplyTo, "direct") do - mentioned_actors = Enum.map(mentions, fn {_, %{url: url}} -> url end) - + @spec get_to_and_cc(Actor.t(), list(), map(), String.t()) :: {list(), list()} + def get_to_and_cc(_actor, mentions, inReplyTo, :direct) do if inReplyTo do - {Enum.uniq([inReplyTo.actor | mentioned_actors]), []} + {Enum.uniq([inReplyTo.actor | mentions]), []} else - {mentioned_actors, []} + {mentions, []} end end + def get_to_and_cc(_user, mentions, _inReplyTo, {:list, _}), do: {mentions, []} + + # def get_addressed_users(_, to) when is_list(to) do + # Actors.get(to) + # end + + def get_addressed_users(mentioned_users, _), do: mentioned_users + @doc """ Creates HTML content from text and mentions """ - @spec make_content_html(String.t(), list(), list(), String.t()) :: String.t() + @spec make_content_html(String.t(), list(), String.t()) :: String.t() def make_content_html( - status, - mentions, - tags, + text, + additional_tags, content_type - ), - do: format_input(status, mentions, tags, content_type) + ) do + with {text, mentions, tags} <- format_input(text, content_type, []) do + {text, mentions, additional_tags ++ Enum.map(tags, fn {_, tag} -> tag end)} + end + end - def format_input(text, mentions, tags, "text/plain") do + def format_input(text, "text/plain", options) do text |> Formatter.html_escape("text/plain") - |> String.replace(~r/\r?\n/, "
") - |> (&{[], &1}).() - |> Formatter.add_links() - |> Formatter.add_actor_links(mentions) - |> Formatter.add_hashtag_links(tags) - |> Formatter.finalize() + |> Formatter.linkify(options) + |> (fn {text, mentions, tags} -> + {String.replace(text, ~r/\r?\n/, "
"), mentions, tags} + end).() end - def format_input(text, mentions, _tags, "text/html") do + def format_input(text, "text/html", options) do text |> Formatter.html_escape("text/html") - |> String.replace(~r/\r?\n/, "
") - |> (&{[], &1}).() - |> Formatter.add_actor_links(mentions) - |> Formatter.finalize() + |> Formatter.linkify(options) end - # def format_input(text, mentions, tags, "text/markdown") do - # text - # |> Earmark.as_html!() - # |> Formatter.html_escape("text/html") - # |> String.replace(~r/\r?\n/, "") - # |> (&{[], &1}).() - # |> Formatter.add_actor_links(mentions) - # |> Formatter.add_hashtag_links(tags) - # |> Formatter.finalize() - # end + # @doc """ + # Formatting text to markdown. + # """ + # def format_input(text, "text/markdown", options) do + # text + # |> Formatter.mentions_escape(options) + # |> Earmark.as_html!() + # |> Formatter.linkify(options) + # |> Formatter.html_escape("text/html") + # end def make_report_content_html(nil), do: {:ok, {nil, [], []}} @@ -132,4 +131,19 @@ defmodule MobilizonWeb.API.Utils do {:error, "Comment must be up to #{max_size} characters"} end end + + def prepare_content(actor, content, visibility, tags, in_reply_to) do + with content <- String.trim(content), + {content_html, mentions, tags} <- + make_content_html( + content, + tags, + "text/plain" + ), + mentioned_users <- for({_, mentioned_user} <- mentions, do: mentioned_user.url), + addressed_users <- get_addressed_users(mentioned_users, nil), + {to, cc} <- get_to_and_cc(actor, addressed_users, in_reply_to, visibility) do + {content_html, tags, to, cc} + end + end end diff --git a/lib/mobilizon_web/resolvers/tag.ex b/lib/mobilizon_web/resolvers/tag.ex index 2de78e304..ecbef7720 100644 --- a/lib/mobilizon_web/resolvers/tag.ex +++ b/lib/mobilizon_web/resolvers/tag.ex @@ -2,7 +2,7 @@ defmodule MobilizonWeb.Resolvers.Tag do @moduledoc """ Handles the tag-related GraphQL calls """ - require Logger + alias Mobilizon.Events alias Mobilizon.Events.Event alias Mobilizon.Events.Tag @@ -19,6 +19,15 @@ defmodule MobilizonWeb.Resolvers.Tag do {:ok, Mobilizon.Events.list_tags_for_event(id)} end + @doc """ + Retrieve the list of tags for an event + """ + def list_tags_for_event(%{url: url}, _args, _resolution) do + with %Event{id: event_id} <- Events.get_event_by_url(url) do + {:ok, Mobilizon.Events.list_tags_for_event(event_id)} + end + end + # @doc """ # Retrieve the list of related tags for a given tag ID # """ diff --git a/lib/mobilizon_web/schema/event.ex b/lib/mobilizon_web/schema/event.ex index 83f6bdff5..12285a7a4 100644 --- a/lib/mobilizon_web/schema/event.ex +++ b/lib/mobilizon_web/schema/event.ex @@ -117,6 +117,11 @@ defmodule MobilizonWeb.Schema.EventType do arg(:public, :boolean) arg(:visibility, :event_visibility, default_value: :private) + arg(:tags, list_of(:string), + default_value: [], + description: "The list of tags associated to the event" + ) + arg(:picture, :picture_input, description: "The picture for the event, either as an object or directly the ID of an existing Picture" diff --git a/lib/service/activity_pub/converters/event.ex b/lib/service/activity_pub/converters/event.ex index 1898f894a..64021611a 100644 --- a/lib/service/activity_pub/converters/event.ex +++ b/lib/service/activity_pub/converters/event.ex @@ -10,6 +10,8 @@ defmodule Mobilizon.Service.ActivityPub.Converters.Event do alias Mobilizon.Actors.Actor alias Mobilizon.Events.Event, as: EventModel alias Mobilizon.Service.ActivityPub.Converter + alias Mobilizon.Events + alias Mobilizon.Events.Tag @behaviour Converter @@ -19,7 +21,8 @@ defmodule Mobilizon.Service.ActivityPub.Converters.Event do @impl Converter @spec as_to_model_data(map()) :: map() def as_to_model_data(object) do - with {:ok, %Actor{id: actor_id}} <- Actors.get_actor_by_url(object["actor"]) do + with {:ok, %Actor{id: actor_id}} <- Actors.get_actor_by_url(object["actor"]), + tags <- fetch_tags(object["tag"]) do picture_id = with true <- Map.has_key?(object, "attachment"), %Picture{id: picture_id} <- @@ -43,11 +46,24 @@ defmodule Mobilizon.Service.ActivityPub.Converters.Event do "begins_on" => object["begins_on"], "category" => object["category"], "url" => object["id"], - "uuid" => object["uuid"] + "uuid" => object["uuid"], + "tags" => tags } end end + defp fetch_tags(tags) do + Enum.reduce(tags, [], fn tag, acc -> + case Events.get_or_create_tag(tag) do + {:ok, %Tag{} = tag} -> + acc ++ [tag] + + _ -> + acc + end + end) + end + @doc """ Convert an event struct to an ActivityStream representation """ diff --git a/lib/service/activity_pub/utils.ex b/lib/service/activity_pub/utils.ex index 4c31d3193..8f1b9b5ca 100644 --- a/lib/service/activity_pub/utils.ex +++ b/lib/service/activity_pub/utils.ex @@ -296,7 +296,7 @@ defmodule Mobilizon.Service.ActivityPub.Utils do "actor" => actor, "id" => Routes.page_url(Endpoint, :event, uuid), "uuid" => uuid, - "tag" => tags |> Enum.map(fn {_, tag} -> tag end) |> Enum.uniq() + "tag" => tags |> Enum.uniq() } if is_nil(picture), do: res, else: Map.put(res, "attachment", [make_picture_data(picture)]) @@ -328,7 +328,7 @@ defmodule Mobilizon.Service.ActivityPub.Utils do "actor" => actor, "id" => Routes.page_url(Endpoint, :comment, uuid), "uuid" => uuid, - "tag" => tags |> Enum.map(fn {_, tag} -> tag end) |> Enum.uniq() + "tag" => tags |> Enum.uniq() } if inReplyTo do diff --git a/lib/service/formatter/formatter.ex b/lib/service/formatter/formatter.ex index 905d8d577..93d3779e9 100644 --- a/lib/service/formatter/formatter.ex +++ b/lib/service/formatter/formatter.ex @@ -1,5 +1,5 @@ # Portions of this file are derived from Pleroma: -# Copyright © 2017-2018 Pleroma Authors +# Copyright © 2017-2019 Pleroma Authors # SPDX-License-Identifier: AGPL-3.0-only # Upstream: https://git.pleroma.social/pleroma/pleroma/blob/develop/lib/pleroma/formatter.ex @@ -10,68 +10,86 @@ defmodule Mobilizon.Service.Formatter do alias Mobilizon.Actors.Actor alias Mobilizon.Actors - @tag_regex ~r/\#\w+/u - def parse_tags(text, data \\ %{}) do - Regex.scan(@tag_regex, text) - |> Enum.map(fn ["#" <> tag = full_tag] -> {full_tag, String.downcase(tag)} end) - |> (fn map -> - if data["sensitive"] in [true, "True", "true", "1"], - do: [{"#nsfw", "nsfw"}] ++ map, - else: map - end).() + @link_regex ~r"((?:http(s)?:\/\/)?[\w.-]+(?:\.[\w\.-]+)+[\w\-\._~%:/?#[\]@!\$&'\(\)\*\+,;=.]+)|[0-9a-z+\-\.]+:[0-9a-z$-_.+!*'(),]+"ui + @markdown_characters_regex ~r/(`|\*|_|{|}|[|]|\(|\)|#|\+|-|\.|!)/ + + @auto_linker_config hashtag: true, + hashtag_handler: &Mobilizon.Service.Formatter.hashtag_handler/4, + mention: true, + mention_handler: &Mobilizon.Service.Formatter.mention_handler/4 + + def escape_mention_handler("@" <> nickname = mention, buffer, _, _) do + case Mobilizon.Actors.get_actor_by_name(nickname) do + %Actor{} -> + # escape markdown characters with `\\` + # (we don't want something like @user__name to be parsed by markdown) + String.replace(mention, @markdown_characters_regex, "\\\\\\1") + + _ -> + buffer + end end - def parse_mentions(text) do - # Modified from https://www.w3.org/TR/html5/forms.html#valid-e-mail-address - regex = - ~r/@[a-zA-Z0-9.!#$%&'*+\/=?^_`{|}~-]*@?[a-zA-Z0-9_-](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*/u + def mention_handler("@" <> nickname, buffer, _opts, acc) do + case Actors.get_actor_by_name(nickname) do + %Actor{id: id, url: url, preferred_username: preferred_username} = actor -> + link = + "@#{ + preferred_username + }" - Regex.scan(regex, text) - |> List.flatten() - |> Enum.uniq() - |> Enum.map(fn "@" <> match = full_match -> - {full_match, Actors.get_actor_by_name(match)} - end) - |> Enum.filter(fn {_match, user} -> user end) + {link, %{acc | mentions: MapSet.put(acc.mentions, {"@" <> nickname, actor})}} + + _ -> + {buffer, acc} + end end - # def emojify(text) do - # emojify(text, Emoji.get_all()) - # end + def hashtag_handler("#" <> tag = tag_text, _buffer, _opts, acc) do + tag = String.downcase(tag) + url = "#{MobilizonWeb.Endpoint.url()}/tag/#{tag}" + link = "" - # def emojify(text, nil), do: text + {link, %{acc | tags: MapSet.put(acc.tags, {tag_text, tag})}} + end - # def emojify(text, emoji) do - # Enum.reduce(emoji, text, fn {emoji, file}, text -> - # emoji = HTML.strip_tags(emoji) - # file = HTML.strip_tags(file) + @doc """ + Parses a text and replace plain text links with HTML. Returns a tuple with a result text, mentions, and hashtags. - # String.replace( - # text, - # ":#{emoji}:", - # "#{emoji}" - # ) - # |> HTML.filter_tags() - # end) - # end + If the 'safe_mention' option is given, only consecutive mentions at the start the post are actually mentioned. + """ + @spec linkify(String.t(), keyword()) :: + {String.t(), [{String.t(), Actor.t()}], [{String.t(), String.t()}]} + def linkify(text, options \\ []) do + options = options ++ @auto_linker_config - # def get_emoji(text) when is_binary(text) do - # Enum.filter(Emoji.get_all(), fn {emoji, _} -> String.contains?(text, ":#{emoji}:") end) - # end + acc = %{mentions: MapSet.new(), tags: MapSet.new()} + {text, %{mentions: mentions, tags: tags}} = AutoLinker.link_map(text, acc, options) - # def get_emoji(_), do: [] + {text, MapSet.to_list(mentions), MapSet.to_list(tags)} + end - @link_regex ~r/[0-9a-z+\-\.]+:[0-9a-z$-_.+!*'(),]+/ui + @doc """ + Escapes a special characters in mention names. + """ + def mentions_escape(text, options \\ []) do + options = + Keyword.merge(options, + mention: true, + url: false, + mention_handler: &escape_mention_handler/4 + ) - @uri_schemes Application.get_env(:mobilizon, :uri_schemes, []) - @valid_schemes Keyword.get(@uri_schemes, :valid_schemes, []) + AutoLinker.link(text, options) + end - # # TODO: make it use something other than @link_regex - # def html_escape(text, "text/html") do - # HTML.filter_tags(text) - # end + def html_escape({text, mentions, hashtags}, type) do + {html_escape(text, type), mentions, hashtags} + end + + def html_escape(_text, "text/html") do + # HTML.filter_tags(text) + end def html_escape(text, "text/plain") do Regex.split(@link_regex, text, include_captures: true) @@ -82,84 +100,15 @@ defmodule Mobilizon.Service.Formatter do |> Enum.join("") end - @doc "changes scheme:... urls to html links" - def add_links({subs, text}) do - links = + def truncate(text, max_length \\ 200, omission \\ "...") do + # Remove trailing whitespace + text = Regex.replace(~r/([^ \t\r\n])([ \t]+$)/u, text, "\\g{1}") + + if String.length(text) < max_length do text - |> String.split([" ", "\t", "
"]) - |> Enum.filter(fn word -> String.starts_with?(word, @valid_schemes) end) - |> Enum.filter(fn word -> Regex.match?(@link_regex, word) end) - |> Enum.map(fn url -> {Ecto.UUID.generate(), url} end) - |> Enum.sort_by(fn {_, url} -> -String.length(url) end) - - uuid_text = - links - |> Enum.reduce(text, fn {uuid, url}, acc -> String.replace(acc, url, uuid) end) - - subs = - subs ++ - Enum.map(links, fn {uuid, url} -> - {uuid, "#{url}"} - end) - - {subs, uuid_text} - end - - @doc "Adds the links to mentioned actors" - def add_actor_links({subs, text}, mentions) do - mentions = - mentions - |> Enum.sort_by(fn {name, _} -> -String.length(name) end) - |> Enum.map(fn {name, actor} -> {name, actor, Ecto.UUID.generate()} end) - - uuid_text = - mentions - |> Enum.reduce(text, fn {match, _actor, uuid}, text -> - String.replace(text, match, uuid) - end) - - subs = - subs ++ - Enum.map(mentions, fn {match, %Actor{id: id, url: url}, uuid} -> - short_match = String.split(match, "@") |> tl() |> hd() - - {uuid, - "@#{short_match}"} - end) - - {subs, uuid_text} - end - - @doc "Adds the hashtag links" - def add_hashtag_links({subs, text}, tags) do - tags = - tags - |> Enum.sort_by(fn {name, _} -> -String.length(name) end) - |> Enum.map(fn {name, short} -> {name, short, Ecto.UUID.generate()} end) - - uuid_text = - tags - |> Enum.reduce(text, fn {match, _short, uuid}, text -> - String.replace(text, match, uuid) - end) - - subs = - subs ++ - Enum.map(tags, fn {tag_text, tag, uuid} -> - url = - "" - - {uuid, url} - end) - - {subs, uuid_text} - end - - def finalize({subs, text}) do - Enum.reduce(subs, text, fn {uuid, replacement}, result_text -> - String.replace(result_text, uuid, replacement) - end) + else + length_with_omission = max_length - String.length(omission) + String.slice(text, 0, length_with_omission) <> omission + end end end diff --git a/mix.exs b/mix.exs index 42eb57979..a3e2c19d8 100644 --- a/mix.exs +++ b/mix.exs @@ -90,6 +90,9 @@ defmodule Mobilizon.Mixfile do {:earmark, "~> 1.3.1"}, {:geohax, "~> 0.3.0"}, {:mogrify, "~> 0.7.2"}, + {:auto_linker, + git: "https://git.pleroma.social/pleroma/auto_linker.git", + ref: "95e8188490e97505c56636c1379ffdf036c1fdde"}, # Dev and test dependencies {:phoenix_live_reload, "~> 1.2", only: :dev}, {:ex_machina, "~> 2.3", only: [:dev, :test]}, diff --git a/mix.lock b/mix.lock index a5d589ffa..286bbc004 100644 --- a/mix.lock +++ b/mix.lock @@ -7,6 +7,7 @@ "arc_ecto": {:git, "https://github.com/tcitworld/arc_ecto.git", "e0d8db119c564744404cff68157417e2a83941af", []}, "argon2_elixir": {:hex, :argon2_elixir, "2.0.5", "0073a87d755c7e63fc4f9d08b1d1646585b93f144cecde126e15061b24240b20", [:make, :mix], [{:comeonin, "~> 5.1", [hex: :comeonin, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.5", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm"}, "atomex": {:hex, :atomex, "0.3.0", "19b5d1a2aef8706dbd307385f7d5d9f6f273869226d317492c396c7bacf26402", [:mix], [{:xml_builder, "~> 2.0.0", [hex: :xml_builder, repo: "hexpm", optional: false]}], "hexpm"}, + "auto_linker": {:git, "https://git.pleroma.social/pleroma/auto_linker.git", "95e8188490e97505c56636c1379ffdf036c1fdde", [ref: "95e8188490e97505c56636c1379ffdf036c1fdde"]}, "bamboo": {:hex, :bamboo, "1.2.0", "8aebd24f7c606c32d0163c398004a11608ca1028182a169b2e527793bfab7561", [:mix], [{:hackney, ">= 1.13.0", [hex: :hackney, repo: "hexpm", optional: false]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: false]}, {:poison, ">= 1.5.0", [hex: :poison, repo: "hexpm", optional: true]}], "hexpm"}, "bamboo_smtp": {:hex, :bamboo_smtp, "1.7.0", "f0d213e18ced1f08b551a72221e9b8cfbf23d592b684e9aa1ef5250f4943ef9b", [:mix], [{:bamboo, "~> 1.2", [hex: :bamboo, repo: "hexpm", optional: false]}, {:gen_smtp, "~> 0.14.0", [hex: :gen_smtp, repo: "hexpm", optional: false]}], "hexpm"}, "base64url": {:hex, :base64url, "0.0.1", "36a90125f5948e3afd7be97662a1504b934dd5dac78451ca6e9abf85a10286be", [:rebar], [], "hexpm"}, diff --git a/schema.graphql b/schema.graphql index bf8c681c8..57cf761a6 100644 --- a/schema.graphql +++ b/schema.graphql @@ -1,663 +1,994 @@ -# This file was generated based on ".graphqlconfig.yaml". Do not edit manually. +# source: http://localhost:4000/api +# timestamp: Fri Jul 26 2019 11:28:32 GMT+0200 (GMT+02:00) schema { - query: RootQueryType - mutation: RootMutationType + query: RootQueryType + mutation: RootMutationType } -"An ActivityPub actor" +"""An action log""" +type ActionLog { + """The action that was done""" + action: String + + """The actor that acted""" + actor: Actor + + """Internal ID for this comment""" + id: ID + + """The object that was acted upon""" + object: ActionLogObject +} + +"""The objects that can be in an action log""" +interface ActionLogObject { + """Internal ID for this object""" + id: ID +} + +"""An ActivityPub actor""" interface Actor { - "The actor's avatar picture" - avatar: Picture - "The actor's banner picture" - banner: Picture - "The actor's domain if (null if it's this instance)" - domain: String - "List of followers" - followers: [Follower] - "Number of followers for this actor" - followersCount: Int - "List of followings" - following: [Follower] - "Number of actors following this actor" - followingCount: Int - "Internal ID for this actor" - id: Int - "The actors RSA Keys" - keys: String - "If the actor is from this instance" - local: Boolean - "Whether the actors manually approves followers" - manuallyApprovesFollowers: Boolean - "The actor's displayed name" - name: String - "A list of the events this actor has organized" - organizedEvents: [Event] - "The actor's preferred username" - preferredUsername: String - "The actor's summary" - summary: String - "If the actor is suspended" - suspended: Boolean - "The type of Actor (Person, Group,…)" - type: ActorType - "The ActivityPub actor's URL" - url: String + """The actor's avatar picture""" + avatar: Picture + + """The actor's banner picture""" + banner: Picture + + """The actor's domain if (null if it's this instance)""" + domain: String + + """List of followers""" + followers: [Follower] + + """Number of followers for this actor""" + followersCount: Int + + """List of followings""" + following: [Follower] + + """Number of actors following this actor""" + followingCount: Int + + """Internal ID for this actor""" + id: Int + + """The actors RSA Keys""" + keys: String + + """If the actor is from this instance""" + local: Boolean + + """Whether the actors manually approves followers""" + manuallyApprovesFollowers: Boolean + + """The actor's displayed name""" + name: String + + """A list of the events this actor has organized""" + organizedEvents: [Event] + + """The actor's preferred username""" + preferredUsername: String + + """The actor's summary""" + summary: String + + """If the actor is suspended""" + suspended: Boolean + + """The type of Actor (Person, Group,…)""" + type: ActorType + + """The ActivityPub actor's URL""" + url: String +} + +"""The list of types an actor can be""" +enum ActorType { + """An ActivityPub Application""" + APPLICATION + + """An ActivityPub Group""" + GROUP + + """An ActivityPub Organization""" + ORGANIZATION + + """An ActivityPub Person""" + PERSON + + """An ActivityPub Service""" + SERVICE } type Address { - country: String - description: String - "The floor this event is at" - floor: String - "The geocoordinates for the point where this address is" - geom: Point - "The address's locality" - locality: String - postalCode: String - region: String - "The address's street name (with number)" - street: String + country: String + description: String + + """The floor this event is at""" + floor: String + + """The geocoordinates for the point where this address is""" + geom: Point + + """The address's locality""" + locality: String + postalCode: String + region: String + + """The address's street name (with number)""" + street: String } -"A comment" +"""A comment""" type Comment { - "Internal ID for this comment" - id: ID - local: Boolean - primaryLanguage: String - replies: [Comment] - text: String - threadLanguages: [String]! - url: String - uuid: UUID - visibility: CommentVisibility + """Internal ID for this comment""" + id: ID + local: Boolean + primaryLanguage: String + replies: [Comment] + text: String + threadLanguages: [String]! + url: String + uuid: UUID + visibility: CommentVisibility } -"A config object" -type Config { - description: String - name: String - registrationsOpen: Boolean -} - -"Represents a deleted feed_token" -type DeletedFeedToken { - actor: DeletedObject - user: DeletedObject -} - -"Represents a deleted member" -type DeletedMember { - actor: DeletedObject - parent: DeletedObject -} - -"A struct containing the id of the deleted object" -type DeletedObject { - id: Int -} - -"Represents a deleted participant" -type DeletedParticipant { - actor: DeletedObject - event: DeletedObject -} - -"An event" -type Event { - "Who the event is attributed to (often a group)" - attributedTo: Actor - "Datetime for when the event begins" - beginsOn: DateTime - "The event's category" - category: String - "When the event was created" - createdAt: DateTime - "The event's description" - description: String - "Datetime for when the event ends" - endsOn: DateTime - "Internal ID for this event" - id: Int - "Whether the event is local or not" - local: Boolean - "Online address of the event" - onlineAddress: OnlineAddress - "The event's organizer (as a person)" - organizerActor: Actor - "The event's participants" - participants: [Participant] - "Phone address for the event" - phoneAddress: PhoneAddress - "The type of the event's address" - physicalAddress: Address - "The event's picture" - picture: Picture - "When the event was published" - publishAt: DateTime - "Events related to this one" - relatedEvents: [Event] - "The event's description's slug" - slug: String - "Status of the event" - status: EventStatus - "The event's tags" - tags: [Tag] - "The event's title" - title: String - "When the event was last updated" - updatedAt: DateTime - "The ActivityPub Event URL" - url: String - "The Event UUID" - uuid: UUID - "The event's visibility" - visibility: EventVisibility -} - -"Search events result" -type Events { - "Event elements" - elements: [Event]! - "Total elements" - total: Int! -} - -"Represents a participant to an event" -type FeedToken { - "The event which the actor participates in" - actor: Actor - "The role of this actor at this event" - token: String - "The actor that participates to the event" - user: User -} - -""" -Represents an actor's follower -""" -type Follower { - "Which profile follows" - actor: Actor - "Whether the follow has been approved by the target actor" - approved: Boolean - "What or who the profile follows" - targetActor: Actor -} - -""" -Represents a group of actors -""" -type Group implements Actor { - "The actor's avatar picture" - avatar: Picture - "The actor's banner picture" - banner: Picture - "The actor's domain if (null if it's this instance)" - domain: String - "List of followers" - followers: [Follower] - "Number of followers for this actor" - followersCount: Int - "List of followings" - following: [Follower] - "Number of actors following this actor" - followingCount: Int - "Internal ID for this group" - id: Int - "The actors RSA Keys" - keys: String - "If the actor is from this instance" - local: Boolean - "Whether the actors manually approves followers" - manuallyApprovesFollowers: Boolean - "List of group members" - members: [Member]! - "The actor's displayed name" - name: String - "Whether the group is opened to all or has restricted access" - openness: Openness - "A list of the events this actor has organized" - organizedEvents: [Event] - "The actor's preferred username" - preferredUsername: String - "The actor's summary" - summary: String - "If the actor is suspended" - suspended: Boolean - "The type of Actor (Person, Group,…)" - type: ActorType - "The type of group : Group, Community,…" - types: GroupType - "The ActivityPub actor's URL" - url: String -} - -"Search groups result" -type Groups { - "Group elements" - elements: [Group]! - "Total elements" - total: Int! -} - -"A JWT and the associated user ID" -type Login { - "A JWT Token for this session" - token: String! - "The user associated to this session" - user: User! -} - -""" -Represents a member of a group -""" -type Member { - "Which profile is member of" - actor: Person - "Of which the profile is member" - parent: Group - "The role of this membership" - role: Int -} - -type OnlineAddress { - info: String - url: String -} - -"Represents a participant to an event" -type Participant { - "The actor that participates to the event" - actor: Actor - "The event which the actor participates in" - event: Event - "The role of this actor at this event" - role: Int -} - -""" -Represents a person identity -""" -type Person implements Actor { - "The actor's avatar picture" - avatar: Picture - "The actor's banner picture" - banner: Picture - "The actor's domain if (null if it's this instance)" - domain: String - "A list of the feed tokens for this person" - feedTokens: [FeedToken] - "List of followers" - followers: [Follower] - "Number of followers for this actor" - followersCount: Int - "List of followings" - following: [Follower] - "Number of actors following this actor" - followingCount: Int - "The list of events this person goes to" - goingToEvents: [Event] - "Internal ID for this person" - id: Int - "The actors RSA Keys" - keys: String - "If the actor is from this instance" - local: Boolean - "Whether the actors manually approves followers" - manuallyApprovesFollowers: Boolean - "The list of groups this person is member of" - memberOf: [Member] - "The actor's displayed name" - name: String - "A list of the events this actor has organized" - organizedEvents: [Event] - "The actor's preferred username" - preferredUsername: String - "The actor's summary" - summary: String - "If the actor is suspended" - suspended: Boolean - "The type of Actor (Person, Group,…)" - type: ActorType - "The ActivityPub actor's URL" - url: String - "The user this actor is associated to" - user: User -} - -"Search persons result" -type Persons { - "Person elements" - elements: [Person]! - "Total elements" - total: Int! -} - -type PhoneAddress { - info: String - phone: String -} - -"A picture" -type Picture { - "The picture's alternative text" - alt: String - "The picture's ID" - id: ID - "The picture's name" - name: String - "The picture's full URL" - url: String -} - -type RootMutationType { - "Change default actor for user" - changeDefaultActor(preferredUsername: String!): User - "Create a comment" - createComment(actorUsername: String!, text: String!): Comment - "Create an event" - createEvent( - beginsOn: DateTime!, - category: String!, - description: String!, - endsOn: DateTime, - onlineAddress: String, - organizerActorId: ID!, - phoneAddress: String, - #The picture for the event, either as an object or directly the ID of an existing Picture - picture: PictureInput, - public: Boolean, - publishAt: DateTime, - state: Int, - status: Int, - title: String!, - visibility: EventVisibility = PRIVATE - ): Event - "Create a Feed Token" - createFeedToken(actorId: Int): FeedToken - "Create a group" - createGroup( - #The actor's username which will be the admin (otherwise user's default one) - adminActorUsername: String, - #The avatar for the group, either as an object or directly the ID of an existing Picture - avatar: PictureInput, - #The banner for the group, either as an object or directly the ID of an existing Picture - banner: PictureInput, - #The summary for the group - description: String = "", - #The displayed name for the group - name: String, - #The name for the group - preferredUsername: String! - ): Group - "Create a new person for user" - createPerson( - #The avatar for the profile, either as an object or directly the ID of an existing Picture - avatar: PictureInput, - #The banner for the profile, either as an object or directly the ID of an existing Picture - banner: PictureInput, - #The displayed name for the new profile - name: String = "", - preferredUsername: String!, - #The summary for the new profile - summary: String = "" - ): Person - "Create an user" - createUser(email: String!, password: String!): User - "Delete an event" - deleteEvent(actorId: Int!, eventId: Int!): DeletedObject - "Delete a feed token" - deleteFeedToken(token: String!): DeletedFeedToken - "Delete a group" - deleteGroup(actorId: Int!, groupId: Int!): DeletedObject - "Join an event" - joinEvent(actorId: Int!, eventId: Int!): Participant - "Join a group" - joinGroup(actorId: Int!, groupId: Int!): Member - "Leave an event" - leaveEvent(actorId: Int!, eventId: Int!): DeletedParticipant - "Leave an event" - leaveGroup(actorId: Int!, groupId: Int!): DeletedMember - "Login an user" - login(email: String!, password: String!): Login - "Register a first profile on registration" - registerPerson( - #The avatar for the profile, either as an object or directly the ID of an existing Picture - avatar: PictureInput, - #The banner for the profile, either as an object or directly the ID of an existing Picture - banner: PictureInput, - #The email from the user previously created - email: String!, - #The displayed name for the new profile - name: String = "", - preferredUsername: String!, - #The summary for the new profile - summary: String = "" - ): Person - "Resend registration confirmation token" - resendConfirmationEmail(email: String!, locale: String = "en"): String - "Reset user password" - resetPassword(locale: String = "en", password: String!, token: String!): Login - "Send a link through email to reset user password" - sendResetPassword(email: String!, locale: String = "en"): String - "Upload a picture" - uploadPicture(actorId: ID!, alt: String, file: Upload!, name: String!): Picture - "Validate an user after registration" - validateUser(token: String!): Login -} - -""" -Root Query -""" -type RootQueryType { - "Get the instance config" - config: Config - "Get an event by uuid" - event(uuid: UUID!): Event - "Get all events" - events(limit: Int = 10, page: Int = 1): [Event] - "Get a group by it's preferred username" - group(preferredUsername: String!): Group - "Get all groups" - groups(limit: Int = 10, page: Int = 1): [Group] - "Get the persons for an user" - identities: [Person] - "Get the current actor for the logged-in user" - loggedPerson: Person - "Get the current user" - loggedUser: User - "Get all participants for an event uuid" - participants(limit: Int = 10, page: Int = 1, uuid: UUID!): [Participant] - "Get a person by it's preferred username" - person(preferredUsername: String!): Person - "Get a picture" - picture(id: String!): Picture - "Reverse geocode coordinates" - reverseGeocode(latitude: Float!, longitude: Float!): [Address] - "Search for an address" - searchAddress(query: String!): [Address] - "Search events" - searchEvents(limit: Int = 10, page: Int = 1, search: String!): Events - "Search groups" - searchGroups(limit: Int = 10, page: Int = 1, search: String!): Groups - "Search persons" - searchPersons(limit: Int = 10, page: Int = 1, search: String!): Persons - "Get the list of tags" - tags(limit: Int = 10, page: Int = 1): [Tag]! - "Get an user" - user(id: ID!): User - "List instance users" - users(direction: SortDirection = DESC, limit: Int = 10, page: Int = 1, sort: SortableUserField = ID): Users -} - -"A tag" -type Tag { - "The tag's ID" - id: ID - "Related tags to this tag" - related: [Tag] - "The tags's slug" - slug: String - "The tag's title" - title: String -} - -"A local user of Mobilizon" -type User { - "The datetime the last activation/confirmation token was sent" - confirmationSentAt: DateTime - "The account activation/confirmation token" - confirmationToken: String - "The datetime when the user was confirmed/activated" - confirmedAt: DateTime - "The user's default actor" - defaultActor: Person - "The user's email" - email: String! - "A list of the feed tokens for this user" - feedTokens: [FeedToken] - "The user's ID" - id: ID! - "The user's list of profiles (identities)" - profiles: [Person]! - "The datetime last reset password email was sent" - resetPasswordSentAt: DateTime - "The token sent when requesting password token" - resetPasswordToken: String -} - -"Users list" -type Users { - "User elements" - elements: [User]! - "Total elements" - total: Int! -} - -"The list of types an actor can be" -enum ActorType { - #An ActivityPub Application - APPLICATION - #An ActivityPub Group - GROUP - #An ActivityPub Organization - ORGANIZATION - #An ActivityPub Person - PERSON - #An ActivityPub Service - SERVICE -} - -"The list of visibility options for a comment" +"""The list of visibility options for a comment""" enum CommentVisibility { - #visible only to people invited - INVITE - #Visible only after a moderator accepted - MODERATED - #Visible only to people members of the group or followers of the person - PRIVATE - #Publically listed and federated. Can be shared. - PUBLIC - #Visible only to people with the link - or invited - UNLISTED + """visible only to people invited""" + INVITE + + """Visible only after a moderator accepted""" + MODERATED + + """Visible only to people members of the group or followers of the person""" + PRIVATE + + """Publically listed and federated. Can be shared.""" + PUBLIC + + """Visible only to people with the link - or invited""" + UNLISTED } -"The list of possible options for the event's status" -enum EventStatus { - #The event is cancelled - CANCELLED - #The event is confirmed - CONFIRMED - #The event is tentative - TENTATIVE +"""A config object""" +type Config { + description: String + name: String + registrationsOpen: Boolean } -"The list of visibility options for an event" -enum EventVisibility { - #visible only to people invited - INVITE - #Visible only after a moderator accepted - MODERATED - #Visible only to people members of the group or followers of the person - PRIVATE - #Publically listed and federated. Can be shared. - PUBLIC - #Visible only to people with the link - or invited - UNLISTED -} - -""" -The types of Group that exist -""" -enum GroupType { - #A public group of many actors - COMMUNITY - #A private group of persons - GROUP -} - -""" -Describes how an actor is opened to follows -""" -enum Openness { - #The actor can only be followed by invitation - INVITE_ONLY - #The actor needs to accept the following before it's effective - MODERATED - #The actor is open to followings - OPEN -} - -"Available sort directions" -enum SortDirection { - ASC - DESC -} - -"The list of possible options for the event's status" -enum SortableUserField { - ID -} - -"An attached picture or a link to a picture" -input PictureInput { - picture: PictureInputObject - pictureId: String -} - -"An attached picture" -input PictureInputObject { - actorId: ID - alt: String - file: Upload! - name: String! -} - - -""" -The `Point` scalar type represents Point geographic information compliant string data, -represented as floats separated by a semi-colon. The geodetic system is WGS 84 -""" -scalar Point - """ The `DateTime` scalar type represents a date and time in the UTC timezone. The DateTime appears in a JSON response as an ISO8601 formatted -string, including UTC timezone (\"Z\"). The parsed date and time string will +string, including UTC timezone ("Z"). The parsed date and time string will be converted to UTC and any UTC offset other than 0 will be rejected. """ scalar DateTime +"""Represents a deleted feed_token""" +type DeletedFeedToken { + actor: DeletedObject + user: DeletedObject +} + +"""Represents a deleted member""" +type DeletedMember { + actor: DeletedObject + parent: DeletedObject +} + +"""A struct containing the id of the deleted object""" +type DeletedObject { + id: Int +} + +"""Represents a deleted participant""" +type DeletedParticipant { + actor: DeletedObject + event: DeletedObject +} + +"""An event""" +type Event { + """Who the event is attributed to (often a group)""" + attributedTo: Actor + + """Datetime for when the event begins""" + beginsOn: DateTime + + """The event's category""" + category: String + + """When the event was created""" + createdAt: DateTime + + """The event's description""" + description: String + + """Datetime for when the event ends""" + endsOn: DateTime + + """Internal ID for this event""" + id: Int + + """Whether the event is local or not""" + local: Boolean + + """Online address of the event""" + onlineAddress: OnlineAddress + + """The event's organizer (as a person)""" + organizerActor: Actor + + """The event's participants""" + participants: [Participant] + + """Phone address for the event""" + phoneAddress: PhoneAddress + + """The type of the event's address""" + physicalAddress: Address + + """The event's picture""" + picture: Picture + + """When the event was published""" + publishAt: DateTime + + """Events related to this one""" + relatedEvents: [Event] + + """The event's description's slug""" + slug: String + + """Status of the event""" + status: EventStatus + + """The event's tags""" + tags: [Tag] + + """The event's title""" + title: String + + """When the event was last updated""" + updatedAt: DateTime + + """The ActivityPub Event URL""" + url: String + + """The Event UUID""" + uuid: UUID + + """The event's visibility""" + visibility: EventVisibility +} + +"""Search events result""" +type Events { + """Event elements""" + elements: [Event]! + + """Total elements""" + total: Int! +} + +"""The list of possible options for the event's status""" +enum EventStatus { + """The event is cancelled""" + CANCELLED + + """The event is confirmed""" + CONFIRMED + + """The event is tentative""" + TENTATIVE +} + +"""The list of visibility options for an event""" +enum EventVisibility { + """visible only to people invited""" + INVITE + + """Visible only after a moderator accepted""" + MODERATED + + """Visible only to people members of the group or followers of the person""" + PRIVATE + + """Publically listed and federated. Can be shared.""" + PUBLIC + + """Visible only to people with the link - or invited""" + UNLISTED +} + +"""Represents a participant to an event""" +type FeedToken { + """The event which the actor participates in""" + actor: Actor + + """The role of this actor at this event""" + token: String + + """The actor that participates to the event""" + user: User +} + +""" +Represents an actor's follower + +""" +type Follower { + """Which profile follows""" + actor: Actor + + """Whether the follow has been approved by the target actor""" + approved: Boolean + + """What or who the profile follows""" + targetActor: Actor +} + +""" +Represents a group of actors + +""" +type Group implements Actor { + """The actor's avatar picture""" + avatar: Picture + + """The actor's banner picture""" + banner: Picture + + """The actor's domain if (null if it's this instance)""" + domain: String + + """List of followers""" + followers: [Follower] + + """Number of followers for this actor""" + followersCount: Int + + """List of followings""" + following: [Follower] + + """Number of actors following this actor""" + followingCount: Int + + """Internal ID for this group""" + id: Int + + """The actors RSA Keys""" + keys: String + + """If the actor is from this instance""" + local: Boolean + + """Whether the actors manually approves followers""" + manuallyApprovesFollowers: Boolean + + """List of group members""" + members: [Member]! + + """The actor's displayed name""" + name: String + + """Whether the group is opened to all or has restricted access""" + openness: Openness + + """A list of the events this actor has organized""" + organizedEvents: [Event] + + """The actor's preferred username""" + preferredUsername: String + + """The actor's summary""" + summary: String + + """If the actor is suspended""" + suspended: Boolean + + """The type of Actor (Person, Group,…)""" + type: ActorType + + """The type of group : Group, Community,…""" + types: GroupType + + """The ActivityPub actor's URL""" + url: String +} + +"""Search groups result""" +type Groups { + """Group elements""" + elements: [Group]! + + """Total elements""" + total: Int! +} + +""" +The types of Group that exist + +""" +enum GroupType { + """A public group of many actors""" + COMMUNITY + + """A private group of persons""" + GROUP +} + +"""A JWT and the associated user ID""" +type Login { + """A JWT Token for this session""" + token: String! + + """The user associated to this session""" + user: User! +} + +""" +Represents a member of a group + +""" +type Member { + """Which profile is member of""" + actor: Person + + """Of which the profile is member""" + parent: Group + + """The role of this membership""" + role: Int +} + +type OnlineAddress { + info: String + url: String +} + +""" +Describes how an actor is opened to follows + +""" +enum Openness { + """The actor can only be followed by invitation""" + INVITE_ONLY + + """The actor needs to accept the following before it's effective""" + MODERATED + + """The actor is open to followings""" + OPEN +} + +"""Represents a participant to an event""" +type Participant { + """The actor that participates to the event""" + actor: Actor + + """The event which the actor participates in""" + event: Event + + """The role of this actor at this event""" + role: Int +} + +""" +Represents a person identity + +""" +type Person implements Actor { + """The actor's avatar picture""" + avatar: Picture + + """The actor's banner picture""" + banner: Picture + + """The actor's domain if (null if it's this instance)""" + domain: String + + """A list of the feed tokens for this person""" + feedTokens: [FeedToken] + + """List of followers""" + followers: [Follower] + + """Number of followers for this actor""" + followersCount: Int + + """List of followings""" + following: [Follower] + + """Number of actors following this actor""" + followingCount: Int + + """The list of events this person goes to""" + goingToEvents: [Event] + + """Internal ID for this person""" + id: Int + + """The actors RSA Keys""" + keys: String + + """If the actor is from this instance""" + local: Boolean + + """Whether the actors manually approves followers""" + manuallyApprovesFollowers: Boolean + + """The list of groups this person is member of""" + memberOf: [Member] + + """The actor's displayed name""" + name: String + + """A list of the events this actor has organized""" + organizedEvents: [Event] + + """The actor's preferred username""" + preferredUsername: String + + """The actor's summary""" + summary: String + + """If the actor is suspended""" + suspended: Boolean + + """The type of Actor (Person, Group,…)""" + type: ActorType + + """The ActivityPub actor's URL""" + url: String + + """The user this actor is associated to""" + user: User +} + +"""Search persons result""" +type Persons { + """Person elements""" + elements: [Person]! + + """Total elements""" + total: Int! +} + +type PhoneAddress { + info: String + phone: String +} + +"""A picture""" +type Picture { + """The picture's alternative text""" + alt: String + + """The picture's detected content type""" + contentType: String + + """The picture's ID""" + id: ID + + """The picture's name""" + name: String + + """The picture's size""" + size: Int + + """The picture's full URL""" + url: String +} + +"""An attached picture or a link to a picture""" +input PictureInput { + picture: PictureInputObject + pictureId: String +} + +"""An attached picture""" +input PictureInputObject { + actorId: ID + alt: String + file: Upload! + name: String! +} + +""" +The `Point` scalar type represents Point geographic information compliant string data, +represented as floats separated by a semi-colon. The geodetic system is WGS 84 +""" +scalar Point + +"""A report object""" +type Report implements ActionLogObject { + """The comments that are reported""" + comments: [Comment] + + """The comment the reporter added about this report""" + content: String + + """The event that is being reported""" + event: Event + + """The internal ID of the report""" + id: ID + + """The actor that is being reported""" + reported: Actor + + """The actor that created the report""" + reporter: Actor + + """Whether the report is still active""" + status: ReportStatus + + """The URI of the report""" + uri: String +} + +"""A report note object""" +type ReportNote implements ActionLogObject { + """The content of the note""" + content: String + + """The internal ID of the report note""" + id: ID + + """The moderator who added the note""" + moderator: Actor + + """The report on which this note is added""" + report: Report +} + +"""The list of possible statuses for a report object""" +enum ReportStatus { + """The report has been closed""" + CLOSED + + """The report has been opened""" + OPEN + + """The report has been marked as resolved""" + RESOLVED +} + +type RootMutationType { + """Change default actor for user""" + changeDefaultActor(preferredUsername: String!): User + + """Create a comment""" + createComment(actorUsername: String!, text: String!): Comment + + """Create an event""" + createEvent( + beginsOn: DateTime! + category: String! + description: String! + endsOn: DateTime + onlineAddress: String + organizerActorId: ID! + phoneAddress: String + + """ + The picture for the event, either as an object or directly the ID of an existing Picture + """ + picture: PictureInput + public: Boolean + publishAt: DateTime + state: Int + status: Int + + """The list of tags associated to the event""" + tags: [String] = [""] + title: String! + visibility: EventVisibility = PRIVATE + ): Event + + """Create a Feed Token""" + createFeedToken(actorId: Int): FeedToken + + """Create a group""" + createGroup( + """ + The actor's username which will be the admin (otherwise user's default one) + """ + adminActorUsername: String + + """ + The avatar for the group, either as an object or directly the ID of an existing Picture + """ + avatar: PictureInput + + """ + The banner for the group, either as an object or directly the ID of an existing Picture + """ + banner: PictureInput + + """The summary for the group""" + description: String = "" + + """The displayed name for the group""" + name: String + + """The name for the group""" + preferredUsername: String! + ): Group + + """Create a new person for user""" + createPerson( + """ + The avatar for the profile, either as an object or directly the ID of an existing Picture + """ + avatar: PictureInput + + """ + The banner for the profile, either as an object or directly the ID of an existing Picture + """ + banner: PictureInput + + """The displayed name for the new profile""" + name: String = "" + preferredUsername: String! + + """The summary for the new profile""" + summary: String = "" + ): Person + + """Create a report""" + createReport(commentsIds: [ID] = [""], eventId: ID, reportContent: String, reportedActorId: ID!, reporterActorId: ID!): Report + + """Create a note on a report""" + createReportNote(content: String, moderatorId: ID!, reportId: ID!): ReportNote + + """Create an user""" + createUser(email: String!, password: String!): User + + """Delete an event""" + deleteEvent(actorId: Int!, eventId: Int!): DeletedObject + + """Delete a feed token""" + deleteFeedToken(token: String!): DeletedFeedToken + + """Delete a group""" + deleteGroup(actorId: Int!, groupId: Int!): DeletedObject + + """Delete an identity""" + deletePerson(preferredUsername: String!): Person + deleteReportNote(moderatorId: ID!, noteId: ID!): DeletedObject + + """Join an event""" + joinEvent(actorId: Int!, eventId: Int!): Participant + + """Join a group""" + joinGroup(actorId: Int!, groupId: Int!): Member + + """Leave an event""" + leaveEvent(actorId: Int!, eventId: Int!): DeletedParticipant + + """Leave an event""" + leaveGroup(actorId: Int!, groupId: Int!): DeletedMember + + """Login an user""" + login(email: String!, password: String!): Login + + """Register a first profile on registration""" + registerPerson( + """ + The avatar for the profile, either as an object or directly the ID of an existing Picture + """ + avatar: PictureInput + + """ + The banner for the profile, either as an object or directly the ID of an existing Picture + """ + banner: PictureInput + + """The email from the user previously created""" + email: String! + + """The displayed name for the new profile""" + name: String = "" + preferredUsername: String! + + """The summary for the new profile""" + summary: String = "" + ): Person + + """Resend registration confirmation token""" + resendConfirmationEmail(email: String!, locale: String = "en"): String + + """Reset user password""" + resetPassword(locale: String = "en", password: String!, token: String!): Login + + """Send a link through email to reset user password""" + sendResetPassword(email: String!, locale: String = "en"): String + + """Update an identity""" + updatePerson( + """ + The avatar for the profile, either as an object or directly the ID of an existing Picture + """ + avatar: PictureInput + + """ + The banner for the profile, either as an object or directly the ID of an existing Picture + """ + banner: PictureInput + + """The displayed name for this profile""" + name: String + preferredUsername: String! + + """The summary for this profile""" + summary: String + ): Person + + """Update a report""" + updateReportStatus(moderatorId: ID!, reportId: ID!, status: ReportStatus!): Report + + """Upload a picture""" + uploadPicture(actorId: ID!, alt: String, file: Upload!, name: String!): Picture + + """Validate an user after registration""" + validateUser(token: String!): Login +} + +""" +Root Query + +""" +type RootQueryType { + """Get the list of action logs""" + actionLogs(limit: Int = 10, page: Int = 1): [ActionLog] + + """Get the instance config""" + config: Config + + """Get an event by uuid""" + event(uuid: UUID!): Event + + """Get all events""" + events(limit: Int = 10, page: Int = 1): [Event] + + """Get a group by it's preferred username""" + group(preferredUsername: String!): Group + + """Get all groups""" + groups(limit: Int = 10, page: Int = 1): [Group] + + """Get the persons for an user""" + identities: [Person] + + """Get the current actor for the logged-in user""" + loggedPerson: Person + + """Get the current user""" + loggedUser: User + + """Get all participants for an event uuid""" + participants(limit: Int = 10, page: Int = 1, uuid: UUID!): [Participant] + + """Get a person by it's preferred username""" + person(preferredUsername: String!): Person + + """Get a picture""" + picture(id: String!): Picture + + """Get a report by id""" + report(id: ID!): Report + + """Get all reports""" + reports(limit: Int = 10, page: Int = 1): [Report] + + """Reverse geocode coordinates""" + reverseGeocode(latitude: Float!, longitude: Float!): [Address] + + """Search for an address""" + searchAddress(query: String!): [Address] + + """Search events""" + searchEvents(limit: Int = 10, page: Int = 1, search: String!): Events + + """Search groups""" + searchGroups(limit: Int = 10, page: Int = 1, search: String!): Groups + + """Search persons""" + searchPersons(limit: Int = 10, page: Int = 1, search: String!): Persons + + """Get the list of tags""" + tags(limit: Int = 10, page: Int = 1): [Tag]! + + """Get an user""" + user(id: ID!): User + + """List instance users""" + users(direction: SortDirection = DESC, limit: Int = 10, page: Int = 1, sort: SortableUserField = ID): Users +} + +"""The list of possible options for the event's status""" +enum SortableUserField { + ID +} + +"""Available sort directions""" +enum SortDirection { + ASC + DESC +} + +"""A tag""" +type Tag { + """The tag's ID""" + id: ID + + """Related tags to this tag""" + related: [Tag] + + """The tags's slug""" + slug: String + + """The tag's title""" + title: String +} + +""" +Represents an uploaded file. + +""" +scalar Upload + +"""A local user of Mobilizon""" +type User { + """The datetime the last activation/confirmation token was sent""" + confirmationSentAt: DateTime + + """The account activation/confirmation token""" + confirmationToken: String + + """The datetime when the user was confirmed/activated""" + confirmedAt: DateTime + + """The user's default actor""" + defaultActor: Person + + """The user's email""" + email: String! + + """A list of the feed tokens for this user""" + feedTokens: [FeedToken] + + """The user's ID""" + id: ID! + + """The user's list of profiles (identities)""" + profiles: [Person]! + + """The datetime last reset password email was sent""" + resetPasswordSentAt: DateTime + + """The token sent when requesting password token""" + resetPasswordToken: String +} + +"""Users list""" +type Users { + """User elements""" + elements: [User]! + + """Total elements""" + total: Int! +} + """ The `UUID` scalar type represents UUID4 compliant string data, represented as UTF-8 character sequences. The UUID4 type is most often used to represent unique human-readable ID strings. """ scalar UUID - -""" -Represents an uploaded file. -""" -scalar Upload diff --git a/test/mobilizon/service/formatter/formatter_test.exs b/test/mobilizon/service/formatter/formatter_test.exs new file mode 100644 index 000000000..cc3dcfa40 --- /dev/null +++ b/test/mobilizon/service/formatter/formatter_test.exs @@ -0,0 +1,205 @@ +# Pleroma: A lightweight social networking server +# Copyright © 2017-2018 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Mobilizon.Service.FormatterTest do + alias Mobilizon.Service.Formatter + use Mobilizon.DataCase + + import Mobilizon.Factory + + describe ".add_hashtag_links" do + test "turns hashtags into links" do + text = "I love #cofe and #2hu" + + expected_text = + "I love and " + + assert {^expected_text, [], _tags} = Formatter.linkify(text) + end + + test "does not turn html characters to tags" do + text = "#fact_3: pleroma does what mastodon't" + + expected_text = + ": pleroma does what mastodon't" + + assert {^expected_text, [], _tags} = Formatter.linkify(text) + end + end + + describe ".add_links" do + test "turning urls into links" do + text = "Hey, check out https://www.youtube.com/watch?v=8Zg1-TufF%20zY?x=1&y=2#blabla ." + + expected = + "Hey, check out https://www.youtube.com/watch?v=8Zg1-TufF%20zY?x=1&y=2#blabla ." + + assert {^expected, [], []} = Formatter.linkify(text) + + text = "https://mastodon.social/@lambadalambda" + + expected = + "https://mastodon.social/@lambadalambda" + + assert {^expected, [], []} = Formatter.linkify(text) + + text = "https://mastodon.social:4000/@lambadalambda" + + expected = + "https://mastodon.social:4000/@lambadalambda" + + assert {^expected, [], []} = Formatter.linkify(text) + + text = "@lambadalambda" + expected = "@lambadalambda" + + assert {^expected, [], []} = Formatter.linkify(text) + + text = "http://www.cs.vu.nl/~ast/intel/" + expected = "http://www.cs.vu.nl/~ast/intel/" + + assert {^expected, [], []} = Formatter.linkify(text) + + text = "https://forum.zdoom.org/viewtopic.php?f=44&t=57087" + + expected = + "https://forum.zdoom.org/viewtopic.php?f=44&t=57087" + + assert {^expected, [], []} = Formatter.linkify(text) + + text = "https://en.wikipedia.org/wiki/Sophia_(Gnosticism)#Mythos_of_the_soul" + + expected = + "https://en.wikipedia.org/wiki/Sophia_(Gnosticism)#Mythos_of_the_soul" + + assert {^expected, [], []} = Formatter.linkify(text) + + text = "https://www.google.co.jp/search?q=Nasim+Aghdam" + + expected = + "https://www.google.co.jp/search?q=Nasim+Aghdam" + + assert {^expected, [], []} = Formatter.linkify(text) + + text = "https://en.wikipedia.org/wiki/Duff's_device" + + expected = + "https://en.wikipedia.org/wiki/Duff's_device" + + assert {^expected, [], []} = Formatter.linkify(text) + + text = "https://pleroma.com https://pleroma.com/sucks" + + expected = + "https://pleroma.com https://pleroma.com/sucks" + + assert {^expected, [], []} = Formatter.linkify(text) + + text = "xmpp:contact@hacktivis.me" + + expected = "xmpp:contact@hacktivis.me" + + assert {^expected, [], []} = Formatter.linkify(text) + + text = + "magnet:?xt=urn:btih:7ec9d298e91d6e4394d1379caf073c77ff3e3136&tr=udp%3A%2F%2Fopentor.org%3A2710&tr=udp%3A%2F%2Ftracker.blackunicorn.xyz%3A6969&tr=udp%3A%2F%2Ftracker.ccc.de%3A80&tr=udp%3A%2F%2Ftracker.coppersurfer.tk%3A6969&tr=udp%3A%2F%2Ftracker.leechers-paradise.org%3A6969&tr=udp%3A%2F%2Ftracker.openbittorrent.com%3A80&tr=wss%3A%2F%2Ftracker.btorrent.xyz&tr=wss%3A%2F%2Ftracker.fastcast.nz&tr=wss%3A%2F%2Ftracker.openwebtorrent.com" + + expected = "#{text}" + + assert {^expected, [], []} = Formatter.linkify(text) + end + end + + describe "add_user_links" do + test "gives a replacement for user links, using local nicknames in user links text" do + text = "@gsimg According to @archa_eme_, that is @daggsy. Also hello @archaeme@archae.me" + gsimg = insert(:actor, preferred_username: "gsimg") + + archaeme = + insert(:actor, preferred_username: "archa_eme_", url: "https://archeme/@archa_eme_") + + archaeme_remote = insert(:actor, preferred_username: "archaeme", domain: "archae.me") + + {text, mentions, []} = Formatter.linkify(text) + + assert length(mentions) == 3 + + expected_text = + "@gsimg According to @archa_eme_, that is @daggsy. Also hello @archaeme" + + assert expected_text == text + end + + test "gives a replacement for single-character local nicknames" do + text = "@o hi" + o = insert(:actor, preferred_username: "o") + + {text, mentions, []} = Formatter.linkify(text) + + assert length(mentions) == 1 + + expected_text = + "@o hi" + + assert expected_text == text + end + + test "does not give a replacement for single-character local nicknames who don't exist" do + text = "@a hi" + + expected_text = "@a hi" + assert {^expected_text, [] = _mentions, [] = _tags} = Formatter.linkify(text) + end + end + + describe ".parse_tags" do + test "parses tags in the text" do + text = "Here's a #Test. Maybe these are #working or not. What about #漢字? And #は。" + + expected_tags = [ + {"#Test", "test"}, + {"#working", "working"}, + {"#は", "は"}, + {"#漢字", "漢字"} + ] + + assert {_text, [], ^expected_tags} = Formatter.linkify(text) + end + end + + test "it can parse mentions and return the relevant users" do + text = + "@@gsimg According to @archaeme, that is @daggsy. Also hello @archaeme@archae.me and @o and @@@jimm" + + o = insert(:actor, preferred_username: "o") + jimm = insert(:actor, preferred_username: "jimm") + gsimg = insert(:actor, preferred_username: "gsimg") + archaeme = insert(:actor, preferred_username: "archaeme") + archaeme_remote = insert(:actor, preferred_username: "archaeme", domain: "archae.me") + + expected_mentions = [ + {"@archaeme", archaeme.id}, + {"@archaeme@archae.me", archaeme_remote.id}, + {"@gsimg", gsimg.id}, + {"@jimm", jimm.id}, + {"@o", o.id} + ] + + {_text, mentions, []} = Formatter.linkify(text) + + assert expected_mentions == + Enum.map(mentions, fn {username, actor} -> {username, actor.id} end) + end + + test "it escapes HTML in plain text" do + text = "hello & world google.com/?a=b&c=d \n http://test.com/?a=b&c=d 1" + expected = "hello & world google.com/?a=b&c=d \n http://test.com/?a=b&c=d 1" + + assert Formatter.html_escape(text, "text/plain") == expected + end +end diff --git a/test/mobilizon_web/resolvers/event_resolver_test.exs b/test/mobilizon_web/resolvers/event_resolver_test.exs index 013a54260..3cce2fd09 100644 --- a/test/mobilizon_web/resolvers/event_resolver_test.exs +++ b/test/mobilizon_web/resolvers/event_resolver_test.exs @@ -84,6 +84,44 @@ defmodule MobilizonWeb.Resolvers.EventResolverTest do assert json_response(res, 200)["data"]["createEvent"]["title"] == "come to my event" end + test "create_event/3 creates an event with tags", %{conn: conn, actor: actor, user: user} do + mutation = """ + mutation { + createEvent( + title: "my event is referenced", + description: "with tags!", + begins_on: "#{ + DateTime.utc_now() |> DateTime.truncate(:second) |> DateTime.to_iso8601() + }", + organizer_actor_id: "#{actor.id}", + category: "birthday", + tags: ["nicolas", "birthday", "bad tag"] + ) { + title, + uuid, + tags { + title, + slug + } + } + } + """ + + res = + conn + |> auth_conn(user) + |> post("/api", AbsintheHelpers.mutation_skeleton(mutation)) + + assert json_response(res, 200)["errors"] == nil + assert json_response(res, 200)["data"]["createEvent"]["title"] == "my event is referenced" + + assert json_response(res, 200)["data"]["createEvent"]["tags"] == [ + %{"slug" => "nicolas", "title" => "nicolas"}, + %{"slug" => "birthday", "title" => "birthday"}, + %{"slug" => "bad-tag", "title" => "bad tag"} + ] + end + test "create_event/3 creates an event with an attached picture", %{ conn: conn, actor: actor,