You've already forked tribes-plugin-template
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6645ec48a0 | |||
| 78f6c11b30 |
@@ -85,6 +85,14 @@ Three test levels:
|
||||
|
||||
Run all: `mix test`
|
||||
|
||||
For DB setup/migrations in local development, run:
|
||||
|
||||
```bash
|
||||
mix tribes.migrate
|
||||
```
|
||||
|
||||
This runs Tribes + Parrhesia + plugin migrations via `Tribes.Release`.
|
||||
|
||||
## Building for Release
|
||||
|
||||
```bash
|
||||
|
||||
@@ -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 */
|
||||
}
|
||||
@@ -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");
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
<h1 class="text-2xl font-bold">My Plugin</h1>
|
||||
<p class="mt-2 text-base-content/70">
|
||||
This is a Tribes plugin. Edit this page in
|
||||
<code>lib/my_plugin_web/live/home_live.ex</code>.
|
||||
<code>lib/aether_web/live/home_live.ex</code>.
|
||||
</p>
|
||||
</div>
|
||||
"""
|
||||
366
lib/aether_web/live/timeline_live.ex
Normal file
366
lib/aether_web/live/timeline_live.ex
Normal file
@@ -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"""
|
||||
<Layouts.app flash={@flash} current_scope={@current_scope} current_user={@current_user}>
|
||||
<div class="space-y-6" id="aether-page">
|
||||
<div class="flex items-center justify-between">
|
||||
<h1 class="text-2xl font-semibold tracking-tight">{timeline_title(@tribe)}</h1>
|
||||
<div class="badge badge-outline">{gettext("%{count} notes", count: @note_count)}</div>
|
||||
</div>
|
||||
|
||||
<%= if @tribe && !@is_tribe_member? do %>
|
||||
<div class="card border border-base-300 bg-base-100 shadow-sm" id="tribe-join-card">
|
||||
<div class="card-body gap-3">
|
||||
<p class="text-sm text-base-content/80">
|
||||
{gettext("You are viewing this tribe as a guest.")}
|
||||
</p>
|
||||
<.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)")}
|
||||
/>
|
||||
<div class="card-actions justify-end">
|
||||
<button id="join-tribe-button" type="submit" class="btn btn-primary btn-sm">
|
||||
{gettext("Join tribe")}
|
||||
</button>
|
||||
</div>
|
||||
</.form>
|
||||
</div>
|
||||
</div>
|
||||
<% 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">
|
||||
<div class="card-body">
|
||||
<.input field={@form[:content]} type="textarea" label={gettext("Post")} rows="3" />
|
||||
<div class="card-actions justify-end">
|
||||
<button id="post-note-button" type="submit" class="btn btn-primary btn-sm">
|
||||
{gettext("Post note")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</.form>
|
||||
<% end %>
|
||||
|
||||
<%= if !@can_view_tribe? && @tribe do %>
|
||||
<div class="rounded-box border border-dashed border-base-300 bg-base-100 p-6 text-center text-sm text-base-content/70">
|
||||
{gettext("This tribe timeline is private until you join.")}
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<div id="notes" phx-update="stream" class="space-y-3">
|
||||
<div
|
||||
id="notes-empty"
|
||||
class="hidden only:block rounded-box border border-dashed border-base-300 bg-base-100 p-8 text-center text-sm text-base-content/60"
|
||||
>
|
||||
{gettext("No notes yet.")}
|
||||
</div>
|
||||
|
||||
<article :for={{id, note} <- @streams.notes} id={id} class="card bg-base-100 shadow-sm">
|
||||
<div class="card-body gap-2">
|
||||
<div class="flex items-center justify-between gap-4 text-xs text-base-content/60">
|
||||
<.link navigate={~p"/u/#{note["pubkey"]}"} class="link link-hover font-medium">
|
||||
{author_label(@profile_cache, note["pubkey"])}
|
||||
</.link>
|
||||
<span
|
||||
phx-hook="DateTime"
|
||||
id={"time-#{note["id"]}"}
|
||||
phx-update="ignore"
|
||||
data-timestamp={note["created_at"] * 1000}
|
||||
data-relative="true"
|
||||
data-format="LLL"
|
||||
>
|
||||
</span>
|
||||
</div>
|
||||
<p class="whitespace-pre-wrap text-sm leading-6">{note["content"]}</p>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
</Layouts.app>
|
||||
"""
|
||||
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
|
||||
5
lib/tribes/plugins/aether/plugin.ex
Normal file
5
lib/tribes/plugins/aether/plugin.ex
Normal file
@@ -0,0 +1,5 @@
|
||||
defmodule Tribes.Plugins.Aether.Plugin do
|
||||
@moduledoc false
|
||||
|
||||
defdelegate register(context), to: Aether.Plugin
|
||||
end
|
||||
@@ -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
|
||||
|
||||
8
mix.exs
8
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]},
|
||||
|
||||
@@ -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
|
||||
|
||||
27
test/aether/host_integration_test.exs
Normal file
27
test/aether/host_integration_test.exs
Normal file
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user