Allow to search groups by location

Signed-off-by: Thomas Citharel <tcit@tcit.fr>
This commit is contained in:
Thomas Citharel 2020-08-05 16:44:08 +02:00
parent 3bae65374f
commit 3c077c59ad
No known key found for this signature in database
GPG key ID: A061B9DDE0CA0773
18 changed files with 408 additions and 149 deletions

View file

@ -2,14 +2,14 @@
<div class="address-autocomplete"> <div class="address-autocomplete">
<b-field expanded> <b-field expanded>
<template slot="label"> <template slot="label">
{{ $t("Find an address") }} {{ actualLabel }}
<b-button <b-button
v-if="!gettingLocation" v-if="canShowLocateMeButton && !gettingLocation"
size="is-small" size="is-small"
icon-right="map-marker" icon-right="map-marker"
@click="locateMe" @click="locateMe"
/> />
<span v-else>{{ $t("Getting location") }}</span> <span v-else-if="gettingLocation">{{ $t("Getting location") }}</span>
</template> </template>
<b-autocomplete <b-autocomplete
:data="addressData" :data="addressData"
@ -44,7 +44,7 @@
</template> </template>
</b-autocomplete> </b-autocomplete>
</b-field> </b-field>
<div class="map" v-if="selected && selected.geom"> <div class="map" v-if="selected && selected.geom && selected.poiInfos">
<map-leaflet <map-leaflet
:coords="selected.geom" :coords="selected.geom"
:marker="{ :marker="{
@ -118,6 +118,7 @@ import { IConfig } from "../../types/config.model";
}) })
export default class FullAddressAutoComplete extends Vue { export default class FullAddressAutoComplete extends Vue {
@Prop({ required: true }) value!: IAddress; @Prop({ required: true }) value!: IAddress;
@Prop({ required: false, default: "" }) label!: string;
addressData: IAddress[] = []; addressData: IAddress[] = [];
@ -189,8 +190,10 @@ export default class FullAddressAutoComplete extends Vue {
if (!(this.value && this.value.id)) return; if (!(this.value && this.value.id)) return;
this.selected = this.value; this.selected = this.value;
const address = new Address(this.selected); const address = new Address(this.selected);
if (address.poiInfos) {
this.queryText = `${address.poiInfos.name} ${address.poiInfos.alternativeName}`; this.queryText = `${address.poiInfos.name} ${address.poiInfos.alternativeName}`;
} }
}
updateSelected(option: IAddress) { updateSelected(option: IAddress) {
if (option == null) return; if (option == null) return;
@ -251,6 +254,14 @@ export default class FullAddressAutoComplete extends Vue {
} }
} }
get actualLabel(): string {
return this.label || (this.$t("Find an address") as string);
}
get canShowLocateMeButton(): boolean {
return window.isSecureContext;
}
static async getLocation(): Promise<Position> { static async getLocation(): Promise<Position> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
if (!("geolocation" in navigator)) { if (!("geolocation" in navigator)) {

View file

@ -465,6 +465,19 @@ export const FETCH_GROUP = gql`
summary summary
preferredUsername preferredUsername
suspended suspended
visibility
physicalAddress {
description
street
locality
postalCode
region
country
geom
type
id
originId
}
avatar { avatar {
url url
} }
@ -588,8 +601,18 @@ export const UPDATE_GROUP = gql`
$summary: String $summary: String
$avatar: PictureInput $avatar: PictureInput
$banner: PictureInput $banner: PictureInput
$visibility: GroupVisibility
$physicalAddress: AddressInput
) {
updateGroup(
id: $id
name: $name
summary: $summary
banner: $banner
avatar: $avatar
visibility: $visibility
physicalAddress: $physicalAddress
) { ) {
createGroup(id: $id, name: $name, summary: $summary, banner: $banner, avatar: $avatar) {
id id
preferredUsername preferredUsername
name name

View file

@ -36,8 +36,8 @@ export const SEARCH_EVENTS = gql`
`; `;
export const SEARCH_GROUPS = gql` export const SEARCH_GROUPS = gql`
query SearchGroups($searchText: String!) { query SearchGroups($term: String, $location: String, $radius: Float) {
searchGroups(search: $searchText) { searchGroups(term: $term, location: $location, radius: $radius) {
total total
elements { elements {
avatar { avatar {
@ -54,7 +54,7 @@ export const SEARCH_GROUPS = gql`
export const SEARCH_PERSONS = gql` export const SEARCH_PERSONS = gql`
query SearchPersons($searchText: String!, $page: Int, $limit: Int) { query SearchPersons($searchText: String!, $page: Int, $limit: Int) {
searchPersons(search: $searchText, page: $page, limit: $limit) { searchPersons(term: $searchText, page: $page, limit: $limit) {
total total
elements { elements {
id id

View file

@ -6,6 +6,7 @@ import { IEvent } from "../event.model";
import { IDiscussion } from "../discussions"; import { IDiscussion } from "../discussions";
import { IPerson } from "./person.model"; import { IPerson } from "./person.model";
import { IPost } from "../post.model"; import { IPost } from "../post.model";
import { IAddress, Address } from "../address.model";
export enum MemberRole { export enum MemberRole {
NOT_APPROVED = "NOT_APPROVED", NOT_APPROVED = "NOT_APPROVED",
@ -23,6 +24,7 @@ export interface IGroup extends IActor {
todoLists: Paginate<ITodoList>; todoLists: Paginate<ITodoList>;
discussions: Paginate<IDiscussion>; discussions: Paginate<IDiscussion>;
organizedEvents: Paginate<IEvent>; organizedEvents: Paginate<IEvent>;
physicalAddress: IAddress;
} }
export interface IMember { export interface IMember {
@ -52,6 +54,7 @@ export class Group extends Actor implements IGroup {
this.patch(hash); this.patch(hash);
} }
physicalAddress: IAddress = new Address();
patch(hash: any) { patch(hash: any) {
Object.assign(this, hash); Object.assign(this, hash);

View file

@ -143,7 +143,6 @@
</section> </section>
</template> </template>
</b-table> </b-table>
<pre>{{ group.members }}</pre>
</section> </section>
</div> </div>
</template> </template>

View file

@ -39,6 +39,58 @@
<b-field :label="$t('Group short description')"> <b-field :label="$t('Group short description')">
<b-input type="textarea" v-model="group.summary" <b-input type="textarea" v-model="group.summary"
/></b-field> /></b-field>
<p class="label">{{ $t("Group visibility") }}</p>
<div class="field">
<b-radio
v-model="group.visibility"
name="groupVisibility"
:native-value="GroupVisibility.PUBLIC"
>
{{ $t("Visible everywhere on the web") }}<br />
<small>{{
$t(
"The group will be publicly listed in search results and may be suggested in the explore section. Only public informations will be shown on it's page."
)
}}</small>
</b-radio>
</div>
<div class="field">
<b-radio
v-model="group.visibility"
name="groupVisibility"
:native-value="GroupVisibility.UNLISTED"
>{{ $t("Only accessible through link") }}<br />
<small>{{
$t("You'll need to transmit the group URL so people may access the group's profile.")
}}</small>
</b-radio>
<p class="control">
<code>{{ group.url }}</code>
<b-tooltip
v-if="canShowCopyButton"
:label="$t('URL copied to clipboard')"
:active="showCopiedTooltip"
always
type="is-success"
position="is-left"
>
<b-button
type="is-primary"
icon-right="content-paste"
native-type="button"
@click="copyURL"
@keyup.enter="copyURL"
/>
</b-tooltip>
</p>
</div>
<full-address-auto-complete
:label="$t('Group address')"
v-model="group.physicalAddress"
:value="currentAddress"
/>
<b-button native-type="submit" type="is-primary">{{ $t("Update group") }}</b-button> <b-button native-type="submit" type="is-primary">{{ $t("Update group") }}</b-button>
</form> </form>
</section> </section>
@ -50,8 +102,10 @@ import { Component, Vue } from "vue-property-decorator";
import RouteName from "../../router/name"; import RouteName from "../../router/name";
import { FETCH_GROUP, UPDATE_GROUP } from "../../graphql/actor"; import { FETCH_GROUP, UPDATE_GROUP } from "../../graphql/actor";
import { IGroup, usernameWithDomain } from "../../types/actor"; import { IGroup, usernameWithDomain } from "../../types/actor";
import { Address, IAddress } from "../../types/address.model";
import { IMember, Group } from "../../types/actor/group.model"; import { IMember, Group } from "../../types/actor/group.model";
import { Paginate } from "../../types/paginate"; import { Paginate } from "../../types/paginate";
import FullAddressAutoComplete from "@/components/Event/FullAddressAutoComplete.vue";
@Component({ @Component({
apollo: { apollo: {
@ -67,6 +121,9 @@ import { Paginate } from "../../types/paginate";
}, },
}, },
}, },
components: {
FullAddressAutoComplete,
},
}) })
export default class GroupSettings extends Vue { export default class GroupSettings extends Vue {
group: IGroup = new Group(); group: IGroup = new Group();
@ -79,13 +136,41 @@ export default class GroupSettings extends Vue {
usernameWithDomain = usernameWithDomain; usernameWithDomain = usernameWithDomain;
GroupVisibility = {
PUBLIC: "PUBLIC",
UNLISTED: "UNLISTED",
};
showCopiedTooltip = false;
async updateGroup() { async updateGroup() {
const variables = { ...this.group };
// eslint-disable-next-line
// @ts-ignore
delete variables.__typename;
// eslint-disable-next-line
// @ts-ignore
delete variables.physicalAddress.__typename;
await this.$apollo.mutate<{ updateGroup: IGroup }>({ await this.$apollo.mutate<{ updateGroup: IGroup }>({
mutation: UPDATE_GROUP, mutation: UPDATE_GROUP,
variables: { variables,
...this.group,
},
}); });
} }
async copyURL() {
await window.navigator.clipboard.writeText(this.group.url);
this.showCopiedTooltip = true;
setTimeout(() => {
this.showCopiedTooltip = false;
}, 2000);
}
get canShowCopyButton(): boolean {
return window.isSecureContext;
}
get currentAddress(): IAddress {
return new Address(this.group.physicalAddress);
}
} }
</script> </script>

View file

@ -177,11 +177,13 @@ const tabsName: { events: number; groups: number } = {
query: SEARCH_GROUPS, query: SEARCH_GROUPS,
variables() { variables() {
return { return {
searchText: this.search, term: this.search,
location: this.geohash,
radius: this.radius,
}; };
}, },
skip() { skip() {
return this.search == null || this.search == ""; return !this.search && !this.geohash;
}, },
}, },
}, },
@ -264,7 +266,7 @@ export default class Search extends Vue {
radiusOptions: (number | null)[] = [1, 5, 10, 25, 50, 100, 150, null]; radiusOptions: (number | null)[] = [1, 5, 10, 25, 50, 100, 150, null];
radius: number | null = null; radius: number = 50;
submit() { submit() {
this.$apollo.queries.searchEvents.refetch(); this.$apollo.queries.searchEvents.refetch();

View file

@ -15,20 +15,17 @@ defmodule Mobilizon.GraphQL.API.Search do
@doc """ @doc """
Searches actors. Searches actors.
""" """
@spec search_actors(String.t(), integer | nil, integer | nil, ActorType.t()) :: @spec search_actors(map(), integer | nil, integer | nil, ActorType.t()) ::
{:ok, Page.t()} | {:error, String.t()} {:ok, Page.t()} | {:error, String.t()}
def search_actors(search, page \\ 1, limit \\ 10, result_type) do def search_actors(%{term: term} = args, page \\ 1, limit \\ 10, result_type) do
search = String.trim(search) term = String.trim(term)
cond do cond do
search == "" ->
{:error, "Search can't be empty"}
# Some URLs could be domain.tld/@username, so keep this condition above # Some URLs could be domain.tld/@username, so keep this condition above
# the `is_handle` function # the `is_handle` function
is_url(search) -> is_url(term) ->
# skip, if it's not an actor # skip, if it's not an actor
case process_from_url(search) do case process_from_url(term) do
%Page{total: _total, elements: _elements} = page -> %Page{total: _total, elements: _elements} = page ->
{:ok, page} {:ok, page}
@ -36,11 +33,17 @@ defmodule Mobilizon.GraphQL.API.Search do
{:ok, %{total: 0, elements: []}} {:ok, %{total: 0, elements: []}}
end end
is_handle(search) -> is_handle(term) ->
{:ok, process_from_username(search)} {:ok, process_from_username(term)}
true -> true ->
page = Actors.build_actors_by_username_or_name_page(search, [result_type], page, limit) page =
Actors.build_actors_by_username_or_name_page(
Map.put(args, :term, term),
[result_type],
page,
limit
)
{:ok, page} {:ok, page}
end end

View file

@ -8,15 +8,15 @@ defmodule Mobilizon.GraphQL.Resolvers.Search do
@doc """ @doc """
Search persons Search persons
""" """
def search_persons(_parent, %{search: search, page: page, limit: limit}, _resolution) do def search_persons(_parent, %{page: page, limit: limit} = args, _resolution) do
Search.search_actors(search, page, limit, :Person) Search.search_actors(args, page, limit, :Person)
end end
@doc """ @doc """
Search groups Search groups
""" """
def search_groups(_parent, %{search: search, page: page, limit: limit}, _resolution) do def search_groups(_parent, %{page: page, limit: limit} = args, _resolution) do
Search.search_actors(search, page, limit, :Group) Search.search_actors(args, page, limit, :Group)
end end
@doc """ @doc """

View file

@ -5,6 +5,9 @@ defmodule Mobilizon.GraphQL.Schema.Actors.GroupType do
use Absinthe.Schema.Notation use Absinthe.Schema.Notation
import Absinthe.Resolution.Helpers, only: [dataloader: 1]
alias Mobilizon.Addresses
alias Mobilizon.GraphQL.Resolvers.{Discussion, Group, Member, Post, Resource, Todos} alias Mobilizon.GraphQL.Resolvers.{Discussion, Group, Member, Post, Resource, Todos}
alias Mobilizon.GraphQL.Schema alias Mobilizon.GraphQL.Schema
@ -29,11 +32,20 @@ defmodule Mobilizon.GraphQL.Schema.Actors.GroupType do
description: "Whether the actors manually approves followers" description: "Whether the actors manually approves followers"
) )
field(:visibility, :group_visibility,
description: "Whether the group can be found and/or promoted"
)
field(:suspended, :boolean, description: "If the actor is suspended") field(:suspended, :boolean, description: "If the actor is suspended")
field(:avatar, :picture, description: "The actor's avatar picture") field(:avatar, :picture, description: "The actor's avatar picture")
field(:banner, :picture, description: "The actor's banner picture") field(:banner, :picture, description: "The actor's banner picture")
field(:physical_address, :address,
resolve: dataloader(Addresses),
description: "The type of the event's address"
)
# These one should have a privacy setting # These one should have a privacy setting
field(:following, list_of(:follower), description: "List of followings") field(:following, list_of(:follower), description: "List of followings")
field(:followers, list_of(:follower), description: "List of followers") field(:followers, list_of(:follower), description: "List of followers")
@ -155,6 +167,8 @@ defmodule Mobilizon.GraphQL.Schema.Actors.GroupType do
"The banner for the group, either as an object or directly the ID of an existing Picture" "The banner for the group, either as an object or directly the ID of an existing Picture"
) )
arg(:physical_address, :address_input)
resolve(&Group.create_group/3) resolve(&Group.create_group/3)
end end
@ -165,6 +179,8 @@ defmodule Mobilizon.GraphQL.Schema.Actors.GroupType do
arg(:name, :string, description: "The displayed name for the group") arg(:name, :string, description: "The displayed name for the group")
arg(:summary, :string, description: "The summary for the group", default_value: "") arg(:summary, :string, description: "The summary for the group", default_value: "")
arg(:visibility, :group_visibility, description: "The visibility for the group")
arg(:avatar, :picture_input, arg(:avatar, :picture_input,
description: description:
"The avatar for the group, either as an object or directly the ID of an existing Picture" "The avatar for the group, either as an object or directly the ID of an existing Picture"
@ -175,6 +191,8 @@ defmodule Mobilizon.GraphQL.Schema.Actors.GroupType do
"The banner for the group, either as an object or directly the ID of an existing Picture" "The banner for the group, either as an object or directly the ID of an existing Picture"
) )
arg(:physical_address, :address_input)
resolve(&Group.update_group/3) resolve(&Group.update_group/3)
end end

View file

@ -27,7 +27,7 @@ defmodule Mobilizon.GraphQL.Schema.SearchType do
object :search_queries do object :search_queries do
@desc "Search persons" @desc "Search persons"
field :search_persons, :persons do field :search_persons, :persons do
arg(:search, non_null(:string)) arg(:term, :string, default_value: "")
arg(:page, :integer, default_value: 1) arg(:page, :integer, default_value: 1)
arg(:limit, :integer, default_value: 10) arg(:limit, :integer, default_value: 10)
@ -36,7 +36,9 @@ defmodule Mobilizon.GraphQL.Schema.SearchType do
@desc "Search groups" @desc "Search groups"
field :search_groups, :groups do field :search_groups, :groups do
arg(:search, non_null(:string)) arg(:term, :string, default_value: "")
arg(:location, :string, description: "A geohash for coordinates")
arg(:radius, :float, default_value: 50)
arg(:page, :integer, default_value: 1) arg(:page, :integer, default_value: 1)
arg(:limit, :integer, default_value: 10) arg(:limit, :integer, default_value: 10)

View file

@ -7,8 +7,9 @@ defmodule Mobilizon.Actors.Actor do
import Ecto.Changeset import Ecto.Changeset
alias Mobilizon.{Actors, Config, Crypto, Mention, Share} alias Mobilizon.{Actors, Addresses, Config, Crypto, Mention, Share}
alias Mobilizon.Actors.{ActorOpenness, ActorType, ActorVisibility, Follower, Member} alias Mobilizon.Actors.{ActorOpenness, ActorType, ActorVisibility, Follower, Member}
alias Mobilizon.Addresses.Address
alias Mobilizon.Discussions.Comment alias Mobilizon.Discussions.Comment
alias Mobilizon.Events.{Event, FeedToken} alias Mobilizon.Events.{Event, FeedToken}
alias Mobilizon.Media.File alias Mobilizon.Media.File
@ -55,7 +56,8 @@ defmodule Mobilizon.Actors.Actor do
shares: [Share.t()], shares: [Share.t()],
owner_shares: [Share.t()], owner_shares: [Share.t()],
memberships: [t], memberships: [t],
last_refreshed_at: DateTime.t() last_refreshed_at: DateTime.t(),
physical_address: Address.t()
} }
@required_attrs [:preferred_username, :keys, :suspended, :url] @required_attrs [:preferred_username, :keys, :suspended, :url]
@ -76,12 +78,13 @@ defmodule Mobilizon.Actors.Actor do
:manually_approves_followers, :manually_approves_followers,
:last_refreshed_at, :last_refreshed_at,
:user_id, :user_id,
:physical_address_id,
:visibility :visibility
] ]
@attrs @required_attrs ++ @optional_attrs @attrs @required_attrs ++ @optional_attrs
@update_required_attrs @required_attrs -- [:url] @update_required_attrs @required_attrs -- [:url]
@update_optional_attrs [:name, :summary, :manually_approves_followers, :user_id] @update_optional_attrs [:name, :summary, :manually_approves_followers, :user_id, :visibility]
@update_attrs @update_required_attrs ++ @update_optional_attrs @update_attrs @update_required_attrs ++ @update_optional_attrs
@registration_required_attrs [:preferred_username, :keys, :suspended, :url, :type] @registration_required_attrs [:preferred_username, :keys, :suspended, :url, :type]
@ -156,6 +159,7 @@ defmodule Mobilizon.Actors.Actor do
embeds_one(:avatar, File, on_replace: :update) embeds_one(:avatar, File, on_replace: :update)
embeds_one(:banner, File, on_replace: :update) embeds_one(:banner, File, on_replace: :update)
belongs_to(:user, User) belongs_to(:user, User)
belongs_to(:physical_address, Address, on_replace: :nilify)
has_many(:followers, Follower, foreign_key: :target_actor_id) has_many(:followers, Follower, foreign_key: :target_actor_id)
has_many(:followings, Follower, foreign_key: :actor_id) has_many(:followings, Follower, foreign_key: :actor_id)
has_many(:organized_events, Event, foreign_key: :organizer_actor_id) has_many(:organized_events, Event, foreign_key: :organizer_actor_id)
@ -228,7 +232,7 @@ defmodule Mobilizon.Actors.Actor do
actor actor
|> cast(attrs, @attrs) |> cast(attrs, @attrs)
|> build_urls() |> build_urls()
|> common_changeset() |> common_changeset(attrs)
|> unique_username_validator() |> unique_username_validator()
|> validate_required(@required_attrs) |> validate_required(@required_attrs)
end end
@ -238,7 +242,7 @@ defmodule Mobilizon.Actors.Actor do
def update_changeset(%__MODULE__{} = actor, attrs) do def update_changeset(%__MODULE__{} = actor, attrs) do
actor actor
|> cast(attrs, @update_attrs) |> cast(attrs, @update_attrs)
|> common_changeset() |> common_changeset(attrs)
|> validate_required(@update_required_attrs) |> validate_required(@update_required_attrs)
end end
@ -263,7 +267,7 @@ defmodule Mobilizon.Actors.Actor do
actor actor
|> cast(attrs, @registration_attrs) |> cast(attrs, @registration_attrs)
|> build_urls() |> build_urls()
|> common_changeset() |> common_changeset(attrs)
|> unique_username_validator() |> unique_username_validator()
|> validate_required(@registration_required_attrs) |> validate_required(@registration_required_attrs)
end end
@ -277,7 +281,7 @@ defmodule Mobilizon.Actors.Actor do
%__MODULE__{} %__MODULE__{}
|> cast(attrs, @remote_actor_creation_attrs) |> cast(attrs, @remote_actor_creation_attrs)
|> validate_required(@remote_actor_creation_required_attrs) |> validate_required(@remote_actor_creation_required_attrs)
|> common_changeset() |> common_changeset(attrs)
|> unique_username_validator() |> unique_username_validator()
|> validate_length(:summary, max: 5000) |> validate_length(:summary, max: 5000)
|> validate_length(:preferred_username, max: 100) |> validate_length(:preferred_username, max: 100)
@ -287,11 +291,12 @@ defmodule Mobilizon.Actors.Actor do
changeset changeset
end end
@spec common_changeset(Ecto.Changeset.t()) :: Ecto.Changeset.t() @spec common_changeset(Ecto.Changeset.t(), map()) :: Ecto.Changeset.t()
defp common_changeset(%Ecto.Changeset{} = changeset) do defp common_changeset(%Ecto.Changeset{} = changeset, attrs) do
changeset changeset
|> cast_embed(:avatar) |> cast_embed(:avatar)
|> cast_embed(:banner) |> cast_embed(:banner)
|> put_address(attrs)
|> unique_constraint(:url, name: :actors_url_index) |> unique_constraint(:url, name: :actors_url_index)
|> unique_constraint(:preferred_username, name: :actors_preferred_username_domain_type_index) |> unique_constraint(:preferred_username, name: :actors_preferred_username_domain_type_index)
|> validate_format(:preferred_username, ~r/[a-z0-9_]+/) |> validate_format(:preferred_username, ~r/[a-z0-9_]+/)
@ -306,7 +311,7 @@ defmodule Mobilizon.Actors.Actor do
actor actor
|> cast(params, @group_creation_attrs) |> cast(params, @group_creation_attrs)
|> build_urls(:Group) |> build_urls(:Group)
|> common_changeset() |> common_changeset(params)
|> put_change(:domain, nil) |> put_change(:domain, nil)
|> put_change(:keys, Crypto.generate_rsa_2048_private_key()) |> put_change(:keys, Crypto.generate_rsa_2048_private_key())
|> put_change(:type, :Group) |> put_change(:type, :Group)
@ -412,4 +417,36 @@ defmodule Mobilizon.Actors.Actor do
|> Ecto.Changeset.cast(data, @attrs) |> Ecto.Changeset.cast(data, @attrs)
|> build_urls() |> build_urls()
end end
# In case the provided addresses is an existing one
@spec put_address(Ecto.Changeset.t(), map) :: Ecto.Changeset.t()
defp put_address(%Ecto.Changeset{} = changeset, %{
physical_address: %{id: id} = _physical_address
})
when not is_nil(id) do
case Addresses.get_address(id) do
%Address{} = address ->
put_assoc(changeset, :physical_address, address)
_ ->
cast_assoc(changeset, :physical_address)
end
end
# In case it's a new address but the origin_id is an existing one
defp put_address(%Ecto.Changeset{} = changeset, %{physical_address: %{origin_id: origin_id}})
when not is_nil(origin_id) do
case Addresses.get_address_by_origin_id(origin_id) do
%Address{} = address ->
put_assoc(changeset, :physical_address, address)
_ ->
cast_assoc(changeset, :physical_address)
end
end
# In case it's a new address without any origin_id (manual)
defp put_address(%Ecto.Changeset{} = changeset, _attrs) do
cast_assoc(changeset, :physical_address)
end
end end

View file

@ -5,10 +5,13 @@ defmodule Mobilizon.Actors do
import Ecto.Query import Ecto.Query
import EctoEnum import EctoEnum
import Geo.PostGIS, only: [st_dwithin_in_meters: 3]
import Mobilizon.Service.Guards
alias Ecto.Multi alias Ecto.Multi
alias Mobilizon.Actors.{Actor, Bot, Follower, Member} alias Mobilizon.Actors.{Actor, Bot, Follower, Member}
alias Mobilizon.Addresses.Address
alias Mobilizon.{Crypto, Events} alias Mobilizon.{Crypto, Events}
alias Mobilizon.Media.File alias Mobilizon.Media.File
alias Mobilizon.Service.Workers alias Mobilizon.Service.Workers
@ -235,6 +238,7 @@ defmodule Mobilizon.Actors do
@spec update_actor(Actor.t(), map) :: {:ok, Actor.t()} | {:error, Ecto.Changeset.t()} @spec update_actor(Actor.t(), map) :: {:ok, Actor.t()} | {:error, Ecto.Changeset.t()}
def update_actor(%Actor{} = actor, attrs) do def update_actor(%Actor{} = actor, attrs) do
actor actor
|> Repo.preload([:physical_address])
|> Actor.update_changeset(attrs) |> Actor.update_changeset(attrs)
|> delete_files_if_media_changed() |> delete_files_if_media_changed()
|> Repo.update() |> Repo.update()
@ -422,14 +426,20 @@ defmodule Mobilizon.Actors do
Builds a page struct for actors by their name or displayed name. Builds a page struct for actors by their name or displayed name.
""" """
@spec build_actors_by_username_or_name_page( @spec build_actors_by_username_or_name_page(
String.t(), map(),
[ActorType.t()], [ActorType.t()],
integer | nil, integer | nil,
integer | nil integer | nil
) :: Page.t() ) :: Page.t()
def build_actors_by_username_or_name_page(username, types, page \\ nil, limit \\ nil) do def build_actors_by_username_or_name_page(
username %{term: term} = args,
|> actor_by_username_or_name_query() types,
page \\ nil,
limit \\ nil
) do
Actor
|> actor_by_username_or_name_query(term)
|> actors_for_location(args)
|> filter_by_types(types) |> filter_by_types(types)
|> Page.build_page(page, limit) |> Page.build_page(page, limit)
end end
@ -1129,19 +1139,23 @@ defmodule Mobilizon.Actors do
) )
end end
@spec actor_by_username_or_name_query(String.t()) :: Ecto.Query.t() @spec actor_by_username_or_name_query(Ecto.Query.t(), String.t()) :: Ecto.Query.t()
defp actor_by_username_or_name_query(username) do defp actor_by_username_or_name_query(query, ""), do: query
from(
a in Actor, defp actor_by_username_or_name_query(query, username) do
where: query
|> where(
[a],
fragment( fragment(
"f_unaccent(?) %> f_unaccent(?) or f_unaccent(coalesce(?, '')) %> f_unaccent(?)", "f_unaccent(?) %> f_unaccent(?) or f_unaccent(coalesce(?, '')) %> f_unaccent(?)",
a.preferred_username, a.preferred_username,
^username, ^username,
a.name, a.name,
^username ^username
), )
order_by: )
|> order_by(
[a],
fragment( fragment(
"word_similarity(?, ?) + word_similarity(coalesce(?, ''), ?) desc", "word_similarity(?, ?) + word_similarity(coalesce(?, ''), ?) desc",
a.preferred_username, a.preferred_username,
@ -1152,6 +1166,27 @@ defmodule Mobilizon.Actors do
) )
end end
@spec actors_for_location(Ecto.Query.t(), map()) :: Ecto.Query.t()
defp actors_for_location(query, %{radius: radius}) when is_nil(radius),
do: query
defp actors_for_location(query, %{location: location, radius: radius})
when is_valid_string?(location) and not is_nil(radius) do
with {lon, lat} <- Geohax.decode(location),
point <- Geo.WKT.decode!("SRID=4326;POINT(#{lon} #{lat})") do
query
|> join(:inner, [q], a in Address, on: a.id == q.physical_address_id, as: :address)
|> where(
[q],
st_dwithin_in_meters(^point, as(:address).geom, ^(radius * 1000))
)
else
_ -> query
end
end
defp actors_for_location(query, _args), do: query
@spec person_query :: Ecto.Query.t() @spec person_query :: Ecto.Query.t()
defp person_query do defp person_query do
from(a in Actor, where: a.type == ^:Person) from(a in Actor, where: a.type == ^:Person)

View file

@ -29,6 +29,9 @@ defmodule Mobilizon.Addresses do
@spec get_address_by_url(String.t()) :: Address.t() | nil @spec get_address_by_url(String.t()) :: Address.t() | nil
def get_address_by_url(url), do: Repo.get_by(Address, url: url) def get_address_by_url(url), do: Repo.get_by(Address, url: url)
@spec get_address_by_origin_id(String.t()) :: Address.t() | nil
def get_address_by_origin_id(origin_id), do: Repo.get_by(Address, origin_id: origin_id)
@doc """ @doc """
Creates an address. Creates an address.
""" """

View file

@ -0,0 +1,9 @@
defmodule Mobilizon.Storage.Repo.Migrations.AddAddressToActors do
use Ecto.Migration
def change do
alter table(:actors) do
add(:physical_address_id, references(:addresses, on_delete: :nothing))
end
end
end

View file

@ -17,7 +17,7 @@ defmodule Mobilizon.GraphQL.API.SearchTest do
with_mock ActivityPub, with_mock ActivityPub,
find_or_make_actor_from_nickname: fn "toto@domain.tld" -> {:ok, %Actor{id: 42}} end do find_or_make_actor_from_nickname: fn "toto@domain.tld" -> {:ok, %Actor{id: 42}} end do
assert {:ok, %Page{total: 1, elements: [%Actor{id: 42}]}} == assert {:ok, %Page{total: 1, elements: [%Actor{id: 42}]}} ==
Search.search_actors("toto@domain.tld", 1, 10, :Person) Search.search_actors(%{term: "toto@domain.tld"}, 1, 10, :Person)
assert_called(ActivityPub.find_or_make_actor_from_nickname("toto@domain.tld")) assert_called(ActivityPub.find_or_make_actor_from_nickname("toto@domain.tld"))
end end
@ -27,7 +27,7 @@ defmodule Mobilizon.GraphQL.API.SearchTest do
with_mock ActivityPub, with_mock ActivityPub,
fetch_object_from_url: fn "https://social.tcit.fr/users/tcit" -> {:ok, %Actor{id: 42}} end do fetch_object_from_url: fn "https://social.tcit.fr/users/tcit" -> {:ok, %Actor{id: 42}} end do
assert {:ok, %Page{total: 1, elements: [%Actor{id: 42}]}} == assert {:ok, %Page{total: 1, elements: [%Actor{id: 42}]}} ==
Search.search_actors("https://social.tcit.fr/users/tcit", 1, 10, :Person) Search.search_actors(%{term: "https://social.tcit.fr/users/tcit"}, 1, 10, :Person)
assert_called(ActivityPub.fetch_object_from_url("https://social.tcit.fr/users/tcit")) assert_called(ActivityPub.fetch_object_from_url("https://social.tcit.fr/users/tcit"))
end end
@ -35,13 +35,15 @@ defmodule Mobilizon.GraphQL.API.SearchTest do
test "search actors" do test "search actors" do
with_mock Actors, with_mock Actors,
build_actors_by_username_or_name_page: fn "toto", _type, 1, 10 -> build_actors_by_username_or_name_page: fn %{term: "toto"}, _type, 1, 10 ->
%Page{total: 1, elements: [%Actor{id: 42}]} %Page{total: 1, elements: [%Actor{id: 42}]}
end do end do
assert {:ok, %{total: 1, elements: [%Actor{id: 42}]}} = assert {:ok, %{total: 1, elements: [%Actor{id: 42}]}} =
Search.search_actors("toto", 1, 10, :Person) Search.search_actors(%{term: "toto"}, 1, 10, :Person)
assert_called(Actors.build_actors_by_username_or_name_page("toto", [:Person], 1, 10)) assert_called(
Actors.build_actors_by_username_or_name_page(%{term: "toto"}, [:Person], 1, 10)
)
end end
end end

View file

@ -208,6 +208,24 @@ defmodule Mobilizon.GraphQL.Resolvers.SearchTest do
end end
describe "search_persons/3" do describe "search_persons/3" do
@search_persons_query """
query SearchPersons($term: String!, $page: Int, $limit: Int) {
searchPersons(term: $term, page: $page, limit: $limit) {
total
elements {
id
avatar {
url
}
domain
preferredUsername
name
__typename
}
}
}
"""
test "finds persons with basic search", %{ test "finds persons with basic search", %{
conn: conn, conn: conn,
user: user user: user
@ -217,29 +235,17 @@ defmodule Mobilizon.GraphQL.Resolvers.SearchTest do
event = insert(:event, title: "test_event") event = insert(:event, title: "test_event")
Workers.BuildSearch.insert_search_event(event) Workers.BuildSearch.insert_search_event(event)
query = """
{
search_persons(search: "test") {
total,
elements {
preferredUsername,
__typename
}
},
}
"""
res = res =
conn AbsintheHelpers.graphql_query(conn,
|> get("/api", AbsintheHelpers.query_skeleton(query, "search")) query: @search_persons_query,
variables: %{term: "test"}
)
assert json_response(res, 200)["errors"] == nil assert res["errors"] == nil
assert json_response(res, 200)["data"]["search_persons"]["total"] == 1 assert res["data"]["searchPersons"]["total"] == 1
assert json_response(res, 200)["data"]["search_persons"]["elements"] |> length == 1 assert res["data"]["searchPersons"]["elements"] |> length == 1
assert hd(json_response(res, 200)["data"]["search_persons"]["elements"])[ assert hd(res["data"]["searchPersons"]["elements"])["preferredUsername"] ==
"preferredUsername"
] ==
actor.preferred_username actor.preferred_username
end end
@ -256,36 +262,41 @@ defmodule Mobilizon.GraphQL.Resolvers.SearchTest do
Workers.BuildSearch.insert_search_event(event2) Workers.BuildSearch.insert_search_event(event2)
Workers.BuildSearch.insert_search_event(event3) Workers.BuildSearch.insert_search_event(event3)
query = """ res =
{ AbsintheHelpers.graphql_query(conn,
search_persons(search: "pineapple") { query: @search_persons_query,
total, variables: %{term: "pineapple"}
)
assert res["errors"] == nil
assert res["data"]["searchPersons"]["total"] == 1
assert res["data"]["searchPersons"]["elements"]
|> length == 1
assert hd(res["data"]["searchPersons"]["elements"])["preferredUsername"] ==
actor.preferred_username
end
end
describe "search_groups/3" do
@search_groups_query """
query SearchGroups($term: String, $location: String, $radius: Float) {
searchGroups(term: $term, location: $location, radius: $radius) {
total
elements { elements {
preferredUsername, avatar {
url
}
domain
preferredUsername
name
__typename __typename
} }
} }
} }
""" """
res =
conn
|> get("/api", AbsintheHelpers.query_skeleton(query, "search"))
assert json_response(res, 200)["errors"] == nil
assert json_response(res, 200)["data"]["search_persons"]["total"] == 1
assert json_response(res, 200)["data"]["search_persons"]["elements"]
|> length == 1
assert hd(json_response(res, 200)["data"]["search_persons"]["elements"])[
"preferredUsername"
] ==
actor.preferred_username
end
end
describe "search_groups/3" do
test "finds persons with basic search", %{ test "finds persons with basic search", %{
conn: conn, conn: conn,
user: user user: user
@ -295,27 +306,17 @@ defmodule Mobilizon.GraphQL.Resolvers.SearchTest do
event = insert(:event, title: "test_event") event = insert(:event, title: "test_event")
Workers.BuildSearch.insert_search_event(event) Workers.BuildSearch.insert_search_event(event)
query = """
{
search_groups(search: "test") {
total,
elements {
preferredUsername,
__typename
}
},
}
"""
res = res =
conn AbsintheHelpers.graphql_query(conn,
|> get("/api", AbsintheHelpers.query_skeleton(query, "search")) query: @search_groups_query,
variables: %{term: "test"}
)
assert json_response(res, 200)["errors"] == nil assert res["errors"] == nil
assert json_response(res, 200)["data"]["search_groups"]["total"] == 1 assert res["data"]["searchGroups"]["total"] == 1
assert json_response(res, 200)["data"]["search_groups"]["elements"] |> length == 1 assert res["data"]["searchGroups"]["elements"] |> length == 1
assert hd(json_response(res, 200)["data"]["search_groups"]["elements"])["preferredUsername"] == assert hd(res["data"]["searchGroups"]["elements"])["preferredUsername"] ==
group.preferred_username group.preferred_username
end end
@ -328,28 +329,54 @@ defmodule Mobilizon.GraphQL.Resolvers.SearchTest do
event = insert(:event, title: "Tour du monde des Kafés") event = insert(:event, title: "Tour du monde des Kafés")
Workers.BuildSearch.insert_search_event(event) Workers.BuildSearch.insert_search_event(event)
# Elaborate query
query = """
{
search_groups(search: "Kafé") {
total,
elements {
preferredUsername,
__typename
}
}
}
"""
res = res =
conn AbsintheHelpers.graphql_query(conn,
|> get("/api", AbsintheHelpers.query_skeleton(query, "search")) query: @search_groups_query,
variables: %{term: "Kafé"}
)
assert json_response(res, 200)["errors"] == nil assert res["errors"] == nil
assert json_response(res, 200)["data"]["search_groups"]["total"] == 1 assert res["data"]["searchGroups"]["total"] == 1
assert hd(json_response(res, 200)["data"]["search_groups"]["elements"])["preferredUsername"] == assert hd(res["data"]["searchGroups"]["elements"])["preferredUsername"] ==
group.preferred_username group.preferred_username
end end
test "finds groups with location", %{conn: conn} do
{lon, lat} = {45.75, 4.85}
point = %Geo.Point{coordinates: {lon, lat}, srid: 4326}
geohash = Geohax.encode(lon, lat, 6)
geohash_2 = Geohax.encode(25, -19, 6)
address = insert(:address, geom: point)
group =
insert(:actor,
type: :Group,
preferred_username: "want_coffee",
name: "Want coffee ?",
physical_address: address
)
res =
AbsintheHelpers.graphql_query(conn,
query: @search_groups_query,
variables: %{location: geohash}
)
assert res["errors"] == nil
assert res["data"]["searchGroups"]["total"] == 1
assert hd(res["data"]["searchGroups"]["elements"])["preferredUsername"] ==
group.preferred_username
res =
AbsintheHelpers.graphql_query(conn,
query: @search_groups_query,
variables: %{location: geohash_2}
)
assert res["errors"] == nil
assert res["data"]["searchGroups"]["total"] == 0
end
end end
end end

View file

@ -188,7 +188,7 @@ defmodule Mobilizon.ActorsTest do
with {:ok, %Actor{id: actor2_id}} <- with {:ok, %Actor{id: actor2_id}} <-
ActivityPub.get_or_fetch_actor_by_url(@remote_account_url) do ActivityPub.get_or_fetch_actor_by_url(@remote_account_url) do
%Page{total: 2, elements: actors} = %Page{total: 2, elements: actors} =
Actors.build_actors_by_username_or_name_page("tcit", [:Person]) Actors.build_actors_by_username_or_name_page(%{term: "tcit"}, [:Person])
actors_ids = actors |> Enum.map(& &1.id) actors_ids = actors |> Enum.map(& &1.id)
@ -199,7 +199,7 @@ defmodule Mobilizon.ActorsTest do
test "test build_actors_by_username_or_name_page/4 returns actors with similar names" do test "test build_actors_by_username_or_name_page/4 returns actors with similar names" do
%{total: 0, elements: actors} = %{total: 0, elements: actors} =
Actors.build_actors_by_username_or_name_page("ohno", [:Person]) Actors.build_actors_by_username_or_name_page(%{term: "ohno"}, [:Person])
assert actors == [] assert actors == []
end end