diff --git a/assets/css/my_plugin.css b/assets/css/aether.css
similarity index 80%
rename from assets/css/my_plugin.css
rename to assets/css/aether.css
index aa1e98a..777966b 100644
--- a/assets/css/my_plugin.css
+++ b/assets/css/aether.css
@@ -1,13 +1,13 @@
/*
* Plugin CSS entry point.
*
- * Served at /plugins-assets/my_plugin/my_plugin.css
+ * Served at /plugins-assets/aether/aether.css
* and included in the page layout if declared in manifest.json assets.global_css.
*
* Prefix all selectors with your plugin name to avoid collisions
* with host or other plugin styles.
*/
-.my-plugin {
+.aether {
/* Plugin-scoped styles go here */
}
diff --git a/assets/js/my_plugin.js b/assets/js/aether.js
similarity index 55%
rename from assets/js/my_plugin.js
rename to assets/js/aether.js
index c8116f1..3c50d99 100644
--- a/assets/js/my_plugin.js
+++ b/assets/js/aether.js
@@ -1,15 +1,15 @@
// Plugin JavaScript entry point.
//
-// This file is served by the host at /plugins-assets/my_plugin/my_plugin.js
+// This file is served by the host at /plugins-assets/aether/aether.js
// and included in the page layout if declared in manifest.json assets.global_js.
//
// To register LiveView hooks:
//
// window.TribesPluginHooks = window.TribesPluginHooks || {};
-// window.TribesPluginHooks["MyPluginHook"] = {
+// window.TribesPluginHooks["AetherHook"] = {
// mounted() {
-// console.log("MyPlugin hook mounted");
+// console.log("Aether hook mounted");
// }
// };
-console.log("my_plugin loaded");
+console.log("aether loaded");
diff --git a/lib/my_plugin/application.ex b/lib/aether/application.ex
similarity index 75%
rename from lib/my_plugin/application.ex
rename to lib/aether/application.ex
index 5553a9b..408b576 100644
--- a/lib/my_plugin/application.ex
+++ b/lib/aether/application.ex
@@ -1,4 +1,4 @@
-defmodule MyPlugin.Application do
+defmodule Aether.Application do
@moduledoc """
OTP Application for this plugin.
@@ -12,10 +12,10 @@ defmodule MyPlugin.Application do
def start(_type, _args) do
children = [
# Add your supervised processes here, e.g.:
- # {MyPlugin.Worker, []}
+ # {Aether.Worker, []}
]
- opts = [strategy: :one_for_one, name: MyPlugin.Supervisor]
+ opts = [strategy: :one_for_one, name: Aether.Supervisor]
Supervisor.start_link(children, opts)
end
end
diff --git a/lib/my_plugin/plugin.ex b/lib/aether/plugin.ex
similarity index 54%
rename from lib/my_plugin/plugin.ex
rename to lib/aether/plugin.ex
index f0310ff..0d3ee57 100644
--- a/lib/my_plugin/plugin.ex
+++ b/lib/aether/plugin.ex
@@ -1,4 +1,4 @@
-defmodule MyPlugin.Plugin do
+defmodule Aether.Plugin do
@moduledoc """
Tribes plugin entry point.
@@ -10,7 +10,7 @@ defmodule MyPlugin.Plugin do
# Once Tribes.Plugin.Base is available, replace the manual implementation
# with:
#
- # use Tribes.Plugin.Base, otp_app: :my_plugin
+ # use Tribes.Plugin.Base, otp_app: :aether
#
# and override only register/1.
@@ -27,8 +27,8 @@ defmodule MyPlugin.Plugin do
enhances_with: manifest["enhances_with"] || [],
nav_items: [
%{
- label: "My Plugin",
- path: "/plugins/my_plugin",
+ label: "Aether",
+ path: "/aether",
icon: nil,
requires: [],
order: 50
@@ -36,8 +36,8 @@ defmodule MyPlugin.Plugin do
],
pages: [
%{
- path: "/plugins/my_plugin",
- live_view: MyPluginWeb.HomeLive,
+ path: "/aether",
+ live_view: AetherWeb.TimelineLive,
layout: nil
}
],
@@ -58,24 +58,44 @@ defmodule MyPlugin.Plugin do
end
defp manifest_path do
- # In a release, manifest.json sits alongside ebin/ in the plugin directory.
- # In dev mode (path dep), it's at the project root.
- case :code.priv_dir(:my_plugin) do
- {:error, :bad_name} ->
- # Dev mode fallback: relative to project root
- Path.join(__DIR__, "../../../manifest.json") |> Path.expand()
+ project_manifest = Path.join(__DIR__, "../../manifest.json") |> Path.expand()
- priv_dir ->
- priv_dir |> to_string() |> Path.join("../manifest.json") |> Path.expand()
- end
+ candidates =
+ case :code.priv_dir(:aether) do
+ {:error, :bad_name} ->
+ [project_manifest]
+
+ priv_dir ->
+ priv_dir = to_string(priv_dir)
+
+ [
+ Path.join(priv_dir, "../manifest.json") |> Path.expand(),
+ project_manifest
+ ]
+ end
+
+ first_existing_path(candidates) || project_manifest
end
defp migrations_path(manifest) do
if manifest["migrations"] do
- case :code.priv_dir(:my_plugin) do
- {:error, :bad_name} -> nil
- priv_dir -> priv_dir |> to_string() |> Path.join("repo/migrations")
- end
+ candidates =
+ case :code.priv_dir(:aether) do
+ {:error, :bad_name} ->
+ [Path.join(__DIR__, "../../priv/repo/migrations") |> Path.expand()]
+
+ priv_dir ->
+ [
+ Path.join(to_string(priv_dir), "repo/migrations") |> Path.expand(),
+ Path.join(__DIR__, "../../priv/repo/migrations") |> Path.expand()
+ ]
+ end
+
+ first_existing_path(candidates)
end
end
+
+ defp first_existing_path(paths) do
+ Enum.find(paths, &File.exists?/1)
+ end
end
diff --git a/lib/my_plugin_web/live/home_live.ex b/lib/aether_web/live/home_live.ex
similarity index 85%
rename from lib/my_plugin_web/live/home_live.ex
rename to lib/aether_web/live/home_live.ex
index 8130455..c2e503f 100644
--- a/lib/my_plugin_web/live/home_live.ex
+++ b/lib/aether_web/live/home_live.ex
@@ -1,9 +1,9 @@
-defmodule MyPluginWeb.HomeLive do
+defmodule AetherWeb.HomeLive do
@moduledoc """
Example LiveView page for the plugin.
This page is registered in the plugin spec and mounted by the host
- at /plugins/my_plugin.
+ at /plugins/aether.
"""
# In dev mode (plugin loaded as path dep), you can use host macros:
@@ -22,7 +22,7 @@ defmodule MyPluginWeb.HomeLive do
My Plugin
This is a Tribes plugin. Edit this page in
- lib/my_plugin_web/live/home_live.ex.
+ lib/aether_web/live/home_live.ex.
"""
diff --git a/lib/aether_web/live/timeline_live.ex b/lib/aether_web/live/timeline_live.ex
new file mode 100644
index 0000000..6532023
--- /dev/null
+++ b/lib/aether_web/live/timeline_live.ex
@@ -0,0 +1,366 @@
+defmodule AetherWeb.TimelineLive do
+ use TribesWeb, :live_view
+
+ on_mount({TribesWeb.LiveUserAuth, :live_user_optional})
+
+ alias Tribes.Communities
+ alias Tribes.Communities.Tribe
+ alias Tribes.Nostr
+
+ @impl true
+ def mount(params, session, socket) do
+ tribe_id = resolve_tribe_id(params, session)
+ scope = load_scope(socket.assigns.current_user, tribe_id)
+
+ {:ok, notes} = Nostr.list_notes(limit: 100)
+ notes = filter_notes(notes, scope)
+ profile_cache = load_profile_cache(notes)
+ note_ids = notes |> Enum.map(& &1["id"]) |> MapSet.new()
+
+ socket =
+ socket
+ |> assign(:page_title, gettext("Aether"))
+ |> assign(:current_scope, socket.assigns[:current_scope])
+ |> assign(:tribe_id, tribe_id)
+ |> assign(:tribe, scope.tribe)
+ |> assign(:can_view_tribe?, scope.can_view?)
+ |> assign(:is_tribe_member?, scope.member?)
+ |> assign(:tribe_member_pubkeys, scope.member_pubkeys)
+ |> assign(:profile_cache, profile_cache)
+ |> assign(:note_ids, note_ids)
+ |> assign(:note_count, MapSet.size(note_ids))
+ |> assign(:form, to_form(%{"content" => ""}, as: :note))
+ |> assign(:join_form, to_form(%{"invite_code" => ""}, as: :join))
+ |> assign(:subscription_ref, nil)
+ |> stream_configure(:notes, dom_id: &("note-" <> &1["id"]))
+ |> stream(:notes, notes)
+
+ if connected?(socket) do
+ {:ok, ref} = Nostr.subscribe_notes(self(), "aether", limit: 100)
+ {:ok, assign(socket, :subscription_ref, ref)}
+ else
+ {:ok, socket}
+ end
+ end
+
+ @impl true
+ def handle_event("join_tribe", %{"join" => %{"invite_code" => invite_code}}, socket) do
+ cond do
+ is_nil(socket.assigns.current_user) ->
+ {:noreply, put_flash(socket, :error, gettext("Sign in to join tribes"))}
+
+ is_nil(socket.assigns.tribe) ->
+ {:noreply, put_flash(socket, :error, gettext("No tribe selected"))}
+
+ true ->
+ opts =
+ case String.trim(invite_code || "") do
+ "" -> []
+ code -> [invite_code: code]
+ end
+
+ case Communities.join_tribe(socket.assigns.current_user, socket.assigns.tribe.id, opts) do
+ {:ok, _membership} ->
+ socket = refresh_scope(socket)
+
+ {:noreply,
+ socket
+ |> put_flash(:info, gettext("Joined tribe"))
+ |> assign(:join_form, to_form(%{"invite_code" => ""}, as: :join))}
+
+ {:error, _reason} ->
+ {:noreply, put_flash(socket, :error, gettext("Unable to join tribe"))}
+ end
+ end
+ end
+
+ def handle_event("post", %{"note" => %{"content" => content}}, socket) do
+ content = String.trim(content)
+
+ cond do
+ socket.assigns[:current_user] == nil ->
+ {:noreply, put_flash(socket, :error, gettext("Sign in to post"))}
+
+ content == "" ->
+ {:noreply, put_flash(socket, :error, gettext("Post cannot be empty"))}
+
+ not can_post_to_scope?(socket) ->
+ {:noreply, put_flash(socket, :error, gettext("Join this tribe to post"))}
+
+ true ->
+ case socket.assigns[:session_privkey] do
+ privkey when is_binary(privkey) ->
+ case Nostr.publish_note(content, socket.assigns.current_user.pubkey_hex, privkey) do
+ {:ok, event} ->
+ if note_in_scope?(event, socket.assigns.tribe_member_pubkeys) do
+ note_ids = MapSet.put(socket.assigns.note_ids, event["id"])
+
+ {:noreply,
+ socket
+ |> assign(:form, to_form(%{"content" => ""}, as: :note))
+ |> assign(:note_ids, note_ids)
+ |> assign(:note_count, MapSet.size(note_ids))
+ |> stream_insert(:notes, event, at: 0)}
+ else
+ {:noreply,
+ socket
+ |> assign(:form, to_form(%{"content" => ""}, as: :note))
+ |> put_flash(:info, gettext("Posted outside current tribe scope"))}
+ end
+
+ {:error, reason} ->
+ {:noreply,
+ put_flash(
+ socket,
+ :error,
+ gettext("Failed to publish: %{reason}", reason: inspect(reason))
+ )}
+ end
+
+ _other ->
+ {:noreply, put_flash(socket, :error, gettext("Signing key unavailable in session"))}
+ end
+ end
+ end
+
+ @impl true
+ def handle_info({:parrhesia, :event, _ref, _sub_id, %{"kind" => 1} = event}, socket) do
+ if note_in_scope?(event, socket.assigns.tribe_member_pubkeys) and
+ socket.assigns.can_view_tribe? do
+ profile_cache = maybe_put_profile(socket.assigns.profile_cache, event["pubkey"])
+
+ if MapSet.member?(socket.assigns.note_ids, event["id"]) do
+ {:noreply, assign(socket, :profile_cache, profile_cache)}
+ else
+ note_ids = MapSet.put(socket.assigns.note_ids, event["id"])
+
+ {:noreply,
+ socket
+ |> assign(:profile_cache, profile_cache)
+ |> assign(:note_ids, note_ids)
+ |> assign(:note_count, MapSet.size(note_ids))
+ |> stream_insert(:notes, event, at: 0)}
+ end
+ else
+ {:noreply, socket}
+ end
+ end
+
+ def handle_info({:parrhesia, :event, _ref, _sub_id, _event}, socket), do: {:noreply, socket}
+ def handle_info({:parrhesia, :eose, _ref, _sub_id}, socket), do: {:noreply, socket}
+ def handle_info({:parrhesia, :closed, _ref, _sub_id, _reason}, socket), do: {:noreply, socket}
+
+ @impl true
+ def terminate(_reason, socket) do
+ if is_reference(socket.assigns[:subscription_ref]) do
+ Nostr.unsubscribe(socket.assigns.subscription_ref)
+ end
+
+ :ok
+ end
+
+ @impl true
+ def render(assigns) do
+ ~H"""
+
+
+
+
{timeline_title(@tribe)}
+
{gettext("%{count} notes", count: @note_count)}
+
+
+ <%= if @tribe && !@is_tribe_member? do %>
+
+
+
+ {gettext("You are viewing this tribe as a guest.")}
+
+ <.form for={@join_form} id="join-tribe-form" phx-submit="join_tribe" class="space-y-3">
+ <.input
+ field={@join_form[:invite_code]}
+ type="text"
+ label={gettext("Invite code (optional for public tribes)")}
+ />
+
+
+ {gettext("Join tribe")}
+
+
+
+
+
+ <% end %>
+
+ <%= if @current_user && (@can_view_tribe? || is_nil(@tribe)) do %>
+ <.form for={@form} id="note-form" phx-submit="post" class="card bg-base-100 shadow-sm">
+
+ <.input field={@form[:content]} type="textarea" label={gettext("Post")} rows="3" />
+
+
+ {gettext("Post note")}
+
+
+
+
+ <% end %>
+
+ <%= if !@can_view_tribe? && @tribe do %>
+
+ {gettext("This tribe timeline is private until you join.")}
+
+ <% end %>
+
+
+
+ {gettext("No notes yet.")}
+
+
+
+
+
+ <.link navigate={~p"/u/#{note["pubkey"]}"} class="link link-hover font-medium">
+ {author_label(@profile_cache, note["pubkey"])}
+
+
+
+
+
{note["content"]}
+
+
+
+
+
+ """
+ end
+
+ defp resolve_tribe_id(params, session) when is_map(params) do
+ case Map.get(params, "tribe_id") || Map.get(params, "id") do
+ tribe_id when is_binary(tribe_id) and tribe_id != "" ->
+ tribe_id
+
+ _ ->
+ parse_tribe_id_from_plugin_path(session["plugin_path"])
+ end
+ end
+
+ defp resolve_tribe_id(_params, session),
+ do: parse_tribe_id_from_plugin_path(session["plugin_path"])
+
+ defp parse_tribe_id_from_plugin_path(path) when is_binary(path) do
+ case String.split(path, "/", trim: true) do
+ ["aether", tribe_id | _] -> tribe_id
+ _ -> nil
+ end
+ end
+
+ defp parse_tribe_id_from_plugin_path(_path), do: nil
+
+ defp load_scope(current_user, tribe_id) when is_binary(tribe_id) do
+ with {:ok, %Tribe{} = tribe} <- Communities.get_tribe(tribe_id),
+ {:ok, members} <- Communities.list_tribe_members_for_timeline(tribe.id) do
+ member_pubkeys = members |> Enum.map(& &1.user.pubkey_hex) |> MapSet.new()
+
+ is_member =
+ case current_user do
+ nil -> false
+ user -> Enum.any?(members, &(&1.user_id == user.id))
+ end
+
+ can_view? = tribe.visibility == :public || is_member
+
+ %{tribe: tribe, member_pubkeys: member_pubkeys, member?: is_member, can_view?: can_view?}
+ else
+ _ -> %{tribe: nil, member_pubkeys: nil, member?: false, can_view?: true}
+ end
+ end
+
+ defp load_scope(_current_user, _tribe_id),
+ do: %{tribe: nil, member_pubkeys: nil, member?: false, can_view?: true}
+
+ defp refresh_scope(socket) do
+ scope = load_scope(socket.assigns.current_user, socket.assigns.tribe_id)
+
+ notes =
+ case Nostr.list_notes(limit: 100) do
+ {:ok, notes} -> filter_notes(notes, scope)
+ _ -> []
+ end
+
+ note_ids = notes |> Enum.map(& &1["id"]) |> MapSet.new()
+
+ socket
+ |> assign(:tribe, scope.tribe)
+ |> assign(:can_view_tribe?, scope.can_view?)
+ |> assign(:is_tribe_member?, scope.member?)
+ |> assign(:tribe_member_pubkeys, scope.member_pubkeys)
+ |> assign(:note_ids, note_ids)
+ |> assign(:note_count, MapSet.size(note_ids))
+ |> stream(:notes, notes, reset: true)
+ end
+
+ defp can_post_to_scope?(socket) do
+ case socket.assigns.tribe_member_pubkeys do
+ nil -> true
+ pubkeys -> MapSet.member?(pubkeys, socket.assigns.current_user.pubkey_hex)
+ end
+ end
+
+ defp note_in_scope?(_event, nil), do: true
+
+ defp note_in_scope?(event, member_pubkeys) do
+ MapSet.member?(member_pubkeys, event["pubkey"])
+ end
+
+ defp filter_notes(_notes, %{can_view?: false}), do: []
+ defp filter_notes(notes, %{member_pubkeys: nil}), do: notes
+
+ defp filter_notes(notes, %{member_pubkeys: member_pubkeys}) do
+ Enum.filter(notes, ¬e_in_scope?(&1, member_pubkeys))
+ end
+
+ defp load_profile_cache(notes) do
+ notes
+ |> Enum.map(& &1["pubkey"])
+ |> Enum.uniq()
+ |> Enum.reduce(%{}, &maybe_put_profile(&2, &1))
+ end
+
+ defp maybe_put_profile(cache, pubkey) do
+ if Map.has_key?(cache, pubkey) do
+ cache
+ else
+ case Nostr.get_profile(pubkey) do
+ {:ok, nil} -> Map.put(cache, pubkey, nil)
+ {:ok, event} -> Map.put(cache, pubkey, parse_profile(event))
+ {:error, _reason} -> Map.put(cache, pubkey, nil)
+ end
+ end
+ end
+
+ defp parse_profile(%{"content" => content}) when is_binary(content) do
+ case JSON.decode(content) do
+ {:ok, profile} when is_map(profile) -> profile
+ _other -> %{}
+ end
+ end
+
+ defp parse_profile(_event), do: %{}
+
+ defp author_label(profile_cache, pubkey) do
+ profile = Map.get(profile_cache, pubkey, %{}) || %{}
+ profile["name"] || "#{String.slice(pubkey, 0, 8)}…"
+ end
+
+ defp timeline_title(nil), do: gettext("Global timeline")
+ defp timeline_title(%Tribe{name: name}), do: gettext("%{name} timeline", name: name)
+end
diff --git a/lib/tribes/plugins/aether/plugin.ex b/lib/tribes/plugins/aether/plugin.ex
new file mode 100644
index 0000000..e1189f7
--- /dev/null
+++ b/lib/tribes/plugins/aether/plugin.ex
@@ -0,0 +1,5 @@
+defmodule Tribes.Plugins.Aether.Plugin do
+ @moduledoc false
+
+ defdelegate register(context), to: Aether.Plugin
+end
diff --git a/manifest.json b/manifest.json
index c4964d6..bb2c819 100644
--- a/manifest.json
+++ b/manifest.json
@@ -1,15 +1,16 @@
{
- "name": "my_plugin",
+ "name": "aether",
"version": "0.1.0",
"description": "TODO: Describe what this plugin does",
- "entry_module": "MyPlugin.Plugin",
+ "entry_module": "Tribes.Plugins.Aether.Plugin",
"host_api": "1",
- "provides": [],
- "requires": ["ecto@1"],
+ "otp_app": "aether",
+ "provides": ["timeline_ui@1"],
+ "requires": ["ecto@1", "phoenix@1"],
"enhances_with": [],
"assets": {
- "global_js": ["my_plugin.js"],
- "global_css": ["my_plugin.css"]
+ "global_js": ["aether.js"],
+ "global_css": ["aether.css"]
},
"migrations": false,
"children": false
diff --git a/mix.exs b/mix.exs
index 5c4eb2a..1a3c3bc 100644
--- a/mix.exs
+++ b/mix.exs
@@ -1,9 +1,9 @@
-defmodule MyPlugin.MixProject do
+defmodule Aether.MixProject do
use Mix.Project
def project do
[
- app: :my_plugin,
+ app: :aether,
version: "0.1.0",
elixir: "~> 1.18",
elixirc_paths: elixirc_paths(Mix.env()),
@@ -17,7 +17,7 @@ defmodule MyPlugin.MixProject do
[
extra_applications: [:logger]
# Uncomment if your plugin needs its own supervision tree:
- # mod: {MyPlugin.Application, []}
+ # mod: {Aether.Application, []}
]
end
@@ -29,7 +29,7 @@ defmodule MyPlugin.MixProject do
# Host dependency — provides Tribes.Plugin behaviour, types, and test helpers.
#
# For local development alongside a tribes checkout:
- {:tribes, path: "../tribes", only: [:dev, :test]},
+ {:tribes, path: "../tribes", runtime: false},
#
# For CI or standalone development (when not co-located with tribes):
# {:tribes, github: "your-org/tribes", branch: "master", only: [:dev, :test]},
diff --git a/scripts/rename.sh b/scripts/rename.sh
index 701e381..2feba00 100755
--- a/scripts/rename.sh
+++ b/scripts/rename.sh
@@ -45,13 +45,32 @@ if [ -d "test/my_plugin" ]; then
mv "test/my_plugin" "test/$SNAKE"
fi
-# Replace in all text files
-find . -type f \( -name '*.ex' -o -name '*.exs' -o -name '*.json' -o -name '*.js' -o -name '*.css' -o -name '*.md' -o -name '*.yml' -o -name '*.yaml' -o -name '.formatter.exs' \) -exec \
- sed -i '' \
- -e "s/my_plugin/$SNAKE/g" \
- -e "s/MyPlugin/$MODULE/g" \
- -e "s/my-plugin/$SNAKE/g" \
- {} +
+# Replace in all text files (portable across GNU/BSD sed)
+sed_in_place() {
+ file=$1
+
+ if sed --version >/dev/null 2>&1; then
+ sed -i \
+ -e "s/my_plugin/$SNAKE/g" \
+ -e "s/MyPlugin/$MODULE/g" \
+ -e "s/my-plugin/$SNAKE/g" \
+ "$file"
+ else
+ sed -i '' \
+ -e "s/my_plugin/$SNAKE/g" \
+ -e "s/MyPlugin/$MODULE/g" \
+ -e "s/my-plugin/$SNAKE/g" \
+ "$file"
+ fi
+}
+
+while IFS= read -r -d '' file; do
+ sed_in_place "$file"
+done < <(
+ find . -type f \
+ \( -name '*.ex' -o -name '*.exs' -o -name '*.json' -o -name '*.js' -o -name '*.css' -o -name '*.md' -o -name '*.yml' -o -name '*.yaml' -o -name '.formatter.exs' \) \
+ -print0
+)
# Rename asset files
for ext in js css; do
diff --git a/test/aether/host_integration_test.exs b/test/aether/host_integration_test.exs
new file mode 100644
index 0000000..0a14859
--- /dev/null
+++ b/test/aether/host_integration_test.exs
@@ -0,0 +1,27 @@
+defmodule Aether.HostIntegrationTest do
+ use ExUnit.Case, async: false
+
+ setup do
+ start_supervised!({Tribes.PluginRegistry, []})
+
+ spec = Tribes.Plugins.Aether.Plugin.register(%{pubsub: nil, repo: nil})
+ :ok = Tribes.PluginRegistry.register_plugin(spec.name, spec, "/tmp/aether")
+
+ on_exit(fn ->
+ :ok = Tribes.PluginRegistry.unregister_plugin(spec.name)
+ end)
+
+ %{spec: spec}
+ end
+
+ test "registers timeline capability and /aether route with host", %{spec: spec} do
+ assert spec.name == "aether"
+ assert %{name: "aether"} = Tribes.PluginRegistry.provider!("timeline_ui@1")
+
+ assert {:ok, "aether", %{path: "/aether"}} =
+ Tribes.PluginRegistry.page_for_path("/aether/tribe-123")
+
+ assert TribesWeb.Navigation.timeline_base_path() == "/aether"
+ assert TribesWeb.Navigation.timeline_path("tribe-123") == "/aether/tribe-123"
+ end
+end
diff --git a/test/my_plugin/manifest_test.exs b/test/aether/manifest_test.exs
similarity index 70%
rename from test/my_plugin/manifest_test.exs
rename to test/aether/manifest_test.exs
index d789aad..4d1a890 100644
--- a/test/my_plugin/manifest_test.exs
+++ b/test/aether/manifest_test.exs
@@ -1,4 +1,4 @@
-defmodule MyPlugin.ManifestTest do
+defmodule Aether.ManifestTest do
use ExUnit.Case, async: true
@manifest_path Path.join(__DIR__, "../../manifest.json") |> Path.expand()
@@ -15,7 +15,15 @@ defmodule MyPlugin.ManifestTest do
end
test "has required fields", %{manifest: manifest} do
- required = ["name", "version", "entry_module", "host_api", "provides", "requires"]
+ required = [
+ "name",
+ "version",
+ "entry_module",
+ "host_api",
+ "otp_app",
+ "provides",
+ "requires"
+ ]
for field <- required do
assert Map.has_key?(manifest, field),
@@ -24,13 +32,18 @@ defmodule MyPlugin.ManifestTest do
end
test "name matches OTP app name", %{manifest: manifest} do
- assert manifest["name"] == "my_plugin"
+ assert manifest["name"] == "aether"
+ assert manifest["otp_app"] == manifest["name"]
end
- test "entry_module is a valid Elixir module name", %{manifest: manifest} do
+ test "entry_module uses Tribes.Plugins namespace and Plugin suffix", %{manifest: manifest} do
module_name = manifest["entry_module"]
assert is_binary(module_name)
- assert String.starts_with?(module_name, "Elixir.") or not String.contains?(module_name, " ")
+
+ assert Regex.match?(
+ ~r/^Tribes\.Plugins\.[A-Z][A-Za-z0-9_]*(\.[A-Z][A-Za-z0-9_]*)*\.Plugin$/,
+ module_name
+ )
end
test "provides contains valid capability identifiers", %{manifest: manifest} do
@@ -53,7 +66,9 @@ defmodule MyPlugin.ManifestTest do
test "entry_module matches actual plugin module", %{manifest: manifest} do
module = String.to_atom("Elixir.#{manifest["entry_module"]}")
- assert Code.ensure_loaded?(module), "entry_module #{manifest["entry_module"]} must be loadable"
+
+ assert Code.ensure_loaded?(module),
+ "entry_module #{manifest["entry_module"]} must be loadable"
end
end
end
diff --git a/test/my_plugin/plugin_test.exs b/test/aether/plugin_test.exs
similarity index 93%
rename from test/my_plugin/plugin_test.exs
rename to test/aether/plugin_test.exs
index 299486a..0dd1775 100644
--- a/test/my_plugin/plugin_test.exs
+++ b/test/aether/plugin_test.exs
@@ -1,14 +1,14 @@
-defmodule MyPlugin.PluginTest do
+defmodule Aether.PluginTest do
use ExUnit.Case, async: true
describe "register/1" do
setup do
context = %{pubsub: nil, repo: nil}
- %{spec: MyPlugin.Plugin.register(context)}
+ %{spec: Aether.Plugin.register(context)}
end
test "returns plugin name and version", %{spec: spec} do
- assert spec.name == "my_plugin"
+ assert spec.name == "aether"
assert is_binary(spec.version)
end
diff --git a/test/contract_test.exs b/test/contract_test.exs
index 1038b2a..db231ef 100644
--- a/test/contract_test.exs
+++ b/test/contract_test.exs
@@ -1,4 +1,4 @@
-defmodule MyPlugin.ContractTest do
+defmodule Aether.ContractTest do
@moduledoc """
Contract compliance tests.
@@ -6,10 +6,10 @@ defmodule MyPlugin.ContractTest do
When Tribes.Plugin.ContractTest is available (host dep loaded),
replace this file with:
- defmodule MyPlugin.ContractTest do
+ defmodule Aether.ContractTest do
use Tribes.Plugin.ContractTest,
- plugin: MyPlugin.Plugin,
- otp_app: :my_plugin
+ plugin: Tribes.Plugins.Aether.Plugin,
+ otp_app: :aether
end
Until then, this file provides a standalone equivalent.
@@ -17,7 +17,7 @@ defmodule MyPlugin.ContractTest do
use ExUnit.Case, async: true
- @plugin MyPlugin.Plugin
+ @plugin Tribes.Plugins.Aether.Plugin
@manifest_path Path.join(__DIR__, "../manifest.json") |> Path.expand()
setup_all do