mirror of
https://github.com/mirego/accent.git
synced 2024-09-11 10:55:28 +03:00
wip
This commit is contained in:
parent
38897cdc53
commit
4b142e7b23
@ -12,6 +12,23 @@ if config_env() == :dev do
|
||||
config :accent, Accent.Repo, log: false
|
||||
end
|
||||
|
||||
if config_env() == :test do
|
||||
events = ~w(sync add_translations create_collaborator create_comment complete_review new_conflicts)
|
||||
|
||||
config :accent, Accent.Hook,
|
||||
outbounds: [{Accent.Hook.Outbounds.Mock, events: events}],
|
||||
inbounds: [{Accent.Hook.Inbounds.Mock, events: events}]
|
||||
else
|
||||
config :accent, Accent.Hook,
|
||||
outbounds: [
|
||||
{Accent.Hook.Outbounds.Discord, events: ~w(sync complete_review new_conflicts)},
|
||||
{Accent.Hook.Outbounds.Email, events: ~w(create_collaborator create_comment)},
|
||||
{Accent.Hook.Outbounds.Slack, events: ~w(sync complete_review new_conflicts)},
|
||||
{Accent.Hook.Outbounds.Websocket, events: ~w(sync create_collaborator create_comment complete_review new_conflicts)}
|
||||
],
|
||||
inbounds: [{Accent.Hook.Inbounds.GitHub, events: ~w(sync)}]
|
||||
end
|
||||
|
||||
config :accent, Accent.Endpoint,
|
||||
render_errors: [accepts: ~w(json)],
|
||||
pubsub_server: Accent.PubSub
|
||||
|
@ -26,15 +26,6 @@ config :accent, Accent.Endpoint,
|
||||
]
|
||||
]
|
||||
|
||||
config :accent, Accent.Hook,
|
||||
outbounds: [
|
||||
{Accent.Hook.Outbounds.Discord, events: ~w(sync)},
|
||||
{Accent.Hook.Outbounds.Email, events: ~w(create_collaborator create_comment)},
|
||||
{Accent.Hook.Outbounds.Slack, events: ~w(sync)},
|
||||
{Accent.Hook.Outbounds.Websocket, events: ~w(sync create_collaborator create_comment)}
|
||||
],
|
||||
inbounds: [{Accent.Hook.Inbounds.GitHub, events: ~w(sync)}]
|
||||
|
||||
config :logger, :console,
|
||||
format: "$metadata[$level] $message\n",
|
||||
metadata: ~w(current_user graphql_operation hook_service hook_url)a
|
||||
|
@ -4,15 +4,6 @@ config :accent, Accent.Endpoint,
|
||||
check_origin: false,
|
||||
server: true
|
||||
|
||||
config :accent, Accent.Hook,
|
||||
outbounds: [
|
||||
{Accent.Hook.Outbounds.Discord, events: ~w(sync)},
|
||||
{Accent.Hook.Outbounds.Email, events: ~w(create_collaborator create_comment)},
|
||||
{Accent.Hook.Outbounds.Slack, events: ~w(sync)},
|
||||
{Accent.Hook.Outbounds.Websocket, events: ~w(sync create_collaborator create_comment)}
|
||||
],
|
||||
inbounds: [{Accent.Hook.Inbounds.GitHub, events: ~w(sync)}]
|
||||
|
||||
config :logger, :console,
|
||||
format: "$time $metadata[$level] $message\n",
|
||||
level: :info,
|
||||
|
@ -10,12 +10,6 @@ config :ueberauth, Ueberauth, providers: [{:dummy, {Accent.Auth.Ueberauth.DummyS
|
||||
|
||||
config :accent, Oban, crontab: false, queues: false, plugins: false
|
||||
|
||||
events = ~w(sync merge create_collaborator create_comment)
|
||||
|
||||
config :accent, Accent.Hook,
|
||||
outbounds: [{Accent.Hook.Outbounds.Mock, events: events}],
|
||||
inbounds: [{Accent.Hook.Inbounds.Mock, events: events}]
|
||||
|
||||
config :goth, disabled: true
|
||||
config :tesla, logger_enabled: false, adapter: Tesla.Mock
|
||||
|
||||
|
@ -106,7 +106,11 @@ defmodule Accent.TelemetryUI do
|
||||
|
||||
defp absinthe_metrics do
|
||||
absinthe_tag_values = fn metadata ->
|
||||
operation_name = Enum.map_join(metadata.blueprint.operations, ",", & &1.name)
|
||||
operation_name =
|
||||
metadata.blueprint.operations
|
||||
|> Enum.map(& &1.name)
|
||||
|> Enum.uniq()
|
||||
|> Enum.join(",")
|
||||
|
||||
%{operation_name: operation_name}
|
||||
end
|
||||
@ -147,8 +151,14 @@ defmodule Accent.TelemetryUI do
|
||||
graphql_tag_values = fn metadata ->
|
||||
operation_name =
|
||||
case metadata.conn.params do
|
||||
%{"_json" => json} -> Enum.map_join(json, ",", & &1["operationName"])
|
||||
_ -> nil
|
||||
%{"_json" => json} ->
|
||||
json
|
||||
|> Enum.map(& &1["operationName"])
|
||||
|> Enum.uniq()
|
||||
|> Enum.join(",")
|
||||
|
||||
_ ->
|
||||
nil
|
||||
end
|
||||
|
||||
%{operation_name: operation_name}
|
||||
|
@ -22,7 +22,7 @@ defmodule Accent.GraphQL.Resolvers.Collaborator do
|
||||
|
||||
case CollaboratorCreator.create(params) do
|
||||
{:ok, collaborator} ->
|
||||
Accent.Hook.outbound(%Hook.Context{
|
||||
Hook.outbound(%Hook.Context{
|
||||
event: "create_collaborator",
|
||||
project_id: project.id,
|
||||
user_id: info.context[:conn].assigns[:current_user].id,
|
||||
|
@ -9,7 +9,10 @@ defmodule Accent.GraphQL.Types.Integration do
|
||||
|
||||
enum :project_integration_event do
|
||||
value(:sync, as: "sync")
|
||||
value(:merge, as: "merge")
|
||||
value(:new_conflicts, as: "new_conflicts")
|
||||
value(:complete_review, as: "complete_review")
|
||||
value(:create_collaborator, as: "create_collaborator")
|
||||
value(:create_comment, as: "create_comment")
|
||||
end
|
||||
|
||||
interface :project_integration do
|
||||
|
@ -1,20 +0,0 @@
|
||||
defmodule Accent.Hook.Outbounds.Discord do
|
||||
use Oban.Worker, queue: :hook
|
||||
require EEx
|
||||
|
||||
EEx.function_from_file(:def, :sync, "lib/hook/outbounds/discord/templates/sync.eex", [:assigns], trim: true)
|
||||
|
||||
alias Accent.Hook.Outbounds.PostURL
|
||||
|
||||
@impl Oban.Worker
|
||||
def perform(%Oban.Job{args: args}) do
|
||||
context = Accent.Hook.Context.from_worker(args)
|
||||
PostURL.perform("discord", &build_body/1).(context)
|
||||
end
|
||||
|
||||
defp build_body(%{event: "sync", user: user, payload: %{"document_path" => document_path, "batch_operation_stats" => stats}}) do
|
||||
%{
|
||||
text: sync(user: user, document_path: document_path, stats: stats)
|
||||
}
|
||||
end
|
||||
end
|
9
lib/hook/outbounds/discord/discord.ex
Normal file
9
lib/hook/outbounds/discord/discord.ex
Normal file
@ -0,0 +1,9 @@
|
||||
defmodule Accent.Hook.Outbounds.Discord do
|
||||
use Oban.Worker, queue: :hook
|
||||
|
||||
@impl Oban.Worker
|
||||
def perform(%Oban.Job{args: args}) do
|
||||
context = Accent.Hook.Context.from_worker(args)
|
||||
Accent.Hook.Outbounds.PostURL.perform("discord", context, Hook.Outbounds.Discord.Templates)
|
||||
end
|
||||
end
|
22
lib/hook/outbounds/discord/templates.ex
Normal file
22
lib/hook/outbounds/discord/templates.ex
Normal file
@ -0,0 +1,22 @@
|
||||
defmodule Hook.Outbounds.Discord.Templates do
|
||||
require EEx
|
||||
|
||||
@sync_template """
|
||||
**<%= @user %>** just synced a file: *<%= @document_path %>*
|
||||
|
||||
**Stats:**<%= for %{"action" => action, "count" => count} <- @stats do %>
|
||||
<%= Phoenix.Naming.humanize(action) %>: *<%= count %>*<% end %>
|
||||
"""
|
||||
@new_conflicts_template """
|
||||
**<%= @user %>** just added *<%= @new_conflicts_count %> strings* to review.
|
||||
The project is currently **<%= @reviewed_count / @translations_count %>** reviewed (<%= @reviewed_count %>/<%= @translations_count %>)
|
||||
"""
|
||||
@complete_review_template """
|
||||
**<%= @user %>** just finished reviewing all strings!
|
||||
The project currently has <%= @translations_count %> reviewed translations.
|
||||
"""
|
||||
|
||||
EEx.function_from_string(:def, :sync, @sync_template, [:assigns], trim: true)
|
||||
EEx.function_from_string(:def, :new_conflicts, @new_conflicts_template, [:assigns], trim: true)
|
||||
EEx.function_from_string(:def, :complete_review, @complete_review_template, [:assigns], trim: true)
|
||||
end
|
@ -1,4 +0,0 @@
|
||||
**<%= @user.fullname %>** just synced a file: *<%= @document_path %>*
|
||||
|
||||
**Stats:**<%= for %{"action" => action, "count" => count} <- @stats do %>
|
||||
<%= action %>: *<%= count %>*<% end %>
|
@ -22,8 +22,8 @@ defmodule Accent.Hook.Outbounds.Email do
|
||||
ProjectInviteEmail.create(emails, user, project)
|
||||
end
|
||||
|
||||
defp build_email(emails, %{event: "create_comment", project: project, payload: payload}) do
|
||||
CreateCommentEmail.create(emails, project, payload)
|
||||
defp build_email(emails, %{event: "create_comment", project: project, user: user, payload: payload}) do
|
||||
CreateCommentEmail.create(emails, user, project, payload)
|
||||
end
|
||||
|
||||
defp fetch_emails(%{event: "create_collaborator", payload: payload}) do
|
||||
|
@ -4,23 +4,22 @@ defmodule Accent.Hook.Outbounds.PostURL do
|
||||
import Ecto.Query, only: [where: 2]
|
||||
|
||||
alias Accent.Repo
|
||||
alias Accent.User
|
||||
|
||||
def perform(service, build_body) do
|
||||
fn context = %{event: event, project: project} ->
|
||||
body = build_body.(context)
|
||||
integrations = filter_service_integration_events(project, event, service)
|
||||
urls = Enum.map(integrations, & &1.data.url)
|
||||
def perform(service, context, templates) do
|
||||
urls = fetch_service_integration_urls(context.project, context.event, service)
|
||||
body = build_body(templates, context)
|
||||
|
||||
post_urls(urls, body, service)
|
||||
end
|
||||
post_urls(urls, body, service)
|
||||
end
|
||||
|
||||
defp filter_service_integration_events(project, event, service) do
|
||||
defp fetch_service_integration_urls(project, event, service) do
|
||||
project
|
||||
|> Ecto.assoc(:integrations)
|
||||
|> where(service: ^service)
|
||||
|> Repo.all()
|
||||
|> Enum.filter(&(event in &1.events))
|
||||
|> Enum.map(& &1.data.url)
|
||||
end
|
||||
|
||||
defp post_urls(urls, body, service) do
|
||||
@ -49,4 +48,34 @@ defmodule Accent.Hook.Outbounds.PostURL do
|
||||
|
||||
defp formatted_diff(diff) when diff > 1000, do: [diff |> div(1000) |> Integer.to_string(), "ms"]
|
||||
defp formatted_diff(diff), do: [Integer.to_string(diff), "µs"]
|
||||
|
||||
defp build_body(templates, %{event: "sync", user: user, payload: %{"document_path" => document_path, "batch_operation_stats" => stats}}) do
|
||||
assigns = %{
|
||||
user: User.name_with_fallback(user),
|
||||
document_path: document_path,
|
||||
stats: stats
|
||||
}
|
||||
|
||||
%{text: templates.sync(assigns)}
|
||||
end
|
||||
|
||||
defp build_body(templates, %{event: "new_conflicts", user: user, payload: payload}) do
|
||||
assigns = %{
|
||||
user: User.name_with_fallback(user),
|
||||
reviewed_count: payload["reviewed_count"],
|
||||
new_conflicts_count: payload["new_conflicts_count"],
|
||||
translations_count: payload["translations_count"]
|
||||
}
|
||||
|
||||
%{text: templates.new_conflicts(assigns)}
|
||||
end
|
||||
|
||||
defp build_body(templates, %{event: "complete_review", user: user, payload: payload}) do
|
||||
assigns = %{
|
||||
user: User.name_with_fallback(user),
|
||||
translations_count: payload["translations_count"]
|
||||
}
|
||||
|
||||
%{text: templates.complete_review(assigns)}
|
||||
end
|
||||
end
|
||||
|
@ -1,20 +0,0 @@
|
||||
defmodule Accent.Hook.Outbounds.Slack do
|
||||
use Oban.Worker, queue: :hook
|
||||
require EEx
|
||||
|
||||
EEx.function_from_file(:def, :sync, "lib/hook/outbounds/slack/templates/sync.eex", [:assigns], trim: true)
|
||||
|
||||
alias Accent.Hook.Outbounds.PostURL
|
||||
|
||||
@impl Oban.Worker
|
||||
def perform(%Oban.Job{args: args}) do
|
||||
context = Accent.Hook.Context.from_worker(args)
|
||||
PostURL.perform("slack", &build_body/1).(context)
|
||||
end
|
||||
|
||||
defp build_body(%{event: "sync", user: user, payload: %{"document_path" => document_path, "batch_operation_stats" => stats}}) do
|
||||
%{
|
||||
text: sync(user: user, document_path: document_path, stats: stats)
|
||||
}
|
||||
end
|
||||
end
|
9
lib/hook/outbounds/slack/slack.ex
Normal file
9
lib/hook/outbounds/slack/slack.ex
Normal file
@ -0,0 +1,9 @@
|
||||
defmodule Accent.Hook.Outbounds.Slack do
|
||||
use Oban.Worker, queue: :hook
|
||||
|
||||
@impl Oban.Worker
|
||||
def perform(%Oban.Job{args: args}) do
|
||||
context = Accent.Hook.Context.from_worker(args)
|
||||
Accent.Hook.Outbounds.PostURL.perform("slack", context, Hook.Outbounds.Slack.Templates)
|
||||
end
|
||||
end
|
22
lib/hook/outbounds/slack/templates.ex
Normal file
22
lib/hook/outbounds/slack/templates.ex
Normal file
@ -0,0 +1,22 @@
|
||||
defmodule Hook.Outbounds.Slack.Templates do
|
||||
require EEx
|
||||
|
||||
@sync_template """
|
||||
*<%= @user %>* just synced a file: _<%= @document_path %>_
|
||||
|
||||
*Stats:*<%= for %{"action" => action, "count" => count} <- @stats do %>
|
||||
<%= Phoenix.Naming.humanize(action) %>: _<%= count %>_<% end %>
|
||||
"""
|
||||
@new_conflicts_template """
|
||||
*<%= @user %>* just added _<%= @new_conflicts_count %> strings_ to review.
|
||||
The project is currently *<%= @reviewed_count / @translations_count %>* reviewed (<%= @reviewed_count %>/<%= @translations_count %>)
|
||||
"""
|
||||
@complete_review_template """
|
||||
*<%= @user %>* just finished reviewing all strings!
|
||||
The project currently has <%= @translations_count %> reviewed translations.
|
||||
"""
|
||||
|
||||
EEx.function_from_string(:def, :sync, @sync_template, [:assigns], trim: true)
|
||||
EEx.function_from_string(:def, :new_conflicts, @new_conflicts_template, [:assigns], trim: true)
|
||||
EEx.function_from_string(:def, :complete_review, @complete_review_template, [:assigns], trim: true)
|
||||
end
|
@ -1,4 +0,0 @@
|
||||
*<%= @user.fullname %>* just synced a file: _<%= @document_path %>_
|
||||
|
||||
*Stats:*<%= for %{"action" => action, "count" => count} <- @stats do %>
|
||||
<%= action %>: _<%= count %>_<% end %>
|
@ -16,6 +16,7 @@ defmodule Langue.Utils.LineByLineHelper.Parser do
|
||||
acc
|
||||
|> Map.put(:line, line)
|
||||
|> Map.put(:captures, Regex.named_captures(prop_line_regex, line))
|
||||
|> IO.inspect
|
||||
|> build_entry()
|
||||
|> add_entries()
|
||||
end
|
||||
|
@ -3,6 +3,7 @@ defmodule Movement.Persisters.Base do
|
||||
|
||||
alias Accent.{Operation, Repo}
|
||||
alias Movement.Mappers.OperationsStats, as: StatMapper
|
||||
alias Movement.Persisters.ProjectStateChangeWorker
|
||||
alias Movement.Migrator
|
||||
|
||||
# Inserts operations by batch of 500 to prevent parameters
|
||||
@ -37,9 +38,21 @@ defmodule Movement.Persisters.Base do
|
||||
end
|
||||
|
||||
def execute(context) do
|
||||
project_state_change_context = %{
|
||||
project_id: context.assigns[:project] && context.assigns.project.id,
|
||||
document_id: context.assigns[:document] && context.assigns.document.id,
|
||||
master_revision_id: context.assigns[:master_revision] && context.assigns.master_revision.id,
|
||||
revision_id: context.assigns[:revision] && context.assigns.revision.id,
|
||||
version_id: context.assigns[:version] && context.assigns.version.id,
|
||||
batch_operation_id: context.assigns[:batch_operation] && context.assigns.batch_operation.id,
|
||||
user_id: context.assigns[:user_id],
|
||||
previous_project_state: ProjectStateChangeWorker.get_project_state(context.assigns[:project])
|
||||
}
|
||||
|
||||
context
|
||||
|> persist_operations()
|
||||
|> migrate_up_operations()
|
||||
|> tap(fn _ -> Oban.insert(ProjectStateChangeWorker.new(project_state_change_context)) end)
|
||||
end
|
||||
|
||||
@spec rollback(Movement.Context.t()) :: {Movement.Context.t(), [Operation.t()]}
|
||||
|
88
lib/movement/persisters/project_state_change_worker.ex
Normal file
88
lib/movement/persisters/project_state_change_worker.ex
Normal file
@ -0,0 +1,88 @@
|
||||
defmodule Movement.Persisters.ProjectStateChangeWorker do
|
||||
use Oban.Worker, queue: :hook
|
||||
|
||||
import Ecto.Query
|
||||
|
||||
alias Accent.Hook
|
||||
alias Accent.Repo
|
||||
alias Accent.Scopes.Project, as: ProjectScope
|
||||
|
||||
@impl Oban.Worker
|
||||
def perform(%Oban.Job{args: args}) do
|
||||
args = cast_args(args)
|
||||
project_state = get_project_state(args.project)
|
||||
|
||||
if new_conflicts_to_review?(args.previous_project_state, project_state) do
|
||||
Hook.outbound(%Hook.Context{
|
||||
event: "new_conflicts",
|
||||
project_id: args.project.id,
|
||||
user_id: args.user.id,
|
||||
payload: %{
|
||||
reviewed_count: project_state.project.reviewed_count,
|
||||
translations_count: project_state.project.translations_count,
|
||||
new_conflicts_count: project_state.project.translations_count - args.previous_project_state.project.translations_count
|
||||
}
|
||||
})
|
||||
end
|
||||
|
||||
if all_reviewed?(args.previous_project_state, project_state) do
|
||||
Hook.outbound(%Hook.Context{
|
||||
event: "complete_review",
|
||||
project_id: args.project.id,
|
||||
user_id: args.user.id,
|
||||
payload: %{
|
||||
translations_count: project_state.project.translations_count
|
||||
}
|
||||
})
|
||||
end
|
||||
|
||||
:ok
|
||||
end
|
||||
|
||||
defp new_conflicts_to_review?(previous_state, current_state) do
|
||||
previous_state.project.conflicts_count < current_state.project.conflicts_count
|
||||
end
|
||||
|
||||
defp all_reviewed?(previous_state, current_state) do
|
||||
previous_state.project.reviewed_count !== previous_state.project.translations_count and
|
||||
current_state.project.reviewed_count === current_state.project.translations_count
|
||||
end
|
||||
|
||||
def get_project_state(nil), do: nil
|
||||
|
||||
def get_project_state(project) do
|
||||
project =
|
||||
Accent.Project
|
||||
|> from(where: [id: ^project.id])
|
||||
|> ProjectScope.with_stats()
|
||||
|> Repo.one()
|
||||
|
||||
%{project: Map.take(project, ~w(translations_count reviewed_count conflicts_count)a)}
|
||||
end
|
||||
|
||||
defp cast_project_state(args) do
|
||||
%{
|
||||
project: %{
|
||||
translations_count: args["project"]["translations_count"],
|
||||
reviewed_count: args["project"]["reviewed_count"],
|
||||
conflicts_count: args["project"]["conflicts_count"]
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
defp cast_args(args) do
|
||||
%{
|
||||
previous_project_state: cast_project_state(args["previous_project_state"]),
|
||||
project: get_record(Accent.Project, args["project_id"]),
|
||||
document: get_record(Accent.Document, args["document_id"]),
|
||||
master_revision: get_record(Accent.Revision, args["master_revision_id"]),
|
||||
revision: get_record(Accent.Revision, args["revision_id"]),
|
||||
version: get_record(Accent.Version, args["version_id"]),
|
||||
batch_operation: get_record(Accent.Operation, args["batch_operation_id"]),
|
||||
user: get_record(Accent.User, args["user_id"])
|
||||
}
|
||||
end
|
||||
|
||||
defp get_record(_schema, nil), do: nil
|
||||
defp get_record(schema, id), do: Repo.get(schema, id)
|
||||
end
|
@ -57,7 +57,7 @@ defmodule Accent.MergeController do
|
||||
|
||||
{:ok, _} ->
|
||||
Accent.Hook.outbound(%HookContext{
|
||||
event: "merge",
|
||||
event: "add_translations",
|
||||
project_id: conn.assigns[:project].id,
|
||||
user_id: conn.assigns[:current_user].id,
|
||||
payload: %{
|
||||
|
@ -3,12 +3,12 @@ defmodule Accent.CreateCommentEmail do
|
||||
|
||||
import Accent.EmailViewConfigHelper, only: [mailer_from: 0, x_smtpapi_header: 0]
|
||||
|
||||
@spec create(list(String.t()), Accent.Project.t(), map()) :: Bamboo.Email.t()
|
||||
def create(emails, project, comment) do
|
||||
@spec create(list(String.t()), Accent.User.t(), Accent.Project.t(), map()) :: Bamboo.Email.t()
|
||||
def create(emails, user, project, comment) do
|
||||
base_email()
|
||||
|> to(emails)
|
||||
|> mailer_subject(project)
|
||||
|> assign(:email, comment["user"]["email"])
|
||||
|> assign(:user, user)
|
||||
|> assign(:key, comment["translation"]["key"])
|
||||
|> assign(:text, comment["text"])
|
||||
|> assign(:translation_path, translation_path(project, comment["translation"]["id"]))
|
||||
|
@ -1,4 +1,4 @@
|
||||
<p style="<%= paragraph_style("font-size": "14px", color: "#444") %>"><%= @email %> has commented on <code><%= @key %><code>:</p>
|
||||
<p style="<%= paragraph_style("font-size": "14px", color: "#444") %>"><%= user_display_name(@user) %> has commented on <code><%= @key %><code>:</p>
|
||||
<p style="<%= paragraph_style("font-size": "18px", color: "#222", "border-left": "3px solid #ddd", "padding-left": "10px") %>"><%= @text %></p>
|
||||
|
||||
<p style="<%= paragraph_style("font-size": "15px") %>">You can reply <a style="<%= link_style() %>" href="<%= webapp_url() %><%= @translation_path %>">here</a></p>
|
||||
|
@ -1,4 +1,4 @@
|
||||
<%= @email %> has commented on <%= @key %>:
|
||||
<%= user_display_name(@user) %> has commented on <%= @key %>:
|
||||
|
||||
---
|
||||
<%= @text %>
|
||||
|
@ -1,4 +1,4 @@
|
||||
<p style="<%= paragraph_style("font-size": "17px", color: "#444") %>"><%= @user.email %> has invited you to collaborate on the project "<%= @project.name %>"</p>
|
||||
<p style="<%= paragraph_style("font-size": "17px", color: "#444") %>"><%= user_display_name(@user) %> has invited you to collaborate on the project "<%= @project.name %>"</p>
|
||||
|
||||
<p style="<%= paragraph_style("font-size": "15px") %>">If you already have an account (<%= @email %>), you can just <a style="<%= link_style() %>" href="<%= webapp_url() %>">login</a> and you’ll see the project.</p>
|
||||
|
||||
|
@ -1,4 +1,4 @@
|
||||
<%= @user.email %> has invited you to collaborate on the project "<%= @project.name %>".
|
||||
<%= user_display_name(@user) %> has invited you to collaborate on the project "<%= @project.name %>".
|
||||
|
||||
If you already have an account (<%= @email %>), you can just login (<%= webapp_url() %>) and you’ll see the project.
|
||||
|
||||
|
@ -3,4 +3,8 @@ defmodule Accent.EmailView do
|
||||
|
||||
import Accent.EmailViewStyleHelper
|
||||
import Accent.EmailViewConfigHelper
|
||||
|
||||
def user_display_name(user) do
|
||||
Accent.User.name_with_fallback(user)
|
||||
end
|
||||
end
|
||||
|
@ -11,11 +11,10 @@ defmodule AccentTest.CreateCommentEmail do
|
||||
|
||||
payload = %{
|
||||
"text" => comment.text,
|
||||
"user" => %{"email" => user.email},
|
||||
"translation" => %{"id" => translation.id, "key" => translation.key}
|
||||
}
|
||||
|
||||
email = Accent.CreateCommentEmail.create(emails, project, payload)
|
||||
email = Accent.CreateCommentEmail.create(emails, user, project, payload)
|
||||
|
||||
assert email.to == emails
|
||||
assert email.from == {"Accent", "accent-test@example.com"}
|
||||
|
@ -40,8 +40,8 @@ defmodule AccentTest.Hook.Outbounds.Discord do
|
||||
**Test** just synced a file: *foo.json*
|
||||
|
||||
**Stats:**
|
||||
new: *4*
|
||||
conflict_on_proposed: *10*
|
||||
New: *4*
|
||||
Conflict on proposed: *10*
|
||||
"""
|
||||
|> String.trim_trailing()
|
||||
})
|
||||
|
@ -34,7 +34,6 @@ defmodule AccentTest.Hook.Outbounds.Email do
|
||||
|
||||
payload = %{
|
||||
"text" => comment.text,
|
||||
"user" => %{"email" => user.email},
|
||||
"translation" => %{"id" => translation.id, "key" => translation.key}
|
||||
}
|
||||
|
||||
@ -42,7 +41,7 @@ defmodule AccentTest.Hook.Outbounds.Email do
|
||||
|
||||
_ = Email.perform(%Oban.Job{args: context})
|
||||
|
||||
refute_delivered_email(CreateCommentEmail.create(["comment@test.com"], project, payload))
|
||||
refute_delivered_email(CreateCommentEmail.create(["comment@test.com"], user, project, payload))
|
||||
end
|
||||
|
||||
test "comment", %{project: project, translation: translation, user: user} do
|
||||
@ -52,7 +51,6 @@ defmodule AccentTest.Hook.Outbounds.Email do
|
||||
|
||||
payload = %{
|
||||
"text" => comment.text,
|
||||
"user" => %{"email" => commenter.email},
|
||||
"translation" => %{"id" => translation.id, "key" => translation.key}
|
||||
}
|
||||
|
||||
@ -60,7 +58,7 @@ defmodule AccentTest.Hook.Outbounds.Email do
|
||||
|
||||
_ = Email.perform(%Oban.Job{args: context})
|
||||
|
||||
assert_delivered_email(CreateCommentEmail.create(["foo@test.com"], project, payload))
|
||||
assert_delivered_email(CreateCommentEmail.create(["foo@test.com"], commenter, project, payload))
|
||||
end
|
||||
|
||||
test "collaborator", %{project: project, user: user} do
|
||||
|
@ -46,8 +46,8 @@ defmodule AccentTest.Hook.Outbounds.Slack do
|
||||
*Test* just synced a file: _foo.json_
|
||||
|
||||
*Stats:*
|
||||
new: _4_
|
||||
conflict_on_proposed: _10_
|
||||
New: _4_
|
||||
Conflict on proposed: _10_
|
||||
"""
|
||||
|> String.trim_trailing()
|
||||
})
|
||||
|
120
test/movement/persisters/project_state_change_worker_test.exs
Normal file
120
test/movement/persisters/project_state_change_worker_test.exs
Normal file
@ -0,0 +1,120 @@
|
||||
defmodule Movement.Persisters.ProjectStateChangeWorkerTest do
|
||||
use Accent.RepoCase
|
||||
use Oban.Testing, repo: Accent.Repo
|
||||
|
||||
alias Movement.Persisters.ProjectStateChangeWorker, as: Worker
|
||||
|
||||
alias Accent.{
|
||||
Document,
|
||||
Language,
|
||||
ProjectCreator,
|
||||
Repo,
|
||||
Translation,
|
||||
User
|
||||
}
|
||||
|
||||
setup do
|
||||
user = Repo.insert!(%User{email: "test@test.com"})
|
||||
language = Repo.insert!(%Language{name: "English", slug: Ecto.UUID.generate()})
|
||||
{:ok, project} = ProjectCreator.create(params: %{main_color: "#f00", name: "My project", language_id: language.id}, user: user)
|
||||
revision = project |> Repo.preload(:revisions) |> Map.get(:revisions) |> hd()
|
||||
document = Repo.insert!(%Document{project_id: project.id, path: "test", format: "json"})
|
||||
|
||||
{:ok, [revision: revision, document: document, project: project, user: user]}
|
||||
end
|
||||
|
||||
test "noop", %{project: project} do
|
||||
args = %{
|
||||
"project_id" => project.id,
|
||||
"previous_project_state" => %{
|
||||
"project" => %{
|
||||
"translations_count" => 0,
|
||||
"reviewed_count" => 0,
|
||||
"conflicts_count" => 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Worker.perform(%Oban.Job{args: args})
|
||||
|
||||
refute_enqueued(worker: Accent.Hook.Outbounds.Mock)
|
||||
end
|
||||
|
||||
test "new_conflicts", %{user: user, project: project, revision: revision, document: document} do
|
||||
%Translation{
|
||||
key: "a",
|
||||
proposed_text: "A",
|
||||
conflicted: true,
|
||||
corrected_text: "Test",
|
||||
revision_id: revision.id,
|
||||
document_id: document.id
|
||||
}
|
||||
|> Repo.insert!()
|
||||
|
||||
args = %{
|
||||
"project_id" => project.id,
|
||||
"user_id" => user.id,
|
||||
"previous_project_state" => %{
|
||||
"project" => %{
|
||||
"translations_count" => 0,
|
||||
"reviewed_count" => 0,
|
||||
"conflicts_count" => 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Worker.perform(%Oban.Job{args: args})
|
||||
|
||||
assert_enqueued(
|
||||
worker: Accent.Hook.Outbounds.Mock,
|
||||
args: %{
|
||||
"event" => "new_conflicts",
|
||||
"payload" => %{
|
||||
"reviewed_count" => 0,
|
||||
"translations_count" => 1,
|
||||
"new_conflicts_count" => 1
|
||||
},
|
||||
"project_id" => project.id,
|
||||
"user_id" => user.id
|
||||
}
|
||||
)
|
||||
end
|
||||
|
||||
test "complete_review", %{user: user, project: project, revision: revision, document: document} do
|
||||
%Translation{
|
||||
key: "a",
|
||||
proposed_text: "A",
|
||||
conflicted: false,
|
||||
corrected_text: "Test",
|
||||
revision_id: revision.id,
|
||||
document_id: document.id
|
||||
}
|
||||
|> Repo.insert!()
|
||||
|
||||
args = %{
|
||||
"project_id" => project.id,
|
||||
"user_id" => user.id,
|
||||
"previous_project_state" => %{
|
||||
"project" => %{
|
||||
"translations_count" => 1,
|
||||
"reviewed_count" => 0,
|
||||
"conflicts_count" => 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Worker.perform(%Oban.Job{args: args})
|
||||
|
||||
assert_enqueued(
|
||||
worker: Accent.Hook.Outbounds.Mock,
|
||||
args: %{
|
||||
"event" => "complete_review",
|
||||
"payload" => %{
|
||||
"translations_count" => 1
|
||||
},
|
||||
"project_id" => project.id,
|
||||
"user_id" => user.id
|
||||
}
|
||||
)
|
||||
end
|
||||
end
|
@ -51,7 +51,7 @@ defmodule AccentTest.MergeController do
|
||||
assert_enqueued(
|
||||
worker: Accent.Hook.Outbounds.Mock,
|
||||
args: %{
|
||||
"event" => "merge",
|
||||
"event" => "add_translations",
|
||||
"payload" => %{"language_name" => "french", "merge_type" => nil},
|
||||
"project_id" => project.id,
|
||||
"user_id" => user.id
|
||||
@ -102,7 +102,7 @@ defmodule AccentTest.MergeController do
|
||||
assert_enqueued(
|
||||
worker: Accent.Hook.Outbounds.Mock,
|
||||
args: %{
|
||||
"event" => "merge",
|
||||
"event" => "add_translations",
|
||||
"payload" => %{"language_name" => "french", "merge_type" => nil},
|
||||
"project_id" => project.id,
|
||||
"user_id" => user.id
|
||||
@ -134,7 +134,7 @@ defmodule AccentTest.MergeController do
|
||||
assert_enqueued(
|
||||
worker: Accent.Hook.Outbounds.Mock,
|
||||
args: %{
|
||||
"event" => "merge",
|
||||
"event" => "add_translations",
|
||||
"payload" => %{"language_name" => "french", "merge_type" => nil},
|
||||
"project_id" => project.id,
|
||||
"user_id" => user.id
|
||||
|
7
webapp/app/helpers/array-includes.ts
Normal file
7
webapp/app/helpers/array-includes.ts
Normal file
@ -0,0 +1,7 @@
|
||||
import {helper} from '@ember/component/helper';
|
||||
|
||||
const arrayIncludes = ([container, item]: [any[], any]) => {
|
||||
return container.includes(item);
|
||||
};
|
||||
|
||||
export default helper(arrayIncludes);
|
@ -455,7 +455,9 @@
|
||||
"events": {
|
||||
"title": "Which events would you like to trigger this webhook?",
|
||||
"options": {
|
||||
"sync": "Sync"
|
||||
"sync": "Sync with any changes",
|
||||
"new_conflicts": "New strings to review",
|
||||
"complete_review": "Project is 100% reviewed"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -39,8 +39,7 @@
|
||||
left: 1px;
|
||||
transition: 0.2s ease-in-out;
|
||||
transition-property: color, opacity, background;
|
||||
padding: 4px 12px 3px;
|
||||
margin-bottom: 6px;
|
||||
padding: 7px 12px 6px;
|
||||
text-decoration: none;
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
@ -56,6 +55,7 @@
|
||||
|
||||
.list-item-link-icon {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.list-item-link-text {
|
||||
@ -70,6 +70,7 @@
|
||||
|
||||
.list-item-link-icon {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.list-item-link-text {
|
||||
@ -89,10 +90,11 @@
|
||||
|
||||
.list-item-link-icon {
|
||||
transition: 0.2s ease-in-out;
|
||||
transition-property: fill;
|
||||
transition-property: fill, opacity;
|
||||
display: inline-block;
|
||||
width: 13px;
|
||||
height: 13px;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
@media (max-width: 800px) {
|
||||
|
@ -164,18 +164,18 @@
|
||||
</LinkTo>
|
||||
</li>
|
||||
{{/if}}
|
||||
</div>
|
||||
|
||||
<li local-class="list-item">
|
||||
<LinkTo
|
||||
@route="logged-in.project.edit"
|
||||
@model={{@project.id}}
|
||||
local-class="list-item-link"
|
||||
>
|
||||
{{inline-svg "/assets/gear.svg" local-class="list-item-link-icon"}}
|
||||
<span local-class="list-item-link-text">
|
||||
{{t "components.project_navigation.settings_link_title"}}
|
||||
</span>
|
||||
</LinkTo>
|
||||
</li>
|
||||
<li local-class="list-item">
|
||||
<LinkTo
|
||||
@route="logged-in.project.edit"
|
||||
@model={{@project.id}}
|
||||
local-class="list-item-link"
|
||||
>
|
||||
{{inline-svg "/assets/gear.svg" local-class="list-item-link-icon"}}
|
||||
<span local-class="list-item-link-text">
|
||||
{{t "components.project_navigation.settings_link_title"}}
|
||||
</span>
|
||||
</LinkTo>
|
||||
</li>
|
||||
</div>
|
||||
</ul>
|
||||
|
@ -54,7 +54,7 @@ export default class IntegrationsForm extends Component<Args> {
|
||||
url: string;
|
||||
|
||||
@tracked
|
||||
syncChecked: boolean;
|
||||
events: string[];
|
||||
|
||||
@tracked
|
||||
repository: string;
|
||||
@ -93,10 +93,6 @@ export default class IntegrationsForm extends Component<Args> {
|
||||
return `project-settings/integrations/form/${this.service.toLowerCase()}`;
|
||||
}
|
||||
|
||||
get events() {
|
||||
return this.syncChecked ? ['SYNC'] : [];
|
||||
}
|
||||
|
||||
@action
|
||||
didUpdateIntegration() {
|
||||
if (this.args.integration) {
|
||||
@ -117,8 +113,7 @@ export default class IntegrationsForm extends Component<Args> {
|
||||
|
||||
this.service = this.integration.service || this.services[0];
|
||||
this.url = this.integration.data.url;
|
||||
this.syncChecked =
|
||||
this.integration.events && this.integration.events.includes('SYNC');
|
||||
this.events = this.integration.events;
|
||||
this.repository = this.integration.data.repository;
|
||||
this.token = this.integration.data.token;
|
||||
this.defaultRef = this.integration.data.defaultRef;
|
||||
@ -140,8 +135,8 @@ export default class IntegrationsForm extends Component<Args> {
|
||||
}
|
||||
|
||||
@action
|
||||
setSyncChecked(syncChecked: boolean) {
|
||||
this.syncChecked = syncChecked;
|
||||
setEventsChecked(events: string[]) {
|
||||
this.events = events;
|
||||
}
|
||||
|
||||
@action
|
||||
|
@ -1,18 +1,36 @@
|
||||
import {inject as service} from '@ember/service';
|
||||
import Component from '@glimmer/component';
|
||||
import {action} from '@ember/object';
|
||||
import {tracked} from '@glimmer/tracking';
|
||||
import IntlService from 'ember-intl/services/intl';
|
||||
|
||||
interface Args {
|
||||
title: string;
|
||||
syncChecked: boolean;
|
||||
syncCheckLabel: string;
|
||||
onChangeSyncChecked: (checked: boolean) => void;
|
||||
events: string[];
|
||||
onChangeEventsChecked: (events: string[]) => void;
|
||||
}
|
||||
|
||||
export default class DataControlCheckboxes extends Component<Args> {
|
||||
@action
|
||||
changeSyncChecked(event: Event) {
|
||||
const target = event.target as HTMLInputElement;
|
||||
@service('intl')
|
||||
intl: IntlService;
|
||||
|
||||
this.args.onChangeSyncChecked(target.checked);
|
||||
allEvents = [
|
||||
{value: 'SYNC', label: 'components.project_settings.integrations.events.options.sync'},
|
||||
{value: 'NEW_CONFLICTS', label: 'components.project_settings.integrations.events.options.new_conflicts'},
|
||||
{value: 'COMPLETE_REVIEW', label: 'components.project_settings.integrations.events.options.complete_review'},
|
||||
];
|
||||
|
||||
@tracked
|
||||
selectedEvents: Set<string> = new Set(this.args.events);
|
||||
|
||||
@action
|
||||
changeEventChecked(event: string) {
|
||||
if (this.selectedEvents.has(event)) {
|
||||
this.selectedEvents.delete(event);
|
||||
} else {
|
||||
this.selectedEvents.add(event);
|
||||
}
|
||||
|
||||
this.args.onChangeEventsChecked(Array.from(this.selectedEvents));
|
||||
}
|
||||
}
|
||||
|
@ -6,12 +6,21 @@
|
||||
align-items: center;
|
||||
margin-right: 10px;
|
||||
border: 1px solid var(--background-light-highlight);
|
||||
padding: 3px 6px;
|
||||
padding: 4px 6px;
|
||||
border-radius: 3px;
|
||||
background: var(--background-light);
|
||||
background: var(--input-background);
|
||||
cursor: pointer;
|
||||
transition: 0.2s ease-in-out;
|
||||
transition-property: background;
|
||||
|
||||
&:hover,
|
||||
&:focus {
|
||||
background: var(--background-light);
|
||||
}
|
||||
|
||||
input {
|
||||
margin-right: 5px;
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -3,12 +3,14 @@
|
||||
{{@title}}
|
||||
</h3>
|
||||
|
||||
<label local-class="checkbox">
|
||||
<Input
|
||||
@type="checkbox"
|
||||
@checked={{@syncChecked}}
|
||||
{{on "change" this.changeSyncChecked}}
|
||||
/>
|
||||
{{@syncCheckLabel}}
|
||||
</label>
|
||||
{{#each this.allEvents as |event|}}
|
||||
<label local-class="checkbox">
|
||||
<Input
|
||||
@type="checkbox"
|
||||
@checked={{if (array-includes @events event.value) 'checked'}}
|
||||
{{on "change" (fn this.changeEventChecked event.value)}}
|
||||
/>
|
||||
{{t event.label}}
|
||||
</label>
|
||||
{{/each}}
|
||||
</div>
|
||||
|
@ -8,9 +8,9 @@ interface Args {
|
||||
errors: any;
|
||||
url: any;
|
||||
project: any;
|
||||
syncChecked: any;
|
||||
events: any;
|
||||
onChangeUrl: (url: string) => void;
|
||||
onChangeSyncChecked: (syncChecked: boolean) => void;
|
||||
onChangeEventsChecked: (events: string[]) => void;
|
||||
onChangeRepository: (repository: string) => void;
|
||||
onChangeToken: (token: string) => void;
|
||||
onChangeDefaultRef: (defaultRef: string) => void;
|
||||
|
@ -10,7 +10,6 @@
|
||||
|
||||
<ProjectSettings::Integrations::Form::DataControlCheckboxes
|
||||
@title={{t "components.project_settings.integrations.events.title"}}
|
||||
@syncCheckLabel={{t "components.project_settings.integrations.events.options.sync"}}
|
||||
@syncChecked={{@syncChecked}}
|
||||
@onChangeSyncChecked={{@onChangeSyncChecked}}
|
||||
@events={{@events}}
|
||||
@onChangeEventsChecked={{@onChangeEventsChecked}}
|
||||
/>
|
||||
|
@ -10,9 +10,7 @@ interface Args {
|
||||
errors: any;
|
||||
url: any;
|
||||
project: any;
|
||||
syncChecked: any;
|
||||
onChangeUrl: (url: string) => void;
|
||||
onChangeSyncChecked: (syncChecked: boolean) => void;
|
||||
onChangeRepository: (repository: string) => void;
|
||||
onChangeToken: (token: string) => void;
|
||||
onChangeDefaultRef: (defaultRef: string) => void;
|
||||
|
@ -8,9 +8,9 @@ interface Args {
|
||||
errors: any;
|
||||
url: any;
|
||||
project: any;
|
||||
syncChecked: any;
|
||||
events: any;
|
||||
onChangeUrl: (url: string) => void;
|
||||
onChangeSyncChecked: (syncChecked: boolean) => void;
|
||||
onChangeEventsChecked: (events: string[]) => void;
|
||||
onChangeRepository: (repository: string) => void;
|
||||
onChangeToken: (token: string) => void;
|
||||
onChangeDefaultRef: (defaultRef: string) => void;
|
||||
|
@ -10,7 +10,6 @@
|
||||
|
||||
<ProjectSettings::Integrations::Form::DataControlCheckboxes
|
||||
@title={{t "components.project_settings.integrations.events.title"}}
|
||||
@syncCheckLabel={{t "components.project_settings.integrations.events.options.sync"}}
|
||||
@syncChecked={{@syncChecked}}
|
||||
@onChangeSyncChecked={{@onChangeSyncChecked}}
|
||||
@events={{@events}}
|
||||
@onChangeEventsChecked={{@onChangeEventsChecked}}
|
||||
/>
|
||||
|
@ -1,6 +1,7 @@
|
||||
.project-settings-integrations-form {
|
||||
padding: 10px 15px;
|
||||
border: 1px solid var(--background-light-highlight);
|
||||
background: var(--background-light);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
|
@ -33,9 +33,9 @@
|
||||
errors=this.errors
|
||||
url=this.url
|
||||
project=@project
|
||||
syncChecked=this.syncChecked
|
||||
events=this.events
|
||||
onChangeUrl=(fn this.setUrl)
|
||||
onChangeSyncChecked=(fn this.setSyncChecked)
|
||||
onChangeEventsChecked=(fn this.setEventsChecked)
|
||||
onChangeRepository=(fn this.setRepository)
|
||||
onChangeToken=(fn this.setToken)
|
||||
onChangeDefaultRef=(fn this.setDefaultRef)
|
||||
|
@ -167,7 +167,7 @@ html {
|
||||
'Segoe UI Symbol';
|
||||
--font-monospace: 'Fira Code', 'Monaco', Courrier, monospace;
|
||||
|
||||
--screen-lg: 1300px;
|
||||
--screen-lg: 1400px;
|
||||
--screen-md: 640px;
|
||||
--screen-sm: 440px;
|
||||
|
||||
@ -307,6 +307,7 @@ input[type='radio'] {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
background: var(--body-background);
|
||||
accent-color: var(--body-background);
|
||||
border: 2px solid var(--color-grey);
|
||||
|
||||
&:checked {
|
||||
|
@ -5,6 +5,6 @@
|
||||
>
|
||||
<path
|
||||
d="M128.001 0C57.317 0 0 57.307 0 128.001c0 56.554 36.676 104.535 87.535 121.46 6.397 1.185 8.746-2.777 8.746-6.158 0-3.052-.12-13.135-.174-23.83-35.61 7.742-43.124-15.103-43.124-15.103-5.823-14.795-14.213-18.73-14.213-18.73-11.613-7.944.876-7.78.876-7.78 12.853.902 19.621 13.19 19.621 13.19 11.417 19.568 29.945 13.911 37.249 10.64 1.149-8.272 4.466-13.92 8.127-17.116-28.431-3.236-58.318-14.212-58.318-63.258 0-13.975 5-25.394 13.188-34.358-1.329-3.224-5.71-16.242 1.24-33.874 0 0 10.749-3.44 35.21 13.121 10.21-2.836 21.16-4.258 32.038-4.307 10.878.049 21.837 1.47 32.066 4.307 24.431-16.56 35.165-13.12 35.165-13.12 6.967 17.63 2.584 30.65 1.255 33.873 8.207 8.964 13.173 20.383 13.173 34.358 0 49.163-29.944 59.988-58.447 63.157 4.591 3.972 8.682 11.762 8.682 23.704 0 17.126-.148 30.91-.148 35.126 0 3.407 2.304 7.398 8.792 6.14C219.37 232.5 256 184.537 256 128.002 256 57.307 198.691 0 128.001 0zm-80.06 182.34c-.282.636-1.283.827-2.194.39-.929-.417-1.45-1.284-1.15-1.922.276-.655 1.279-.838 2.205-.399.93.418 1.46 1.293 1.139 1.931zm6.296 5.618c-.61.566-1.804.303-2.614-.591-.837-.892-.994-2.086-.375-2.66.63-.566 1.787-.301 2.626.591.838.903 1 2.088.363 2.66zm4.32 7.188c-.785.545-2.067.034-2.86-1.104-.784-1.138-.784-2.503.017-3.05.795-.547 2.058-.055 2.861 1.075.782 1.157.782 2.522-.019 3.08zm7.304 8.325c-.701.774-2.196.566-3.29-.49-1.119-1.032-1.43-2.496-.726-3.27.71-.776 2.213-.558 3.315.49 1.11 1.03 1.45 2.505.701 3.27zm9.442 2.81c-.31 1.003-1.75 1.459-3.199 1.033-1.448-.439-2.395-1.613-2.103-2.626.301-1.01 1.747-1.484 3.207-1.028 1.446.436 2.396 1.602 2.095 2.622zm10.744 1.193c.036 1.055-1.193 1.93-2.715 1.95-1.53.034-2.769-.82-2.786-1.86 0-1.065 1.202-1.932 2.733-1.958 1.522-.03 2.768.818 2.768 1.868zm10.555-.405c.182 1.03-.875 2.088-2.387 2.37-1.485.271-2.861-.365-3.05-1.386-.184-1.056.893-2.114 2.376-2.387 1.514-.263 2.868.356 3.061 1.403z"
|
||||
fill="#161614"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 2.0 KiB After Width: | Height: | Size: 2.0 KiB |
Loading…
Reference in New Issue
Block a user