47 Commits

Author SHA1 Message Date
self 80101b7e78 fix: tolerate Parrhesia EOSE hints
Handle the NIP-67 EOSE tuple shape emitted by newer Parrhesia while keeping compatibility with the previous message format.
2026-06-08 21:53:24 +02:00
self 1483d30d7e dev: add en_GB locale archive to devenv
Provide a Nix glibc locale archive with en_GB.UTF-8 so tools inside the devenv shell inherit a valid UTF-8 locale.
2026-06-08 21:52:55 +02:00
self 1cbcb5ea55 chore: use conventional Guix manifest
Rename the repo Guix development manifest to manifest.scm and update the wrapper and direnv integration.
2026-06-01 19:38:21 +02:00
self 742359c2cb build: load dotenv in Guix dev shell
Source the repo .env before entering the pinned Guix shell so Guix fallback commands match the devenv environment more closely.
2026-06-01 16:33:13 +02:00
self 3bc16c7cb5 docs: note Guix plugin shell fallback
Clarify that plugin workflows should use devenv when available and the repo-local Guix wrapper when devenv is unavailable.
2026-06-01 15:32:30 +02:00
self d0ba98f382 build: add pinned Guix dev shell wrapper
Add repo-local guix/channels.scm time-machine pins and a guix/guix-dev bootstrap wrapper. Update the Guix direnv path to use the pinned channel set.
2026-05-31 22:57:51 +02:00
self 603ad5d0ae feat: support Guix development shell
Add a repo-local Guix manifest, direnv backend selection, and PATH-based hook configuration for the Aether plugin development environment.
2026-05-30 22:29:37 +02:00
self 34bec7225b feat: use shared Tribes gettext
Use tgettext for shared sign-in UI copy.
2026-05-27 20:07:00 +02:00
self b4b8c83ddb feat: namespace plugin identity
Adopt canonical plugin id/slug manifest fields, vendor-prefixed OTP app naming, and fully-qualified capability ids for Aether.
2026-05-27 19:05:51 +02:00
self 44b9c6caba chore: quiet dev Ash domain warnings
Suppress Ash domain config inclusion warnings while compiling the host as a dev-only plugin dependency.
2026-05-27 16:19:13 +02:00
self c1f4339dde refactor: move Aether into TribeOne plugin namespace
Use TribeOne.TribesPlugin.Aether modules throughout the plugin and expose chat@1 from the entry module for capability-based consumers.
2026-05-26 01:13:28 +02:00
self 341dcb573f docs: update Aether README for chat APIs
Document the current social and chat feature set, NIP-17/NIP-04 behavior, runtime requirements, and plugin API surface. Remove obsolete template content and bump the plugin version to 0.2.0.
2026-05-25 17:20:26 +02:00
self 446fffcadc feat: implement NIP-17 direct messages
Use the session-unlocked Nostr private key to publish and read NIP-17/NIP-59 gift-wrapped DMs via Parrhesia events, with a read-only NIP-04 import path. Wire the chat UI to pass signer context and add nak-backed interoperability tests for inbound and outbound DMs.
2026-05-25 17:13:34 +02:00
self 2884f43f9a test: add external client interop checks
Add deterministic nak coverage for NIP-17/NIP-59 gift wrapping and NIP-04 decryptability, plus a thin algia JSON-consumption smoke test with isolated config.
2026-05-25 16:23:34 +02:00
self 8b1990ee25 feat: add chat backend contract and recipient picker
Introduce plain-Elixir chat backends, participant projections, a chat@1 recipient picker component, and a public-sync user-to-user message flow test while scaffolding NIP-17/NIP-04/Marmot backend capabilities.
2026-05-25 15:54:58 +02:00
self a55bd9612d refactor: extract reusable chat panel component
Move Aether chat panel behavior into a LiveComponent so other plugins can embed it without an iframe while ChatLive continues to own standalone routing.
2026-05-25 12:50:05 +02:00
self d257221dc8 feat: scaffold marmot chat backend
Pin marmot-ts 0.5.1, expose Marmot chat routes/configuration, and keep the web UI disabled until transport, storage, and signing adapters are implemented.
2026-05-25 05:47:49 +02:00
self 421fa01076 feat: add embeddable chat panel
Add compact embedded chat routing and provider helpers so other plugins can attach Aether chat channels by URL without linking against Aether internals.
2026-05-25 05:39:37 +02:00
self f386cd38f5 feat: add public synced chat
Introduce the chat@1 capability, public Ash-backed chat channels/messages, standalone Aether chat page, migrations, and plugin tests.
2026-05-25 05:37:15 +02:00
self 21dd359b2f docs: clarify plugin hook registration
Document that external plugin hooks are registered through global_js bundles and window.TribesPluginHooks rather than host-side colocated imports.
2026-05-24 23:46:10 +02:00
self 763f1ffe14 build: configure npm release delay
Use npm's native min-release-age setting in devenv and assets npm config so dependency resolution avoids very fresh publishes.
2026-05-24 19:53:30 +02:00
self 8f8748b278 build: update devenv inputs
Run devenv update to refresh pinned devenv and related inputs.
2026-05-24 16:26:21 +02:00
self a6fa08118f build: refresh devenv lock
Refresh devenv.lock after the devenv update normalized lock-file inputs.
2026-05-24 16:16:55 +02:00
self 09fa00c92c chore: declare git-hooks input in devenv.yaml
Newer devenv versions require git-hooks.nix to be declared as an
explicit input when devenv.nix uses `git-hooks.hooks`.
2026-05-24 15:20:00 +02:00
self 0fa953420b fix: handle batched aether note events
Accept Parrhesia batch delivery in the timeline LiveView and reuse the existing note processing path for filtering, de-duplication, profile cache updates, and stream insertion.

Add a regression test that sends the batched message shape to the LiveView.
2026-05-23 15:59:44 +02:00
self 20be0857a5 feat: expose plugin migration commands
Forward ash.codegen and ecto.migration through the Aether plugin wrapper and update agent guidance to prefer AshPostgres codegen for resource-backed schema changes.
2026-05-23 15:58:13 +02:00
self ca4995aa2b build: ignore playwright mcp state
Exclude local Playwright MCP session state from the aether plugin repository.
2026-05-21 12:42:42 +02:00
self dbb5491020 fix: remove relay internals from timeline
Drop user-facing relay topology details from the Aether timeline and refresh the Parrhesia lock entry used by the plugin.
2026-05-18 12:19:35 +02:00
self da003b0a4f fix: use alliance plugin service
Switch Aether's timeline feed from the removed communities service to the new alliance service contract.
2026-05-14 19:17:46 +02:00
self 24f314a6d5 fix: use host-backed plugin test aliases
Route raw Mix test and precommit aliases through the plugin wrapper workflow.

Document that normal plugin checks should use plugin test or plugin precommit.
2026-05-09 19:43:30 +02:00
self aecc7da70b fix: advertise social plugin contract 2026-05-09 17:31:18 +02:00
self c0eab283a1 chore: Update plugin scaffolding and nosdump wrapper 2026-05-07 16:49:20 +02:00
self 5d6ab457ef build(plugin): add shared plugin validate workflow
Extend the local plugin helper to forward the new validate command through the host environment and document the validate/test/precommit workflow in the plugin README.
2026-04-27 16:41:44 +02:00
self c121bde9b8 test(aether): adopt host-backed plugin workflow
Switch Aether to the stricter plugin-facing auth and community contract.
Replace local structural coverage with host-backed contract and page tests.
Add plugin helper wrappers and shared test config for the host devenv flow.
2026-04-27 15:42:31 +02:00
self 2afde628ca refactor: Adjust for tribes data model 2026-04-27 12:42:13 +02:00
self 5024e8857c build: Credo, precommit 2026-04-26 22:06:08 +02:00
self 1a0ba6a93c refactor: switch timeline auth state to scope 2026-04-11 18:29:55 +02:00
self 118d32241f refactor: use typed plugin nostr data 2026-04-10 18:38:06 +02:00
self 6fc8be20bf refactor: use plugin route helpers in timeline 2026-04-10 13:58:45 +02:00
self 8297334ea5 refactor: keep aether timeline on cluster-aware nostr wrappers
Retain TimelineLive on the plugin-facing Nostr service after the host implementation switched to multi-relay defaults, so the plugin benefits from cluster-aware reads and publish fanout without falling back to direct host internals.
2026-04-10 12:31:59 +02:00
self c904de90d7 refactor: switch aether timeline to plugin host services
Replace direct Tribes and TribesWeb module references in TimelineLive with the new Tribes.Plugin wrappers so the plugin can compile cleanly against tribes_plugin_api instead of host internals.

Use the new cluster relay discovery service to surface the known cluster relay set in the timeline UI while keeping the current local-relay publish and live-read behavior explicit.
2026-04-10 11:57:44 +02:00
self 4b7fe1b9ed dev: devenv.nix, nosdump pkg 2026-04-09 10:44:46 +02:00
self a07942b644 Render Aether in Tribes layout 2026-04-08 18:33:17 +02:00
self 63e10ad5bc Align aether plugin entrypoint and docs 2026-04-08 13:11:39 +02:00
self f833791991 Build Aether against tribes_plugin_api 2026-04-08 01:16:15 +02:00
self 6645ec48a0 Document tribes.migrate workflow in plugin README 2026-04-04 22:39:37 +02:00
self 78f6c11b30 Rename to aether plugin and add /aether timeline integration 2026-04-04 20:33:51 +02:00
69 changed files with 5542 additions and 597 deletions
+62 -7
View File
@@ -2,11 +2,66 @@
export DIRENV_WARN_TIMEOUT=20s
eval "$(devenv direnvrc)"
# Backend selection:
# - default: use devenv when available, otherwise Guix
# - optional local override: write "guix" or "devenv" to .dev-shell
backend=auto
if [ -f .dev-shell ]; then
backend=$(tr -d '[:space:]' < .dev-shell)
watch_file .dev-shell
fi
# `use devenv` supports the same options as the `devenv shell` command.
#
# To silence all output, use `--quiet`.
#
# Example usage: use devenv --quiet --impure --option services.postgres.enable:bool true
use devenv
use_aether_guix() {
watch_file manifest.scm
watch_file guix/channels.scm
watch_file guix/guix-dev
watch_file .pre-commit-config.yaml
watch_file scripts/plugin
export GUIX_DEV_ROOT="$PWD"
eval "$(guix time-machine -C guix/channels.scm -- shell -m manifest.scm --search-paths)"
if [ -f .env ]; then set -a; . ./.env; set +a; fi
export MIX_OS_DEPS_COMPILE_PARTITION_COUNT=8
export NODE_ENV=development
export NPM_CONFIG_MIN_RELEASE_AGE=7
export TRIBES_HOST_ROOT="${TRIBES_HOST_ROOT:-$PWD/../tribes}"
plugin() { bash "$PWD/scripts/plugin" "$@"; }
}
use_aether_devenv() {
eval "$(devenv direnvrc)"
use devenv
}
case "$backend" in
auto)
if command -v devenv >/dev/null 2>&1; then
use_aether_devenv
elif command -v guix >/dev/null 2>&1; then
use_aether_guix
else
log_error "neither devenv nor guix is available"
exit 1
fi
;;
nix|devenv)
if ! command -v devenv >/dev/null 2>&1; then
log_error ".dev-shell requested devenv, but devenv is unavailable"
exit 1
fi
use_aether_devenv
;;
guix)
if ! command -v guix >/dev/null 2>&1; then
log_error ".dev-shell requested guix, but guix is unavailable"
exit 1
fi
use_aether_guix
;;
*)
log_error "unknown .dev-shell backend: $backend"
exit 1
;;
esac
+16 -12
View File
@@ -5,21 +5,11 @@
/result
/result-*
# Devenv
.devenv*
devenv.local.nix
# Pre-commit
.pre-commit-config.yaml
# Node
/node_modules/
/assets/node_modules/
# Built plugin assets
/priv/static/*.css
/priv/static/*.js
/priv/static/vendor/
/priv/static/*
!/priv/static/.gitkeep
# Browser tooling
.playwright-mcp/
@@ -40,3 +30,17 @@ Thumbs.db
# Env files
.env
.env.local
# To do list
/TODO.md
# Devenv
.devenv*
.env
.null-ls_*.nix
devenv.local.nix
.guix-dev/
.dev-shell
# direnv
.direnv
+34
View File
@@ -0,0 +1,34 @@
# Shared by Nix/devenv and Guix shells. Keep hook entries PATH-based.
default_stages:
- pre-commit
repos:
- repo: local
hooks:
- id: check-added-large-files
name: check added large files
entry: scripts/hooks/check-added-large-files
args: ["--maxkb=131072"]
language: system
types: [file]
stages: [pre-commit, pre-push, manual]
- id: mix-format
name: mix format
entry: mix format
language: system
files: '\.(ex|exs|heex)$'
types: [file]
stages: [pre-commit]
- id: prettier
name: prettier
entry: prettier
args:
- --ignore-unknown
- --list-different
- --write
language: system
files: '\.(js|ts|tsx|css)$'
types: [text]
stages: [pre-commit]
+40 -36
View File
@@ -1,32 +1,33 @@
# Tribes Plugin Template Agent Guide
# Tribes Aether Plugin Agent Guide
This repository is a template for Tribes plugins. Preserve template placeholders
unless a task explicitly asks to rename the template.
This repository is the Tribes Aether plugin. Keep changes scoped to the plugin
contract, migrations, assets, and tests owned by this repo.
## Required Workflow
- Use `scripts/plugin` for plugin-aware commands: `scripts/plugin validate`, `scripts/plugin test`, `scripts/plugin precommit`, and `scripts/plugin smoke`.
- Use `devenv shell -- <command>` when installing or building Node assets from outside the devenv shell. Do not run raw `npm install` directly.
- Do not use raw `mix test` for host-backed plugin suites unless you are
deliberately recreating the `plugin test` host environment by hand; use
`mix raw_test` only for that low-level debugging path.
- Run `scripts/plugin smoke` after changing `mix.exs`, `manifest.json`, entry modules, or host-facing dependency setup.
- Keep commits semantic, for example `feat: add template smoke checks`, and include a body except for minimal patches.
- Use `plugin` for plugin-aware commands inside the repo development shell: `plugin validate`,
`plugin test`, `plugin precommit`, and `plugin smoke`. Outside the development
shell, use the local `scripts/plugin` wrapper.
- Prefer `devenv shell -- <command>` when running repo commands from outside the
development shell. If `devenv` is unavailable, use `./guix/guix-dev -- <command>`
rather than raw `guix shell` so pinned channels from `guix/channels.scm` are used.
- Raw `mix test` is not the normal entrypoint for this repo; `plugin test`
invokes `mix raw_test` with the host database/services and host
plugin-manager paths.
- Keep browser assets under the plugin asset pipeline and avoid host app
internals unless the plugin API requires them.
- For AshPostgres resource changes, generate migrations with
`plugin ash.codegen <name>`. Use `plugin ecto.migration <name>` only for rare
manual schema migrations that are not derived from Ash resources.
- Do not edit an existing migration after it may have run; add a new migration
instead.
## Plugin Contract
- `manifest.json` `entry_module` must point at `Tribes.Plugins.MyPlugin.Plugin` until the template is renamed.
- Keep the host bridge module at `lib/tribes/plugins/my_plugin/plugin.ex`; after renaming it should become `lib/tribes/plugins/<plugin>/plugin.ex`.
- The dev `:tribes` dependency is compile-only so host plugin loading can build `_build/dev` beams without starting a nested host app.
- The test `:tribes` dependency is runtime-enabled so host-backed tests can start `Tribes.Repo` and related services.
- If the plugin adds cluster-synced Ash resources, use `AshNostrSync` deliberately and document replication semantics in `docs/plugin-contract.md`.
- `manifest.json` `entry_module` must point at `TribeOne.TribesPlugin.Aether.Plugin`.
- Keep runtime spec fields aligned with `manifest.json`.
- Treat host integrations as capability-based plugin contracts.
## Frontend
- Default pages should render inside host chrome with `Tribes.Plugin.Layouts.app`; keep `ui@1` in `manifest.json` `requires` for host chrome or direct `Tribes.UI` usage.
- Prefer TypeScript in `assets/ts` for browser code. The default build emits browser-ready files to `priv/static`.
- Keep CSS selectors plugin-scoped, normally prefixed with `.my-plugin`, to avoid collisions with the host or other plugins.
- Use the package scripts in `assets/package.json`; do not bypass them with ad-hoc asset commands.
<!-- usage-rules-start -->
<!-- usage_rules-start -->
@@ -362,20 +363,18 @@ when writing scripts inside the template**:
}
</script>
- colocated hooks are automatically integrated into the app.js bundle
- colocated hooks are automatically integrated into the app.js bundle that imports the generated `phoenix-colocated/<otp_app>` module
- external Tribes plugin OTP apps are not auto-imported by the host `app.js` bundle; use `assets.global_js` plus `window.TribesPluginHooks` for plugin hooks
- colocated hooks names **MUST ALWAYS** start with a `.` prefix, i.e. `.PhoneNumber`
#### External phx-hook
External JS hooks (`<div id="myhook" phx-hook="MyHook">`) must be placed in `assets/js/` and passed to the
LiveSocket constructor:
External JS hooks (`<div id="myhook" phx-hook="MyHook">`) in Tribes plugins must be registered by a plugin JS bundle listed in `manifest.json` `assets.global_js`; the host merges `window.TribesPluginHooks` into the LiveSocket constructor:
const MyHook = {
window.TribesPluginHooks = window.TribesPluginHooks || {};
window.TribesPluginHooks.MyHook = {
mounted() { ... }
}
let liveSocket = new LiveSocket("/live", Socket, {
hooks: { MyHook }
});
};
#### Pushing events between client and server
@@ -549,8 +548,10 @@ source directory, the same document is at `../docs/plugins.md`.
- Implement the runtime contract with `Tribes.Plugin` or `Tribes.Plugin.Base`.
- Prefer `use Tribes.Plugin.Base, otp_app: :your_plugin` for normal plugins; it
reads `manifest.json` and fills the manifest-backed spec fields.
- Keep the host-facing entry module under `Tribes.Plugins.*.Plugin`. It may be a
thin delegate to the plugin application's own module.
- Keep plugin modules under an owner-controlled namespace, such as
`TribeOne.TribesPlugin.<Plugin>.Plugin` for first-party plugins or
`AcmeCorp.TribesPlugins.Foo.Plugin` for third-party plugins. The entry module
must end in `.Plugin` and should be the module named by `manifest.json`.
- `register/1` must return a `Tribes.Plugin.Spec` struct or a map/struct that
validates into that spec.
@@ -619,9 +620,9 @@ checkout during development. For the full contract, see
- `manifest.json` is the static build/runtime contract. Keep `name`,
`version`, `entry_module`, `host_api`, `otp_app`, `provides`, `requires`, and
`enhances_with` aligned with the runtime spec returned by `register/1`.
- `entry_module` must be under `Tribes.Plugins.*.Plugin` and end in `.Plugin`.
- Capability versions are discrete breaking-change markers such as `ui@1` or
`social@1`, not semver. Framework APIs are part of the `host_api` foundation, not separate manifest capabilities.
- `entry_module` must be a valid module ending in `.Plugin` under an owner-controlled namespace.
- Capability versions are discrete breaking-change markers such as `org.tribe-one.caps.ui@1` or
`org.tribe-one.caps.social@1`, not semver. Framework APIs are part of the `host_api` foundation, not separate manifest capabilities.
- Use `requires` for hard dependencies and `enhances_with` for optional
integrations that the plugin can run without.
- Run `scripts/plugin validate` after changing `manifest.json` or the runtime
@@ -630,11 +631,14 @@ checkout during development. For the full contract, see
## UI And Assets
- Plugin LiveViews that use host chrome should render with
`Tribes.Plugin.Layouts.app` and keep `ui@1` in `manifest.json` `requires`.
- Consumers should target the `ui@1` facade with `use Tribes.UI` or
`Tribes.Plugin.Layouts.app` and keep `org.tribe-one.caps.ui@1` in `manifest.json` `requires`.
- Consumers should target the `org.tribe-one.caps.ui@1` facade with `use Tribes.UI` or
`import Tribes.UI.Components`, not a concrete provider module.
- Declare browser assets in `manifest.json` under `assets.global_js` and
`assets.global_css`; the host serves them through the plugin asset surface.
- Register plugin LiveView hooks from `assets.global_js` bundles via
`window.TribesPluginHooks`. Phoenix colocated hooks are not auto-imported from
external plugin OTP apps by the host `app.js` bundle.
- Keep CSS selectors scoped to the plugin, normally with a plugin-specific root
class.
+130 -181
View File
@@ -1,209 +1,158 @@
# Tribes Plugin Template
# Aether
**DEPRECATED!** Use [the generator (`tribes-plugin-new`)](https://git.teralink.net/tribes/tribes-plugin-new) instead.
Aether is an external Tribes plugin that adds local social posting and chat to a
Tribes node.
Template for creating [Tribes](https://github.com/your-org/tribes) plugins.
It provides:
## Getting Started
- `org.tribe-one.caps.social@1` — a local tribe feed backed by Nostr kind `1` notes.
- `org.tribe-one.caps.chat@1` — reusable chat surfaces for public rooms, embeddable context chat,
NIP-17 direct messages, and future chat backends.
1. Click **"Use this template"** on GitHub to create your own repo
2. Clone and rename:
## Features
```bash
git clone https://github.com/you/your-plugin.git
cd your-plugin
./scripts/rename.sh your_plugin YourPlugin
### Local social feed
- `/aether` renders the local tribe feed.
- Signed-in users can publish Nostr notes with their Tribes identity.
- Feed subscriptions use the host/Parrhesia Nostr event stream.
### Public and embeddable chat
- `/aether/chat` and `/aether/chat/:slug` render standalone chat rooms.
- `/aether/chat/embed/:slug` renders an embeddable chat panel for other plugins
such as Sender livestream pages.
- Public chat uses Aether Ash resources as the synced message projection.
### Direct messages
- Signed-in users can start a DM with another local tribe user from the chat
recipient picker.
- New direct conversations default to the `:nostr_nip17` backend.
- NIP-17 DMs are stored canonically as Parrhesia/Nostr kind `1059` giftwrap
events and decrypted into local UI message structs at read time.
- Sender and recipient copies are published so both sides can read the thread.
- Legacy NIP-04 kind `4` DMs are supported as a read-only import/decrypt path.
Current signing model: Aether uses the existing Tribes session-unlocked Nostr
private key. This produces real NIP-17/NIP-59 and NIP-04 protocol artifacts, but
it is still a server-trusting web model, not browser-only/non-custodial E2EE.
The protocol operations are isolated behind `TribeOne.TribesPlugin.Aether.Chat.Nostr.Nak` so a native
Elixir implementation or NIP-07/NIP-46 signer can replace it later.
### Marmot scaffold
A Marmot backend scaffold and UI route exist for future MLS group chat work, but
browser transport, storage, signing, and message rendering are not active yet.
The package pin is kept at `@internet-privacy/marmot-ts@0.5.1`.
## Plugin contract and API surface
`manifest.json` declares:
```json
{
"id": "org.tribe-one.plugins.aether",
"slug": "aether",
"display_name": "Aether",
"version": "0.2.0",
"entry_module": "TribeOne.TribesPlugin.Aether.Plugin",
"host_api": "1",
"otp_app": "tribe_one_aether",
"provides": ["org.tribe-one.caps.social@1", "org.tribe-one.caps.chat@1"],
"requires": ["org.tribe-one.caps.ui@1"],
"assets": {
"global_js": ["aether.js"],
"global_css": ["aether.css"]
},
"migrations": true
}
```
3. Edit `manifest.json` — set description, capabilities, requirements
4. Implement your plugin in `lib/your_plugin/plugin.ex`
5. Run validation, smoke checks, and tests:
Runtime contributions from `TribeOne.TribesPlugin.Aether.Plugin` include:
```bash
mix deps.get
mix tribes.plugin.validate
scripts/plugin smoke
scripts/plugin test
```
- nav items for Aether and Chat,
- LiveView pages for `/aether` and `/aether/chat`,
- the `TribeOne.TribesPlugin.Aether.Chat` Ash domain,
- plugin config schema for chat backend defaults and Marmot UI enablement,
- global JS/CSS assets.
If you are using the shared `tribes` devenv, run through the local helper instead:
The `org.tribe-one.caps.chat@1` provider surface is intentionally small and reusable:
- `TribeOne.TribesPlugin.Aether.Chat.chat_panel_component/0` returns the reusable chat panel component.
- `TribeOne.TribesPlugin.Aether.Chat.recipient_picker_component/0` returns the recipient picker.
- `TribeOne.TribesPlugin.Aether.Chat.ensure_context_channel/5` creates context-owned chat rooms.
- `TribeOne.TribesPlugin.Aether.Chat.ensure_direct_conversation/4` creates a NIP-17 DM projection.
- `TribeOne.TribesPlugin.Aether.Chat.send_message/3`, `list_conversation_messages/2`, and
`subscribe_conversation/3` dispatch to the selected backend.
- `TribeOne.TribesPlugin.Aether.Chat.embed_path/1`, `standalone_path/1`, and `marmot_path/1` expose
stable route helpers.
Backend modules implement `TribeOne.TribesPlugin.Aether.Chat.Backend`:
- `TribeOne.TribesPlugin.Aether.Chat.Backends.PublicSync`
- `TribeOne.TribesPlugin.Aether.Chat.Backends.NostrNip17`
- `TribeOne.TribesPlugin.Aether.Chat.Backends.NostrNip04ReadOnly`
- `TribeOne.TribesPlugin.Aether.Chat.Backends.Marmot`
These are plugin-local today. If other plugins need the same primitives, the
likely host/plugin API candidates are a stable Nostr event publish/query service
and a stable session/external signer interface.
## Development
Use the plugin-aware commands from the repo devenv shell:
```bash
devenv shell -- plugin validate
devenv shell -- plugin smoke
devenv shell -- plugin test
devenv shell -- plugin precommit
```
## Development
For local development alongside a Tribes checkout:
Outside the devenv shell, use the local wrapper:
```bash
# Symlink into the host plugins directory once
cd /path/to/tribes
ln -s /path/to/your-plugin plugins/your_plugin
# Start Tribes dev server
iex --sname dev -S mix phx.server
```
Then edit the plugin in its own repo. In development, the host now watches
symlinked external plugins and automatically:
- runs `mix compile` for Elixir/HEEx or manifest changes
- runs `npm run build --prefix assets` when `assets/package.json` is present
- reloads the plugin in the running Tribes VM
- triggers a Phoenix browser reload after the rebuild finishes
That means the normal edit/compile loop is:
```bash
cd /path/to/your-plugin
mix deps.get
# in another terminal
cd /path/to/tribes
iex --sname dev -S mix phx.server
```
Inside the plugin repo's devenv shell, the `plugin` helper forwards to the host devenv automatically:
```bash
plugin validate
plugin smoke
plugin test
plugin precommit
```
If your plugin does not need a custom frontend pipeline, you can skip
`assets/package.json` and write browser-ready files directly under `assets/js`
and `assets/css`; the host dev watcher will copy them into `priv/static` for
you in development. The default template uses TypeScript under `assets/ts`.
## Project Structure
```
your_plugin/
├── manifest.json # Plugin metadata (Nix build + runtime)
├── mix.exs # Dependencies
├── config/ # Host-backed test config
├── lib/
│ ├── your_plugin/
│ │ ├── plugin.ex # Tribes.Plugin entry point
│ │ └── application.ex # OTP supervision tree (optional)
│ └── your_plugin_web/
│ └── live/ # LiveView pages
├── assets/ # TS/CSS (one bundle per plugin)
│ ├── ts/ # TypeScript browser entry points
│ ├── package.json # Optional build script used by dev + Guix packaging
│ ├── tsconfig.json # TypeScript compiler settings
│ └── package-lock.json # Optional, recommended for reproducible builds
├── priv/
│ ├── static/ # Built assets for release
│ └── repo/migrations/ # Ecto migrations
├── scripts/
│ ├── plugin # Shared devenv/non-devenv test wrapper
│ └── rename.sh
└── test/
```
## Manifest
`manifest.json` declares your plugin's identity and capabilities:
```json
{
"name": "your_plugin",
"entry_module": "Tribes.Plugins.YourPlugin.Plugin",
"host_api": "1",
"otp_app": "your_plugin",
"provides": ["some_capability@1"],
"requires": ["ui@1"],
"enhances_with": ["inference@1"]
}
```
- **entry_module** — must be `Tribes.Plugins.*.Plugin`
- **otp_app** — required and must match `name`
- **provides** — capabilities this plugin makes available
- **requires** — hard plugin/API contracts beyond the `host_api` foundation
(build fails without them)
- **enhances_with** — optional dependencies (plugin degrades gracefully)
The default template includes `ui@1` because the generated LiveView renders
inside the host chrome through `Tribes.Plugin.Layouts.app`. Keep `ui@1` when a
plugin uses host chrome, imports `Tribes.UI.Components`, or uses `use Tribes.UI`:
```json
"requires": ["ui@1"]
```
`host_api` is the versioned foundation contract. It provides the supported
Phoenix, Ash, PubSub, data, and cluster-event APIs for plugins. Do not add
separate framework capability requirements for APIs that belong to that
foundation.
See the [Plugin System docs](https://github.com/your-org/tribes/blob/master/docs/PLUGINS.md) for the full specification.
The default template assumes the plugin owns a top-level page such as
`/your_plugin` and any views beneath it. Render normal plugin pages with
`Tribes.Plugin.Layouts.app` so they live inside the Tribes chrome/navigation;
only omit it for intentionally unwrapped fullscreen surfaces.
## Plugin Data
If your plugin adds Ash resources that should replicate cluster-wide, use
`extensions: [AshNostrSync]` on those resources and follow the host defaults:
- persisted attributes sync by default unless explicitly excluded
- synced resources should use one UUID primary key by default
- `AshNostrSync` requires exactly one primary key; composite keys are not supported
- synced resources get extension-managed `deleted_at` soft-delete support by default
- destroy actions can use `soft_delete()` when they should tombstone rows
- `soft_delete?(false)` is the opt-out if a synced resource should not use the default tombstone model
Keep resource-specific side effects explicit in actions. `AshNostrSync` owns the
default tombstone/filter/projection behavior, but your plugin still decides which
actions are semantic deletes and what else they should do.
## Testing
The template starts with two host-backed test layers:
- **Contract tests** (`test/your_plugin/plugin_contract_test.exs`) — manifest and runtime spec stay aligned
- **Page tests** (`test/your_plugin/home_page_test.exs`) — the plugin renders through the real host page pipeline
Run them with:
```bash
mix tribes.plugin.validate
scripts/plugin smoke
scripts/plugin validate
scripts/plugin test
scripts/plugin precommit
```
Use `plugin test` / `plugin precommit` for host-backed tests. The helper invokes
Mix with the host database/services and host plugin-manager paths, including
built-in providers such as `tribes_ui`. Raw `mix test` in a plugin checkout is
guarded and prints this guidance; `mix raw_test` / `mix raw_precommit` are
available only for unusual manual debugging when you have set the same host
environment yourself.
Plain `mix test` and `mix precommit` are not the normal entrypoints; this plugin
suite is host-backed and expects the Tribes test environment.
## Building for Release
For local development alongside a Tribes checkout, symlink this repo into the
host plugin directory:
```bash
MIX_ENV=prod mix compile
devenv shell -- npm run build --prefix assets
mkdir -p dist/your_plugin
cp -r _build/prod/lib/your_plugin/ebin dist/your_plugin/
cp -r priv dist/your_plugin/
cp manifest.json dist/your_plugin/
cd /path/to/tribes
ln -s /path/to/tribes-plugin-aether plugins/aether
iex --sname dev -S mix phx.server
```
For Guix-based deployment, package your plugin in the `guix-tribes` channel and
enable it from the node config.
Then edit this repo normally. The host watches symlinked external plugins and
reloads changed Elixir, HEEx, assets, manifests, and migrations.
## Licence
## Assets and hooks
TODO: Choose a licence.
Plugin browser assets are declared in `manifest.json` and served by the host.
Aether currently uses:
- `assets/js/aether.js` for LiveView hooks such as chat auto-scroll,
- `assets/css/aether.css` for plugin-scoped styles.
External plugin hooks are registered through `window.TribesPluginHooks`; Phoenix
colocated hooks from external plugin OTP apps are not auto-imported by the host.
## Runtime requirements
- A Tribes host checkout/runtime with `org.tribe-one.caps.ui@1`.
- Parrhesia available through the host for Nostr event storage and streaming.
- `nak` available in the runtime path for the current NIP-17/NIP-59/NIP-04
protocol implementation.
- Node dependencies under `assets/` for browser asset builds.
## Release packaging
Guix packaging assembles the plugin artifact from the compiled BEAM output,
`priv/`, browser assets, and `manifest.json`. Enable the plugin from the
`guix-tribes` channel-side node configuration.
+1
View File
@@ -0,0 +1 @@
min-release-age=7
@@ -1,13 +1,17 @@
/*
* 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 */
}
.aether-chat-message {
scroll-margin-block-end: 1rem;
}
+28
View File
@@ -0,0 +1,28 @@
// Plugin JavaScript entry point.
//
// 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.
//
// Register plugin LiveView hooks here. External plugin OTP apps are not
// auto-imported by the host's phoenix-colocated bundle.
//
// window.TribesPluginHooks = window.TribesPluginHooks || {};
// window.TribesPluginHooks["AetherHook"] = {
// mounted() {
// console.log("Aether hook mounted");
// }
// };
window.TribesPluginHooks = window.TribesPluginHooks || {};
window.TribesPluginHooks.AetherChatScroll = {
mounted() {
this.scrollToBottom();
},
updated() {
this.scrollToBottom();
},
scrollToBottom() {
this.el.scrollTop = this.el.scrollHeight;
},
};
+460 -14
View File
@@ -1,29 +1,475 @@
{
"name": "my-plugin-assets",
"name": "aether-assets",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "my-plugin-assets",
"name": "aether-assets",
"version": "0.1.0",
"devDependencies": {
"typescript": "^6.0.3"
"dependencies": {
"@internet-privacy/marmot-ts": "0.5.1"
}
},
"node_modules/typescript": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz",
"integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
"node_modules/@hpke/common": {
"version": "1.10.1",
"resolved": "https://registry.npmjs.org/@hpke/common/-/common-1.10.1.tgz",
"integrity": "sha512-moJwhmtLtuxiUzzNp1jpfBfx8yefKoO9D/RCR9dmwrnc7qjJqId1rEtQz+lSlU5cabX8daToMSx/7HayXOiaFw==",
"license": "MIT",
"engines": {
"node": ">=16.0.0"
}
},
"node_modules/@hpke/core": {
"version": "1.9.0",
"resolved": "https://registry.npmjs.org/@hpke/core/-/core-1.9.0.tgz",
"integrity": "sha512-pFxWl1nNJeQCSUFs7+GAblHvXBCjn9EPN65vdKlYQil2aURaRxfGMO6vBKGqm1YHTKwiAxJQNEI70PbSowMP9Q==",
"license": "MIT",
"dependencies": {
"@hpke/common": "^1.10.0"
},
"engines": {
"node": ">=14.17"
"node": ">=16.0.0"
}
},
"node_modules/@internet-privacy/marmot-ts": {
"version": "0.5.1",
"resolved": "https://registry.npmjs.org/@internet-privacy/marmot-ts/-/marmot-ts-0.5.1.tgz",
"integrity": "sha512-AZ9924Dz75CHbB+YJPmEGj09xYIYAHpyDCq1xn++ubQjP95sQuV4yNXX5CmPQNXcyEMd50LL5dhJOMuH8gmPsg==",
"license": "MIT",
"dependencies": {
"@hpke/core": "^1.9.0",
"@noble/ciphers": "^2.1.1",
"@noble/curves": "^2.0.1",
"@noble/hashes": "^2.0.1",
"@scure/base": "^2.0.0",
"applesauce-common": "^5.1.0",
"applesauce-core": "^5.1.0",
"debug": "^4.4.3",
"eventemitter3": "^5.0.4",
"ts-mls": "2.0.0-rc.10"
},
"engines": {
"bun": ">=1.1.0",
"deno": ">=2.0.0",
"node": ">=20.0.0"
}
},
"node_modules/@noble/ciphers": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-2.1.1.tgz",
"integrity": "sha512-bysYuiVfhxNJuldNXlFEitTVdNnYUc+XNJZd7Qm2a5j1vZHgY+fazadNFWFaMK/2vye0JVlxV3gHmC0WDfAOQw==",
"license": "MIT",
"engines": {
"node": ">= 20.19.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@noble/curves": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/@noble/curves/-/curves-2.0.1.tgz",
"integrity": "sha512-vs1Az2OOTBiP4q0pwjW5aF0xp9n4MxVrmkFBxc6EKZc6ddYx5gaZiAsZoq0uRRXWbi3AT/sBqn05eRPtn1JCPw==",
"license": "MIT",
"dependencies": {
"@noble/hashes": "2.0.1"
},
"engines": {
"node": ">= 20.19.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@noble/curves/node_modules/@noble/hashes": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.0.1.tgz",
"integrity": "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==",
"license": "MIT",
"engines": {
"node": ">= 20.19.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@noble/hashes": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.2.0.tgz",
"integrity": "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==",
"license": "MIT",
"engines": {
"node": ">= 20.19.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@scure/base": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@scure/base/-/base-2.2.0.tgz",
"integrity": "sha512-b8XEupJibegiXV+tDUseI8oLQc8ei3d/4Jkb2RpbHh3MfE054ov3uIz2dhFkB3FI8iwYkEh0gGCApkrYggkPNg==",
"license": "MIT",
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@scure/bip32": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/@scure/bip32/-/bip32-1.3.1.tgz",
"integrity": "sha512-osvveYtyzdEVbt3OfwwXFr4P2iVBL5u1Q3q4ONBfDY/UpOuXmOlbgwc1xECEboY8wIays8Yt6onaWMUdUbfl0A==",
"license": "MIT",
"dependencies": {
"@noble/curves": "~1.1.0",
"@noble/hashes": "~1.3.1",
"@scure/base": "~1.1.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@scure/bip32/node_modules/@noble/curves": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.1.0.tgz",
"integrity": "sha512-091oBExgENk/kGj3AZmtBDMpxQPDtxQABR2B9lb1JbVTs6ytdzZNwvhxQ4MWasRNEzlbEH8jCWFCwhF/Obj5AA==",
"license": "MIT",
"dependencies": {
"@noble/hashes": "1.3.1"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@scure/bip32/node_modules/@noble/curves/node_modules/@noble/hashes": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.3.1.tgz",
"integrity": "sha512-EbqwksQwz9xDRGfDST86whPBgM65E0OH/pCgqW0GBVzO22bNE+NuIbeTb714+IfSjU3aRk47EUvXIb5bTsenKA==",
"license": "MIT",
"engines": {
"node": ">= 16"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@scure/bip32/node_modules/@noble/hashes": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.3.3.tgz",
"integrity": "sha512-V7/fPHgl+jsVPXqqeOzT8egNj2iBIVt+ECeMMG8TdcnTikP3oaBtUVqpT/gYCR68aEBJSF+XbYUxStjbFMqIIA==",
"license": "MIT",
"engines": {
"node": ">= 16"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@scure/bip32/node_modules/@scure/base": {
"version": "1.1.9",
"resolved": "https://registry.npmjs.org/@scure/base/-/base-1.1.9.tgz",
"integrity": "sha512-8YKhl8GHiNI/pU2VMaofa2Tor7PJRAjwQLBBuilkJ9L5+13yVbC7JO/wS7piioAvPSwR3JKM1IJ/u4xQzbcXKg==",
"license": "MIT",
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@scure/bip39": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@scure/bip39/-/bip39-1.2.1.tgz",
"integrity": "sha512-Z3/Fsz1yr904dduJD0NpiyRHhRYHdcnyh73FZWiV+/qhWi83wNJ3NWolYqCEN+ZWsUz2TWwajJggcRE9r1zUYg==",
"license": "MIT",
"dependencies": {
"@noble/hashes": "~1.3.0",
"@scure/base": "~1.1.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@scure/bip39/node_modules/@noble/hashes": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.3.3.tgz",
"integrity": "sha512-V7/fPHgl+jsVPXqqeOzT8egNj2iBIVt+ECeMMG8TdcnTikP3oaBtUVqpT/gYCR68aEBJSF+XbYUxStjbFMqIIA==",
"license": "MIT",
"engines": {
"node": ">= 16"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@scure/bip39/node_modules/@scure/base": {
"version": "1.1.9",
"resolved": "https://registry.npmjs.org/@scure/base/-/base-1.1.9.tgz",
"integrity": "sha512-8YKhl8GHiNI/pU2VMaofa2Tor7PJRAjwQLBBuilkJ9L5+13yVbC7JO/wS7piioAvPSwR3JKM1IJ/u4xQzbcXKg==",
"license": "MIT",
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/applesauce-common": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/applesauce-common/-/applesauce-common-5.2.0.tgz",
"integrity": "sha512-6Natb0szkj65OBKK5LBHIszAmVd8ha9GkCZcavJnZbNeWBgoDTO0hfkgI4pk2L6L/OWNceo2XCajvDAx0AjlgQ==",
"license": "MIT",
"dependencies": {
"@scure/base": "^1.2.4",
"applesauce-core": "^5.2.0",
"hash-sum": "^2.0.0",
"light-bolt11-decoder": "^3.2.0",
"rxjs": "^7.8.1"
},
"funding": {
"type": "lightning",
"url": "lightning:nostrudel@geyser.fund"
}
},
"node_modules/applesauce-common/node_modules/@scure/base": {
"version": "1.2.6",
"resolved": "https://registry.npmjs.org/@scure/base/-/base-1.2.6.tgz",
"integrity": "sha512-g/nm5FgUa//MCj1gV09zTJTaM6KBAHqLN907YVQqf7zC49+DcO4B1so4ZX07Ef10Twr6nuqYEH9GEggFXA4Fmg==",
"license": "MIT",
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/applesauce-core": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/applesauce-core/-/applesauce-core-5.2.0.tgz",
"integrity": "sha512-aSuM6q6/Gs2FGUqytlHDjKZpSst2xKaT0vMXUQFWUctECNIxvwy6/hTDDInukMuI9mrQdjnO781ZJJgghI7RNw==",
"license": "MIT",
"dependencies": {
"debug": "^4.4.0",
"fast-deep-equal": "^3.1.3",
"hash-sum": "^2.0.0",
"nanoid": "^5.0.9",
"nostr-tools": "~2.19",
"rxjs": "^7.8.1"
},
"funding": {
"type": "lightning",
"url": "lightning:nostrudel@geyser.fund"
}
},
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/eventemitter3": {
"version": "5.0.4",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz",
"integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==",
"license": "MIT"
},
"node_modules/fast-deep-equal": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
"license": "MIT"
},
"node_modules/hash-sum": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/hash-sum/-/hash-sum-2.0.0.tgz",
"integrity": "sha512-WdZTbAByD+pHfl/g9QSsBIIwy8IT+EsPiKDs0KNX+zSHhdDLFKdZu0BQHljvO+0QI/BasbMSUa8wYNCZTvhslg==",
"license": "MIT"
},
"node_modules/light-bolt11-decoder": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/light-bolt11-decoder/-/light-bolt11-decoder-3.2.0.tgz",
"integrity": "sha512-3QEofgiBOP4Ehs9BI+RkZdXZNtSys0nsJ6fyGeSiAGCBsMwHGUDS/JQlY/sTnWs91A2Nh0S9XXfA8Sy9g6QpuQ==",
"license": "MIT",
"dependencies": {
"@scure/base": "1.1.1"
}
},
"node_modules/light-bolt11-decoder/node_modules/@scure/base": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@scure/base/-/base-1.1.1.tgz",
"integrity": "sha512-ZxOhsSyxYwLJj3pLZCefNitxsj093tb2vq90mp2txoYeBqbcjDjqFhyM8eUjq/uFm6zJ+mUuqxlS2FkuSY1MTA==",
"funding": [
{
"type": "individual",
"url": "https://paulmillr.com/funding/"
}
],
"license": "MIT"
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/nanoid": {
"version": "5.1.11",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.11.tgz",
"integrity": "sha512-v+KEsUv2ps74PaSKv0gHTxTCgMXOIfBEbaqa6w6ISIGC7ZsvHN4N9oJ8d4cmf0n5oTzQz2SLmThbQWhjd/8eKg==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"bin": {
"nanoid": "bin/nanoid.js"
},
"engines": {
"node": "^18 || >=20"
}
},
"node_modules/nostr-tools": {
"version": "2.19.4",
"resolved": "https://registry.npmjs.org/nostr-tools/-/nostr-tools-2.19.4.tgz",
"integrity": "sha512-qVLfoTpZegNYRJo5j+Oi6RPu0AwLP6jcvzcB3ySMnIT5DrAGNXfs5HNBspB/2HiGfH3GY+v6yXkTtcKSBQZwSg==",
"license": "Unlicense",
"dependencies": {
"@noble/ciphers": "^0.5.1",
"@noble/curves": "1.2.0",
"@noble/hashes": "1.3.1",
"@scure/base": "1.1.1",
"@scure/bip32": "1.3.1",
"@scure/bip39": "1.2.1",
"nostr-wasm": "0.1.0"
},
"peerDependencies": {
"typescript": ">=5.0.0"
},
"peerDependenciesMeta": {
"typescript": {
"optional": true
}
}
},
"node_modules/nostr-tools/node_modules/@noble/ciphers": {
"version": "0.5.3",
"resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-0.5.3.tgz",
"integrity": "sha512-B0+6IIHiqEs3BPMT0hcRmHvEj2QHOLu+uwt+tqDDeVd0oyVzh7BPrDcPjRnV1PV/5LaknXJJQvOuRGR0zQJz+w==",
"license": "MIT",
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/nostr-tools/node_modules/@noble/curves": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.2.0.tgz",
"integrity": "sha512-oYclrNgRaM9SsBUBVbb8M6DTV7ZHRTKugureoYEncY5c65HOmRzvSiTE3y5CYaPYJA/GVkrhXEoF0M3Ya9PMnw==",
"license": "MIT",
"dependencies": {
"@noble/hashes": "1.3.2"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/nostr-tools/node_modules/@noble/curves/node_modules/@noble/hashes": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.3.2.tgz",
"integrity": "sha512-MVC8EAQp7MvEcm30KWENFjgR+Mkmf+D189XJTkFIlwohU5hcBbn1ZkKq7KVTi2Hme3PMGF390DaL52beVrIihQ==",
"license": "MIT",
"engines": {
"node": ">= 16"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/nostr-tools/node_modules/@noble/hashes": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.3.1.tgz",
"integrity": "sha512-EbqwksQwz9xDRGfDST86whPBgM65E0OH/pCgqW0GBVzO22bNE+NuIbeTb714+IfSjU3aRk47EUvXIb5bTsenKA==",
"license": "MIT",
"engines": {
"node": ">= 16"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/nostr-tools/node_modules/@scure/base": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@scure/base/-/base-1.1.1.tgz",
"integrity": "sha512-ZxOhsSyxYwLJj3pLZCefNitxsj093tb2vq90mp2txoYeBqbcjDjqFhyM8eUjq/uFm6zJ+mUuqxlS2FkuSY1MTA==",
"funding": [
{
"type": "individual",
"url": "https://paulmillr.com/funding/"
}
],
"license": "MIT"
},
"node_modules/nostr-wasm": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/nostr-wasm/-/nostr-wasm-0.1.0.tgz",
"integrity": "sha512-78BTryCLcLYv96ONU8Ws3Q1JzjlAt+43pWQhIl86xZmWeegYCNLPml7yQ+gG3vR6V5h4XGj+TxO+SS5dsThQIA==",
"license": "MIT"
},
"node_modules/rxjs": {
"version": "7.8.2",
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz",
"integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==",
"license": "Apache-2.0",
"dependencies": {
"tslib": "^2.1.0"
}
},
"node_modules/ts-mls": {
"version": "2.0.0-rc.10",
"resolved": "https://registry.npmjs.org/ts-mls/-/ts-mls-2.0.0-rc.10.tgz",
"integrity": "sha512-4FFbkysQkJlVaUv4fs7ZC4wuiwTOCgX/bEMo2fFGngy/QofC/lvWWgsScKuGxEIhEf4e2Q8APfJ7DknP0VRCoA==",
"license": "MIT",
"dependencies": {
"@hpke/core": "1.9.0"
},
"peerDependencies": {
"@hpke/chacha20poly1305": "1.8.0",
"@hpke/dhkem-x448": "1.8.0",
"@hpke/hybridkem-x-wing": "0.7.0",
"@hpke/ml-kem": "0.3.0",
"@noble/ciphers": "2.1.1",
"@noble/curves": "2.0.1",
"@noble/post-quantum": "0.5.2"
},
"peerDependenciesMeta": {
"@hpke/chacha20poly1305": {
"optional": true
},
"@hpke/dhkem-x448": {
"optional": true
},
"@hpke/hybridkem-x-wing": {
"optional": true
},
"@hpke/ml-kem": {
"optional": true
},
"@noble/curves": {
"optional": true
},
"@noble/post-quantum": {
"optional": true
}
}
},
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD"
}
}
}
+4 -5
View File
@@ -1,12 +1,11 @@
{
"name": "my-plugin-assets",
"name": "aether-assets",
"private": true,
"version": "0.1.0",
"scripts": {
"build": "npm run check && mkdir -p ../priv/static && cp -r css/. ../priv/static && tsc --project tsconfig.json",
"check": "tsc --project tsconfig.json --noEmit"
"build": "mkdir -p ../priv/static && cp -r css/. ../priv/static && cp -r js/. ../priv/static"
},
"devDependencies": {
"typescript": "^6.0.3"
"dependencies": {
"@internet-privacy/marmot-ts": "0.5.1"
}
}
-17
View File
@@ -1,17 +0,0 @@
// Plugin TypeScript entry point.
//
// This file is compiled to /priv/static/my_plugin.js, served by the host at
// /plugins-assets/my_plugin/my_plugin.js, and included when declared in
// manifest.json assets.global_js.
declare global {
interface Window {
TribesPluginHooks?: Record<string, unknown>;
}
}
window.TribesPluginHooks = window.TribesPluginHooks ?? {};
console.info("my_plugin loaded");
export {};
-14
View File
@@ -1,14 +0,0 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ES2022",
"moduleResolution": "Bundler",
"strict": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"skipLibCheck": true,
"outDir": "../priv/static",
"rootDir": "ts"
},
"include": ["ts/**/*.ts"]
}
+2
View File
@@ -1,3 +1,5 @@
import Config
config :tribe_one_aether, ash_domains: [TribeOne.TribesPlugin.Aether.Chat]
import_config "#{config_env()}.exs"
+4
View File
@@ -1 +1,5 @@
import Config
# The host is a compile-only dependency in dev. Suppress host Ash domain
# config inclusion warnings while compiling host modules as a dependency.
config :ash, :validate_domain_config_inclusion?, false
+34 -15
View File
@@ -3,10 +3,11 @@
"devenv": {
"locked": {
"dir": "src/modules",
"lastModified": 1777109791,
"lastModified": 1779566536,
"narHash": "sha256-KlMZMcgyU0BFUW2JCrCb9IHJMgCdG0eyhukpu9pwXLs=",
"owner": "cachix",
"repo": "devenv",
"rev": "025b6ba9903b96a55ac21a9a63fa290a6da5afe6",
"rev": "6715fab7a826d108278fbfd6ef35a61f49ab7ac9",
"type": "github"
},
"original": {
@@ -20,6 +21,7 @@
"flake": false,
"locked": {
"lastModified": 1767039857,
"narHash": "sha256-vNpUSpF5Nuw8xvDLj2KCwwksIbjua2LZCqhV1LNRDns=",
"owner": "NixOS",
"repo": "flake-compat",
"rev": "5edf11c44bc78a0d334f6334cdaf7d60d732daab",
@@ -40,10 +42,11 @@
]
},
"locked": {
"lastModified": 1776796298,
"lastModified": 1778507602,
"narHash": "sha256-kTwur1wV+01SdqskVMSo6JMEpg71ps3HpbFY2GsflKs=",
"owner": "cachix",
"repo": "git-hooks.nix",
"rev": "3cfd774b0a530725a077e17354fbdb87ea1c4aad",
"rev": "61ab0e80d9c7ab14c256b5b453d8b3fb0189ba0a",
"type": "github"
},
"original": {
@@ -60,10 +63,11 @@
]
},
"locked": {
"lastModified": 1762808025,
"lastModified": 1709087332,
"narHash": "sha256-HG2cCnktfHsKV0s4XW83gU3F57gaTljL9KNSuG6bnQs=",
"owner": "hercules-ci",
"repo": "gitignore.nix",
"rev": "cb5e3fdca1de58ccbc3ef53de65bd372b48f567c",
"rev": "637db329424fd7e46cf4185293b9cc8c88c95394",
"type": "github"
},
"original": {
@@ -77,10 +81,11 @@
"nixpkgs-src": "nixpkgs-src"
},
"locked": {
"lastModified": 1776852779,
"lastModified": 1778507786,
"narHash": "sha256-HzSQCKMsMr8r55LwM1JuzIOB+8bzk0FEv6sItKvsfoY=",
"owner": "cachix",
"repo": "devenv-nixpkgs",
"rev": "ec3063523dcd911aeadb50faa589f237cdab5853",
"rev": "8f24a228a782e24576b155d1e39f0d914b380691",
"type": "github"
},
"original": {
@@ -93,11 +98,11 @@
"nixpkgs-src": {
"flake": false,
"locked": {
"lastModified": 1776329215,
"narHash": "sha256-a8BYi3mzoJ/AcJP8UldOx8emoPRLeWqALZWu4ZvjPXw=",
"lastModified": 1778274207,
"narHash": "sha256-I4puXmX1iovcCHZlRmztO3vW0mAbbRvq4F8wgIMQ1MM=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "b86751bc4085f48661017fa226dee99fab6c651b",
"rev": "b3da656039dc7a6240f27b2ef8cc6a3ef3bccae7",
"type": "github"
},
"original": {
@@ -107,17 +112,31 @@
"type": "github"
}
},
"nixpkgs-unstable": {
"locked": {
"lastModified": 1779508470,
"narHash": "sha256-Ap9KJX+5xHIn3bPIpfNgT6MEXdAECECwo4/rmlQD74M=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "29916453413845e54a65b8a1cf996842300cd299",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"devenv": "devenv",
"git-hooks": "git-hooks",
"nixpkgs": "nixpkgs",
"pre-commit-hooks": [
"git-hooks"
]
"nixpkgs-unstable": "nixpkgs-unstable"
}
}
},
"root": "root",
"version": 7
}
}
+58 -8
View File
@@ -1,23 +1,69 @@
{
pkgs,
lib,
inputs,
...
}: {
env = {
MIX_OS_DEPS_COMPILE_PARTITION_COUNT = 8;
NODE_ENV = "development";
};
}: let
system = pkgs.stdenv.system;
pkgs-unstable = inputs.nixpkgs-unstable.legacyPackages.${system};
hasGlibcLocales = pkgs.stdenv.hostPlatform.isLinux && pkgs.stdenv.hostPlatform.isGnu;
devLocales =
if hasGlibcLocales
then
pkgs.glibcLocales.override {
allLocales = false;
locales = [
"en_GB.UTF-8/UTF-8"
"en_US.UTF-8/UTF-8"
];
}
else null;
in {
# https://devenv.sh/basics/
env =
{
LANG = "en_GB.UTF-8";
# Parallel deps compilation
MIX_OS_DEPS_COMPILE_PARTITION_COUNT = 8;
# Enable JS/LV debugging (required?)
NODE_ENV = "development";
# Delay npm dependency resolution to reduce rushed supply-chain updates.
NPM_CONFIG_MIN_RELEASE_AGE = "7";
}
// lib.optionalAttrs hasGlibcLocales {
LOCALE_ARCHIVE = "${devLocales}/lib/locale/locale-archive";
};
# https://devenv.sh/packages/
packages = with pkgs;
[
git
alejandra
# linter and formatter for JavaScript, TypeScript, JSX, CSS and GraphQL
prettier
# Nix code formatter
alejandra
# Nostr tooling
pkgs-unstable.nak
pkgs-unstable.algia
(pkgs-unstable.callPackage ./nix/nosdump.nix {})
]
++ lib.optionals pkgs.stdenv.isLinux [
++
# Linux only
lib.optionals pkgs.stdenv.isLinux [
# for ExUnit notifier
libnotify
# for package - file_system
inotify-tools
];
# https://devenv.sh/tests/
# enterTest = ''
# echo "Running tests"
# git --version | grep "2.42.0"
# '';
# https://devenv.sh/languages/
languages = {
elixir = {
enable = true;
@@ -31,9 +77,10 @@
};
};
dotenv.enable = true;
# dotenv.enable = true;
devenv.warnOnNewVersion = false;
# https://devenv.sh/pre-commit-hooks/
git-hooks.hooks = {
alejandra.enable = true;
prettier.enable = true;
@@ -46,6 +93,7 @@
mix-format.files = "\\.(ex|exs|heex)$";
};
# https://devenv.sh/scripts/
enterShell = ''
echo
elixir --version
@@ -57,4 +105,6 @@
scripts = {
plugin.exec = ''bash "$DEVENV_ROOT/scripts/plugin" "$@"'';
};
# See full reference at https://devenv.sh/reference/options/
}
+14 -3
View File
@@ -2,10 +2,21 @@
inputs:
nixpkgs:
url: github:cachix/devenv-nixpkgs/rolling
git-hooks:
url: github:cachix/git-hooks.nix
inputs:
nixpkgs:
follows: nixpkgs
nixpkgs-unstable:
url: github:NixOS/nixpkgs/nixos-unstable
# If you're using non-OSS software, you can set allowUnfree to true.
# allowUnfree: true
# If you have more than one devenv you can merge them.
# imports:
# - ./backend
# If you're willing to use a package that's vulnerable
# permittedInsecurePackages:
# - "openssl-1.1.1w"
# If you have more than one devenv you can merge them
#imports:
# - ./backend
-19
View File
@@ -1,19 +0,0 @@
# Checklist
## After Renaming
- Run `./scripts/rename.sh your_plugin YourPlugin`.
- Confirm `manifest.json` `name`, `otp_app`, and `entry_module`.
- Confirm the bridge module path is `lib/tribes/plugins/your_plugin/plugin.ex`.
- Rename asset files and update manifest asset names if needed.
- Run `mix deps.get`.
- Run `scripts/plugin smoke`.
- Run `scripts/plugin test`.
## Before Committing
- Run `mix format`.
- Run `scripts/plugin precommit`.
- If assets changed, run `devenv shell -- npm run build --prefix assets`.
- Check `git status --short` for generated files that should not be committed.
- Commit with a semantic subject and a useful body unless the patch is minimal.
-53
View File
@@ -1,53 +0,0 @@
# Development
## Local Setup
This template expects a sibling Tribes checkout at `../tribes`.
```bash
mix deps.get
devenv shell -- plugin test
```
To load the plugin in the host during development:
```bash
cd ../tribes
ln -s ../tribes-plugin-template plugins/my_plugin
iex --sname dev -S mix phx.server
```
Set `TRIBES_HOST_ROOT=/path/to/tribes` if your host checkout is not a sibling.
## Command Wrapper
Use `scripts/plugin` for host-aware workflows:
```bash
scripts/plugin validate
scripts/plugin test
scripts/plugin precommit
scripts/plugin smoke
```
Inside the template devenv shell, the `plugin` command is available and forwards
through the same wrapper.
Prefer `plugin test` over raw `mix test` for host-backed plugin suites. The
wrapper runs Mix with the host database/services and points the host plugin
manager at the host manifest plus built-in providers such as `tribes_ui`. Use
`mix raw_test` / `mix raw_precommit` only for deliberate low-level debugging.
## Assets
Browser code lives in `assets/ts` and is compiled to `priv/static`:
```bash
devenv shell -- npm run build --prefix assets
```
Use the wrapper form for installs too:
```bash
devenv shell -- npm install --prefix assets
```
+144
View File
@@ -0,0 +1,144 @@
# Feature Plan: Aether Chat
## Decision: manifest capability
Use `chat@1`, not `group_chat@1`.
Rationale: the first shipped feature is group chat, but the capability should be able to grow to DMs, stream-adjacent chats, support rooms, or other conversation types later. The `chat@1` contract should therefore mean "conversation provider and reusable chat UI". The provider can advertise supported conversation kinds; initially Aether supports public group-style channels and later Marmot-backed channels.
Backward-compatible additions, such as optional DM support, can stay under `chat@1` as long as existing consumers keep working. Breaking provider API changes should become `chat@2`.
## Goals
- Add a standalone Aether chat experience.
- Provide an embeddable chat panel that Sender can mount next to a live video stream.
- Support two backends:
- `public_sync`: plaintext messages stored in plugin-owned synced Ash resources.
- `marmot`: Marmot/Nostr-backed encrypted transport using `@internet-privacy/marmot-ts@0.5.1`, without claiming true E2EE while the current web UI/server trust model is in place.
- Build a high-quality Telegram-like UI that can support media later.
## Non-goals for the first slice
- Full true E2EE claims.
- Media upload/storage.
- DMs.
- Sender integration before Aether exposes a stable provider surface.
## Capability contract sketch
Aether should provide:
```json
"provides": ["social@1", "chat@1"]
```
Initial `chat@1` provider responsibilities:
- ensure or find a channel for a context, e.g. `{provider: "sender", type: "stream", id: stream_id}`
- expose supported conversation kinds/backends
- render standalone chat routes
- provide an embeddable compact panel for consumers
Initial supported kinds:
- `:group`
- `:context_group`, for stream/sidebar-style group channels
Future optional kinds:
- `:dm`
- `:multi_dm`
- `:support_room`
## Data model: public_sync backend
Plugin-owned Ash domain/resources:
- `TribeOne.TribesPlugin.Aether.Chat.Channel`
- `id`
- `slug`
- `title`
- `description`
- `backend`: `:public_sync | :marmot`
- `conversation_kind`: initially `:group | :context_group`
- context fields: `context_provider`, `context_type`, `context_id`
- `metadata`
- timestamps
- `TribeOne.TribesPlugin.Aether.Chat.Message`
- `id`
- `channel_id`
- `author_id`
- `author_pubkey`
- `body`
- `client_message_id`
- `metadata`
- timestamps
Both public resources should be added deliberately to Aether's `ash_domains` and `AshNostrSync` so public chat replicates across cluster nodes.
## UI plan
One reusable chat surface with modes:
- standalone page mode
- compact embedded mode for Sender
MVP UI:
- message stream with stable DOM IDs
- sticky composer
- multiline textarea
- send button
- sender display label
- timestamp display
- empty state
- signed-out state
- compact mode support
- auto-scroll hook later
Media-ready UI affordances can be added later without implementing uploads now.
## Marmot plan
Use `@internet-privacy/marmot-ts@0.5.1` if/when implementing the Marmot backend.
Marmot backend constraints:
- document as encrypted/Marmot-backed, not true E2EE yet
- keep behind a feature flag or explicit backend choice until stable
- use browser local storage for Marmot state if using `marmot-ts`
- integrate with Parrhesia relay interfaces
- decide signing bridge/key handling before productizing
## Phases
1. Add plan, capability, public Ash resources, migrations, and standalone public chat MVP.
2. Extract/shape embeddable panel API and tests.
3. Integrate Sender stream pages with Aether chat provider.
4. Add Marmot spike using `marmot-ts@0.5.1`.
5. Productize Marmot backend behind explicit configuration.
6. Add media support later.
## Validation
For Aether work:
```sh
scripts/plugin validate
scripts/plugin test
scripts/plugin precommit
```
For host/plugin API changes:
```sh
cd ../tribes
devenv shell -- mix test
```
For Sender integration:
```sh
cd ../tribes-plugin-sender
scripts/plugin test
```
-62
View File
@@ -1,62 +0,0 @@
# Plugin Contract
## Manifest
`manifest.json` is the runtime contract consumed by Tribes:
- `name` and `otp_app` must match the Mix application name.
- `entry_module` must be a loadable `Tribes.Plugins.*.Plugin` module.
- `provides` declares capabilities exported by the plugin.
- `requires` declares hard plugin/API contracts beyond the `host_api` foundation.
- `enhances_with` declares optional host capabilities.
- `migrations` should be `true` when `priv/repo/migrations` contains plugin migrations.
- `children` should be `true` when the plugin starts its own supervision tree.
Declare `ui@1` in `requires` when rendering through `Tribes.Plugin.Layouts.app`,
importing `Tribes.UI.Components`, or using `use Tribes.UI`. The facade is
intentionally provider-backed, so host chrome and UI consumers must make the
runtime dependency explicit.
`host_api` is the versioned foundation contract. It provides the supported
Phoenix, Ash, PubSub, data, and cluster-event APIs for plugins. Do not add
separate framework capability requirements for APIs that belong to that
foundation.
Default LiveView pages should wrap their content in `Tribes.Plugin.Layouts.app`
and use `on_mount({Tribes.Plugin.LiveUserAuth, :live_user_optional})`. This
keeps plugin pages inside the replaceable Tribes chrome while avoiding a hard
compile-time dependency on host web macros.
## Entry Modules
The template uses a thin host bridge:
```elixir
defmodule Tribes.Plugins.MyPlugin.Plugin do
defdelegate register(context), to: MyPlugin.Plugin
end
```
Keep plugin implementation code in `MyPlugin.Plugin`. Keep the bridge stable so
the host plugin manager can load it from the plugin build output.
## Host Dependencies
`tribes_plugin_api` is the release-facing `host_api` foundation. It carries the
supported plugin behaviours, helpers, Phoenix/Ash data surface, and sync DSL.
Do not add the full `:tribes` app as a production dependency.
The template intentionally splits the `:tribes` path dependency by environment:
- In `:dev`, `:tribes` is compile-only. This lets `mix compile` create the
entry-module beam expected by the host plugin manager without starting a
nested Tribes application.
- In `:test`, `:tribes` is runtime-enabled. Host-backed tests need the real
repository, endpoint pipeline, and plugin contract helpers.
## Ash Resources
For cluster-synced plugin data, add Ash resources under the plugin namespace and
use `extensions: [AshNostrSync]` only for data that should replicate across the
cluster. Prefer one UUID primary key per synced resource. Document any local-only
tables, retention policies, or side effects in plugin docs.
+24
View File
@@ -0,0 +1,24 @@
(list
(channel
(name 'guix)
(url "https://git.teralink.net/tribes/guix-fork.git")
(branch "refactor/substituter-trace-framing")
;; guix-fork refactor/substituter-trace-framing
(commit
"83b0e7d44546968002fb0c0043004da4e9bedc0d")
(introduction
(make-channel-introduction
"093f27dde01cdbda68f2ec4b81e5a34ae180aab9"
(openpgp-fingerprint
"6688 9153 C51C 4613 A493 A525 2F0D FD14 EF99 DAC3"))))
(channel
(name 'tribes)
(url "https://git.teralink.net/tribes/guix-tribes.git")
(branch "master")
(commit
"99789706834d678fb37f2c6a972b78803d2a2cf2")
(introduction
(make-channel-introduction
"607c69a5c1662acca07ad72c3e18646c73500856"
(openpgp-fingerprint
"6688 9153 C51C 4613 A493 A525 2F0D FD14 EF99 DAC3")))))
Executable
+26
View File
@@ -0,0 +1,26 @@
#!/usr/bin/env bash
set -euo pipefail
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
root="$(cd "$script_dir/.." && pwd)"
if [ ! -f "$root/manifest.scm" ]; then
root="$(git rev-parse --show-toplevel 2>/dev/null || pwd)"
fi
channels="$root/guix/channels.scm"
manifest="$root/manifest.scm"
if [ ! -f "$channels" ] || [ ! -f "$manifest" ]; then
echo "guix-dev: expected guix/channels.scm and manifest.scm in the project root" >&2
exit 1
fi
if [ -f "$root/.env" ]; then
set -a
. "$root/.env"
set +a
fi
exec guix time-machine -C "$channels" -- \
shell -m "$manifest" "$@"
@@ -1,4 +1,4 @@
defmodule MyPlugin.Application do
defmodule TribeOne.TribesPlugin.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, []}
# {TribeOne.TribesPlugin.Aether.Worker, []}
]
opts = [strategy: :one_for_one, name: MyPlugin.Supervisor]
opts = [strategy: :one_for_one, name: TribeOne.TribesPlugin.Aether.Supervisor]
Supervisor.start_link(children, opts)
end
end
+352
View File
@@ -0,0 +1,352 @@
defmodule TribeOne.TribesPlugin.Aether.Chat do
@moduledoc """
Chat domain and provider facade for TribeOne.TribesPlugin.Aether.
"""
use Ash.Domain,
otp_app: :tribe_one_aether
require Ash.Query
alias TribeOne.TribesPlugin.Aether.Chat.{Channel, Message, Participant}
@default_channel_slug "general"
@default_channel_title "General Chat"
@pubsub Tribes.PubSub
resources do
resource Channel do
define(:create_channel, action: :create)
define(:get_channel, action: :by_id, args: [:id])
define(:get_channel_by_slug, action: :by_slug, args: [:slug])
define(:list_channels, action: :read)
end
resource Message do
define(:create_message, action: :create)
define(:get_message, action: :by_id, args: [:id])
define(:list_all_messages, action: :read)
end
resource Participant do
define(:create_participant, action: :create)
define(:get_participant, action: :by_channel_pubkey, args: [:channel_id, :pubkey])
define(:list_participants, action: :read)
define(:list_participants_by_pubkey, action: :by_pubkey, args: [:pubkey])
end
end
def backend_module(:public_sync), do: TribeOne.TribesPlugin.Aether.Chat.Backends.PublicSync
def backend_module(:nostr_nip17), do: TribeOne.TribesPlugin.Aether.Chat.Backends.NostrNip17
def backend_module(:nostr_nip04),
do: TribeOne.TribesPlugin.Aether.Chat.Backends.NostrNip04ReadOnly
def backend_module(:marmot), do: TribeOne.TribesPlugin.Aether.Chat.Backends.Marmot
def backend_module(_backend), do: TribeOne.TribesPlugin.Aether.Chat.Backends.PublicSync
def backend_capabilities(backend) do
module = backend_module(backend)
module.capabilities()
end
def supported_conversation_kinds, do: [:group, :context_group, :dm, :legacy_dm, :marmot_group]
def supported_backends, do: [:public_sync, :nostr_nip17, :nostr_nip04, :marmot]
def chat_panel_component, do: TribeOne.TribesPlugin.AetherWeb.ChatPanelComponent
def recipient_picker_component, do: TribeOne.TribesPlugin.AetherWeb.ChatRecipientPickerComponent
def default_channel_slug, do: @default_channel_slug
def standalone_path(%Channel{slug: slug}), do: standalone_path(slug)
def standalone_path(slug) when is_binary(slug), do: "/aether/chat/" <> slug
def embed_path(%Channel{slug: slug}), do: embed_path(slug)
def embed_path(slug) when is_binary(slug), do: "/aether/chat/embed/" <> slug
def marmot_path(%Channel{slug: slug}), do: marmot_path(slug)
def marmot_path(slug) when is_binary(slug), do: "/aether/chat/marmot/" <> slug
def ensure_context_channel(provider, type, id, attrs \\ %{}, opts \\ [])
when is_binary(provider) and is_binary(type) and is_binary(id) and is_map(attrs) do
attrs
|> Map.merge(%{
context_provider: provider,
context_type: type,
context_id: id
})
|> ensure_conversation(opts)
end
def ensure_direct_conversation(sender, recipient, attrs \\ %{}, opts \\ [])
def ensure_direct_conversation(sender_pubkey, recipient_pubkey, attrs, opts)
when is_binary(sender_pubkey) and is_binary(recipient_pubkey) and is_map(attrs) do
participants = Enum.sort([sender_pubkey, recipient_pubkey])
attrs =
attrs
|> Map.update(:metadata, %{"participants" => participants}, fn metadata ->
Map.put(metadata || %{}, "participants", participants)
end)
|> Map.put_new(:slug, direct_slug(sender_pubkey, recipient_pubkey))
|> Map.put_new(:title, "Direct Message")
|> Map.put_new(:conversation_kind, :dm)
|> Map.put_new(:backend, :nostr_nip17)
with {:ok, %Channel{} = channel} <- ensure_conversation(attrs, opts),
{:ok, _sender} <- ensure_participant(channel, sender_pubkey, %{role: :member}, opts),
{:ok, _recipient} <-
ensure_participant(channel, recipient_pubkey, %{role: :member}, opts) do
{:ok, channel}
end
end
def direct_slug(pubkey_a, pubkey_b) when is_binary(pubkey_a) and is_binary(pubkey_b) do
digest =
[pubkey_a, pubkey_b]
|> Enum.sort()
|> Enum.join(":")
|> then(&:crypto.hash(:sha256, &1))
|> Base.encode16(case: :lower)
"dm-" <> digest
end
def ash_opts(context \\ nil) do
[
authorize?: false,
context: %{
private: %{
system?: true,
system_purpose: :aether_chat,
plugin_management_context: context
}
}
]
end
def ensure_conversation(attrs \\ %{}, opts \\ []) when is_map(attrs) do
backend = attrs |> atomize_known_keys() |> Map.get(:backend, :public_sync)
backend_module(backend).ensure_conversation(attrs, opts)
end
def ensure_channel(attrs \\ %{}, opts \\ []) when is_map(attrs) do
attrs = normalize_channel_attrs(attrs)
ash_opts = Keyword.get(opts, :ash_opts, ash_opts())
case get_existing_channel(attrs.slug, ash_opts) do
{:ok, nil} -> create_channel(attrs, ash_opts)
{:ok, %Channel{} = channel} -> {:ok, channel}
{:error, _reason} = error -> error
end
end
defp get_existing_channel(slug, ash_opts) do
Channel
|> Ash.Query.filter(slug == ^slug)
|> Ash.Query.limit(1)
|> Ash.read_one(ash_opts)
end
defp get_existing_participant(channel_id, pubkey, ash_opts) do
Participant
|> Ash.Query.filter(channel_id == ^channel_id and pubkey == ^pubkey)
|> Ash.Query.limit(1)
|> Ash.read_one(ash_opts)
end
def ensure_participant(%Channel{} = channel, pubkey, attrs \\ %{}, opts \\ [])
when is_binary(pubkey) and is_map(attrs) do
attrs = atomize_known_keys(attrs)
ash_opts = Keyword.get(opts, :ash_opts, ash_opts())
case get_existing_participant(channel.id, pubkey, ash_opts) do
{:ok, nil} ->
attrs
|> Map.merge(%{channel_id: channel.id, pubkey: pubkey})
|> Map.put_new(:role, :member)
|> Map.put_new(:metadata, %{})
|> create_participant(ash_opts)
{:ok, %Participant{} = participant} ->
{:ok, participant}
{:error, _reason} = error ->
error
end
end
def list_conversation_messages(%Channel{backend: backend} = channel, opts \\ []) do
backend_module(backend).list_messages(channel, opts)
end
def list_messages(channel_or_id, opts \\ []) do
channel_id = channel_id(channel_or_id)
limit = Keyword.get(opts, :limit, 100)
ash_opts = Keyword.get(opts, :ash_opts, ash_opts())
result =
Message
|> Ash.Query.filter(channel_id == ^channel_id)
|> Ash.Query.sort(inserted_at: :desc, id: :desc)
|> Ash.Query.limit(limit)
|> Ash.read(ash_opts)
case result do
{:ok, messages} -> {:ok, Enum.reverse(messages)}
{:error, _reason} = error -> error
end
end
def send_message(%Channel{backend: backend} = channel, attrs, opts \\ []) when is_map(attrs) do
backend_module(backend).send_message(channel, attrs, opts)
end
def post_message(%Channel{} = channel, attrs, opts \\ []) when is_map(attrs) do
ash_opts = Keyword.get(opts, :ash_opts, ash_opts())
attrs
|> normalize_message_attrs(channel)
|> create_message(ash_opts)
|> maybe_broadcast_message()
end
def subscribe_conversation(%Channel{backend: backend} = channel, pid \\ self(), opts \\ []) do
backend_module(backend).subscribe(channel, pid, opts)
end
def subscribe_channel(%Channel{} = channel), do: subscribe_channel(channel.id)
def subscribe_channel(channel_id) when is_binary(channel_id) do
if pubsub_started?() do
Phoenix.PubSub.subscribe(@pubsub, topic(channel_id))
else
{:error, :pubsub_not_started}
end
end
def topic(channel_id) when is_binary(channel_id), do: "aether:chat:" <> channel_id
defp maybe_broadcast_message({:ok, %Message{} = message}) do
broadcast_message(message)
{:ok, message}
end
defp maybe_broadcast_message({:error, _reason} = error), do: error
defp broadcast_message(%Message{} = message) do
if pubsub_started?() do
Phoenix.PubSub.broadcast_from(
@pubsub,
self(),
topic(message.channel_id),
{:aether_chat, :message, message}
)
end
end
defp pubsub_started?, do: Process.whereis(@pubsub) != nil
defp normalize_channel_attrs(attrs) do
attrs = atomize_known_keys(attrs)
title = attrs[:title] || @default_channel_title
slug = attrs[:slug] || context_slug(attrs) || slugify(title) || @default_channel_slug
%{
slug: slug,
title: title,
description: attrs[:description],
backend: attrs[:backend] || :public_sync,
conversation_kind: attrs[:conversation_kind] || default_conversation_kind(attrs),
context_provider: attrs[:context_provider],
context_type: attrs[:context_type],
context_id: attrs[:context_id],
metadata: attrs[:metadata] || %{}
}
end
defp normalize_message_attrs(attrs, %Channel{} = channel) do
attrs = atomize_known_keys(attrs)
%{
channel_id: channel.id,
author_id: attrs[:author_id],
author_pubkey: attrs[:author_pubkey],
body: String.trim(attrs[:body] || ""),
client_message_id: attrs[:client_message_id],
metadata: attrs[:metadata] || %{}
}
end
defp atomize_known_keys(attrs) do
Enum.reduce(attrs, %{}, fn
{key, value}, acc when is_atom(key) -> Map.put(acc, key, value)
{key, value}, acc when is_binary(key) -> put_known_key(acc, key, value)
end)
end
defp put_known_key(acc, key, value) do
case key do
"slug" -> Map.put(acc, :slug, value)
"title" -> Map.put(acc, :title, value)
"description" -> Map.put(acc, :description, value)
"backend" -> Map.put(acc, :backend, parse_atom(value))
"conversation_kind" -> Map.put(acc, :conversation_kind, parse_atom(value))
"context_provider" -> Map.put(acc, :context_provider, value)
"context_type" -> Map.put(acc, :context_type, value)
"context_id" -> Map.put(acc, :context_id, value)
"metadata" -> Map.put(acc, :metadata, value)
"pubkey" -> Map.put(acc, :pubkey, value)
"user_id" -> Map.put(acc, :user_id, value)
"display_name" -> Map.put(acc, :display_name, value)
"role" -> Map.put(acc, :role, parse_atom(value))
"author_id" -> Map.put(acc, :author_id, value)
"author_pubkey" -> Map.put(acc, :author_pubkey, value)
"body" -> Map.put(acc, :body, value)
"client_message_id" -> Map.put(acc, :client_message_id, value)
_other -> acc
end
end
defp parse_atom(value) when is_atom(value), do: value
defp parse_atom("public_sync"), do: :public_sync
defp parse_atom("nostr_nip17"), do: :nostr_nip17
defp parse_atom("nostr_nip04"), do: :nostr_nip04
defp parse_atom("marmot"), do: :marmot
defp parse_atom("group"), do: :group
defp parse_atom("context_group"), do: :context_group
defp parse_atom("dm"), do: :dm
defp parse_atom("legacy_dm"), do: :legacy_dm
defp parse_atom("marmot_group"), do: :marmot_group
defp parse_atom("owner"), do: :owner
defp parse_atom("member"), do: :member
defp parse_atom(_value), do: nil
defp context_slug(%{context_provider: provider, context_type: type, context_id: id})
when is_binary(provider) and is_binary(type) and is_binary(id) do
Enum.map_join([provider, type, id], "-", &slugify/1)
end
defp context_slug(_attrs), do: nil
defp default_conversation_kind(%{context_provider: provider}) when is_binary(provider),
do: :context_group
defp default_conversation_kind(_attrs), do: :group
defp channel_id(%Channel{id: id}), do: id
defp channel_id(id) when is_binary(id), do: id
defp slugify(value) when is_binary(value) do
slug =
value
|> String.downcase()
|> String.replace(~r/[^a-z0-9]+/u, "-")
|> String.trim("-")
if slug == "", do: nil, else: slug
end
end
+25
View File
@@ -0,0 +1,25 @@
defmodule TribeOne.TribesPlugin.Aether.Chat.Backend do
@moduledoc """
Behaviour for chat storage/transport backends.
Backends are plain Elixir modules. Ash resources store conversation projections,
participants, and local message state, while protocol backends may use Parrhesia
raw events as canonical storage.
`opts` may carry signing/client context for future non-custodial clients, e.g.
browser-held Nostr signers, NIP-07 bridges, or server-session signers. Backends
that need signatures should reject missing signer context explicitly instead of
assuming server-side custody.
"""
alias TribeOne.TribesPlugin.Aether.Chat.Channel
@type attrs :: map()
@type opts :: keyword()
@callback capabilities() :: map()
@callback ensure_conversation(attrs(), opts()) :: {:ok, Channel.t()} | {:error, term()}
@callback list_messages(Channel.t(), opts()) :: {:ok, [struct()]} | {:error, term()}
@callback send_message(Channel.t(), attrs(), opts()) :: {:ok, struct()} | {:error, term()}
@callback subscribe(Channel.t(), pid(), opts()) :: :ok | {:error, term()}
end
+41
View File
@@ -0,0 +1,41 @@
defmodule TribeOne.TribesPlugin.Aether.Chat.Backends.Marmot do
@moduledoc """
Marmot/MLS group backend scaffold.
Marmot events should be stored canonically as Parrhesia/Nostr events while the
browser/client maintains MLS state. The current web UI does not claim true E2EE.
"""
@behaviour TribeOne.TribesPlugin.Aether.Chat.Backend
alias TribeOne.TribesPlugin.Aether.Chat
alias TribeOne.TribesPlugin.Aether.Chat.Channel
@impl true
def capabilities do
%{
canonical_store: :parrhesia_events,
nostr_compatible?: true,
non_custodial_signing?: true,
conversation_kinds: [:marmot_group],
read_only?: false,
required_protocols: [:marmot, :mls]
}
end
@impl true
def ensure_conversation(attrs, opts) when is_map(attrs) do
attrs
|> Map.put(:backend, :marmot)
|> Chat.ensure_channel(opts)
end
@impl true
def list_messages(%Channel{}, _opts), do: {:ok, []}
@impl true
def send_message(%Channel{}, _attrs, _opts), do: {:error, :not_implemented}
@impl true
def subscribe(%Channel{} = channel, _pid, _opts), do: Chat.subscribe_channel(channel)
end
@@ -0,0 +1,169 @@
defmodule TribeOne.TribesPlugin.Aether.Chat.Backends.NostrNip04ReadOnly do
@moduledoc """
Legacy NIP-04 DM read-only fallback.
This backend queries canonical Parrhesia kind-4 events and decrypts messages for
local display when the viewer has an unlocked session private key. Outbound
messages intentionally remain disabled; new DMs should use NIP-17.
"""
@behaviour TribeOne.TribesPlugin.Aether.Chat.Backend
require Ash.Query
alias TribeOne.TribesPlugin.Aether.Chat
alias TribeOne.TribesPlugin.Aether.Chat.{Channel, Message, Participant}
alias TribeOne.TribesPlugin.Aether.Chat.Nostr.Nak
alias Parrhesia.API.Events
alias Parrhesia.API.RequestContext
@kind_nip04 4
@impl true
def capabilities do
%{
canonical_store: :parrhesia_events,
nostr_compatible?: true,
non_custodial_signing?: true,
conversation_kinds: [:legacy_dm],
read_only?: true,
required_protocols: [:nip04],
signer_modes: [:session_privkey, :future_external_signer]
}
end
@impl true
def ensure_conversation(attrs, opts) when is_map(attrs) do
attrs
|> Map.put(:backend, :nostr_nip04)
|> Map.put_new(:conversation_kind, :legacy_dm)
|> Chat.ensure_channel(opts)
end
@impl true
def list_messages(%Channel{} = channel, opts) do
with {:ok, privkey} <- fetch_privkey(opts),
{:ok, local_pubkey} <- Nak.pubkey_from_private_key(privkey),
{:ok, participant_pubkeys} <- participant_pubkeys(channel),
true <- MapSet.member?(participant_pubkeys, local_pubkey),
{:ok, events} <- query_legacy_dms(local_pubkey, opts) do
messages =
events
|> Enum.flat_map(&decrypt_message(&1, privkey, channel, participant_pubkeys))
|> Enum.sort_by(&message_sort_key/1)
|> maybe_take_latest(Keyword.get(opts, :limit, 100))
{:ok, messages}
else
false -> {:error, :not_a_participant}
{:error, _reason} = error -> error
end
end
@impl true
def send_message(%Channel{}, _attrs, _opts), do: {:error, :read_only_backend}
@impl true
def subscribe(%Channel{}, _pid, _opts), do: :ok
defp fetch_privkey(opts) do
case Keyword.get(opts, :session_privkey) || Keyword.get(opts, :privkey) do
privkey when is_binary(privkey) -> {:ok, privkey}
_other -> {:error, :missing_session_privkey}
end
end
defp participant_pubkeys(%Channel{id: channel_id}) do
Participant
|> Ash.Query.filter(channel_id == ^channel_id)
|> Ash.read(Chat.ash_opts())
|> case do
{:ok, participants} ->
pubkeys =
participants
|> Enum.map(& &1.pubkey)
|> Enum.filter(&valid_pubkey?/1)
|> MapSet.new()
{:ok, pubkeys}
{:error, _reason} = error ->
error
end
end
defp query_legacy_dms(local_pubkey, opts) do
filters = [
%{
"kinds" => [@kind_nip04],
"#p" => [local_pubkey],
"limit" => Keyword.get(opts, :event_limit, 200)
}
]
Events.query(filters, context: request_context([local_pubkey]))
end
defp decrypt_message(event, privkey, channel, participant_pubkeys) do
with %{"kind" => @kind_nip04, "pubkey" => sender_pubkey, "content" => ciphertext} <- event,
true <- MapSet.member?(participant_pubkeys, sender_pubkey),
true <- Enum.any?(p_tags(event), &MapSet.member?(participant_pubkeys, &1)),
{:ok, body} <- Nak.nip04_decrypt(privkey, sender_pubkey, ciphertext) do
[
%Message{
id: event["id"],
channel_id: channel.id,
author_pubkey: sender_pubkey,
body: body,
client_message_id: event["id"],
metadata: %{"nostr_kind" => @kind_nip04, "nostr_event_id" => event["id"]},
inserted_at: unix_to_datetime(event["created_at"])
}
]
else
_other -> []
end
end
defp p_tags(event) do
event
|> Map.get("tags", [])
|> Enum.flat_map(fn
["p", pubkey | _rest] when is_binary(pubkey) -> [pubkey]
_tag -> []
end)
end
defp message_sort_key(%Message{inserted_at: %DateTime{} = inserted_at, id: id}) do
{DateTime.to_unix(inserted_at, :microsecond), id || ""}
end
defp maybe_take_latest(messages, limit) when is_integer(limit) and limit > 0 do
if length(messages) > limit do
messages |> Enum.take(-limit)
else
messages
end
end
defp maybe_take_latest(messages, _limit), do: messages
defp unix_to_datetime(timestamp) when is_integer(timestamp) do
case DateTime.from_unix(timestamp, :second) do
{:ok, datetime} -> datetime
{:error, _reason} -> DateTime.utc_now()
end
end
defp unix_to_datetime(_timestamp), do: DateTime.utc_now()
defp request_context(pubkeys) do
%RequestContext{
caller: :local,
authenticated_pubkeys: MapSet.new(Enum.filter(pubkeys, &valid_pubkey?/1))
}
end
defp valid_pubkey?(pubkey) when is_binary(pubkey), do: pubkey =~ ~r/\A[0-9a-fA-F]{64}\z/
defp valid_pubkey?(_pubkey), do: false
end
+294
View File
@@ -0,0 +1,294 @@
defmodule TribeOne.TribesPlugin.Aether.Chat.Backends.NostrNip17 do
@moduledoc """
NIP-17 private DM backend.
Gift-wrapped Nostr events in Parrhesia are the canonical message store. The
Ash chat channel/participant records are local UI projections used to find the
conversation participants; decrypted message structs returned to the UI are not
persisted through the Ash synced message resource.
"""
@behaviour TribeOne.TribesPlugin.Aether.Chat.Backend
require Ash.Query
alias TribeOne.TribesPlugin.Aether.Chat
alias TribeOne.TribesPlugin.Aether.Chat.{Channel, Message, Participant}
alias TribeOne.TribesPlugin.Aether.Chat.Nostr.Nak
alias Parrhesia.API.Events
alias Parrhesia.API.RequestContext
alias Parrhesia.API.Stream
@kind_giftwrap 1059
@kind_dm 14
@impl true
def capabilities do
%{
canonical_store: :parrhesia_events,
nostr_compatible?: true,
non_custodial_signing?: true,
conversation_kinds: [:dm],
read_only?: false,
required_protocols: [:nip17, :nip44, :nip59],
signer_modes: [:session_privkey, :future_external_signer],
plaintext_projection?: false
}
end
@impl true
def ensure_conversation(attrs, opts) when is_map(attrs) do
attrs
|> Map.put(:backend, :nostr_nip17)
|> Map.put_new(:conversation_kind, :dm)
|> Chat.ensure_channel(opts)
end
@impl true
def list_messages(%Channel{} = channel, opts) do
with {:ok, privkey} <- fetch_privkey(opts),
{:ok, local_pubkey} <- Nak.pubkey_from_private_key(privkey),
{:ok, participant_pubkeys} <- participant_pubkeys(channel),
true <- MapSet.member?(participant_pubkeys, local_pubkey),
{:ok, events} <- query_giftwraps(local_pubkey, opts) do
messages =
events
|> Enum.flat_map(&unwrap_message(&1, privkey, channel, participant_pubkeys))
|> dedupe_messages()
|> Enum.sort_by(&message_sort_key/1)
|> maybe_take_latest(Keyword.get(opts, :limit, 100))
{:ok, messages}
else
false -> {:error, :not_a_participant}
{:error, _reason} = error -> error
end
end
@impl true
def send_message(%Channel{} = channel, attrs, opts) when is_map(attrs) do
body = attrs |> Map.get(:body) || Map.get(attrs, "body") || ""
body = String.trim(body)
with false <- body == "",
{:ok, privkey} <- fetch_privkey(opts),
{:ok, sender_pubkey} <- Nak.pubkey_from_private_key(privkey),
:ok <- validate_author(attrs, sender_pubkey),
{:ok, participant_pubkeys} <- participant_pubkeys(channel),
true <- MapSet.member?(participant_pubkeys, sender_pubkey),
{:ok, recipient_pubkey} <- recipient_pubkey(participant_pubkeys, sender_pubkey),
{:ok, rumor} <- Nak.nip17_rumor(privkey, recipient_pubkey, body),
{:ok, giftwraps} <- publish_giftwraps(privkey, rumor, [recipient_pubkey, sender_pubkey]) do
{:ok, message_from_rumor(rumor, channel, attrs, giftwraps)}
else
true -> {:error, :empty_message}
false -> {:error, :not_a_participant}
{:error, _reason} = error -> error
end
end
@impl true
def subscribe(%Channel{} = channel, pid, opts) when is_pid(pid) do
with {:ok, privkey} <- fetch_privkey(opts),
{:ok, local_pubkey} <- Nak.pubkey_from_private_key(privkey) do
filters = [%{"kinds" => [@kind_giftwrap], "#p" => [local_pubkey]}]
Stream.subscribe(pid, "aether-chat-#{channel.id}", filters,
context: request_context([local_pubkey])
)
else
{:error, :missing_session_privkey} -> :ok
{:error, _reason} = error -> error
end
end
def unwrap_event(%Channel{} = channel, giftwrap, opts) when is_map(giftwrap) do
with {:ok, privkey} <- fetch_privkey(opts),
{:ok, participant_pubkeys} <- participant_pubkeys(channel) do
case unwrap_message(giftwrap, privkey, channel, participant_pubkeys) do
[message] -> {:ok, message}
[] -> {:error, :not_conversation_message}
end
end
end
defp fetch_privkey(opts) do
case Keyword.get(opts, :session_privkey) || Keyword.get(opts, :privkey) do
privkey when is_binary(privkey) -> {:ok, privkey}
_other -> {:error, :missing_session_privkey}
end
end
defp validate_author(attrs, sender_pubkey) do
case Map.get(attrs, :author_pubkey) || Map.get(attrs, "author_pubkey") do
nil -> :ok
^sender_pubkey -> :ok
_other -> {:error, :author_pubkey_mismatch}
end
end
defp participant_pubkeys(%Channel{id: channel_id}) do
Participant
|> Ash.Query.filter(channel_id == ^channel_id)
|> Ash.read(Chat.ash_opts())
|> case do
{:ok, participants} ->
pubkeys =
participants
|> Enum.map(& &1.pubkey)
|> Enum.filter(&valid_pubkey?/1)
|> MapSet.new()
{:ok, pubkeys}
{:error, _reason} = error ->
error
end
end
defp recipient_pubkey(participant_pubkeys, sender_pubkey) do
case participant_pubkeys |> MapSet.delete(sender_pubkey) |> MapSet.to_list() do
[recipient_pubkey] -> {:ok, recipient_pubkey}
[] -> {:error, :missing_recipient}
_many -> {:error, :unsupported_multi_recipient_dm}
end
end
defp publish_giftwraps(privkey, rumor, delivery_pubkeys) do
delivery_pubkeys
|> Enum.uniq()
|> Enum.reduce_while({:ok, []}, fn delivery_pubkey, {:ok, acc} ->
with {:ok, giftwrap} <- Nak.nip17_wrap(privkey, delivery_pubkey, rumor),
:ok <- publish_event(giftwrap) do
{:cont, {:ok, [giftwrap | acc]}}
else
{:error, _reason} = error -> {:halt, error}
end
end)
|> case do
{:ok, giftwraps} -> {:ok, Enum.reverse(giftwraps)}
{:error, _reason} = error -> error
end
end
defp publish_event(event) do
with {:ok, result} <- Events.publish(event, context: request_context([event["pubkey"]])),
true <- result.accepted do
:ok
else
false -> {:error, :publish_rejected}
{:ok, %{reason: reason}} when not is_nil(reason) -> {:error, reason}
{:error, _reason} = error -> error
other -> {:error, other}
end
end
defp query_giftwraps(local_pubkey, opts) do
filters = [
%{
"kinds" => [@kind_giftwrap],
"#p" => [local_pubkey],
"limit" => Keyword.get(opts, :event_limit, 200)
}
]
Events.query(filters, context: request_context([local_pubkey]))
end
defp unwrap_message(giftwrap, privkey, channel, participant_pubkeys) do
with {:ok, rumor} <- Nak.nip17_unwrap(privkey, giftwrap),
true <- conversation_rumor?(rumor, participant_pubkeys) do
[message_from_rumor(rumor, channel, %{}, [giftwrap])]
else
_other -> []
end
end
defp conversation_rumor?(%{"kind" => @kind_dm, "pubkey" => sender_pubkey} = rumor, pubkeys) do
recipient_pubkeys = p_tags(rumor)
MapSet.member?(pubkeys, sender_pubkey) and
Enum.any?(recipient_pubkeys, &MapSet.member?(pubkeys, &1))
end
defp conversation_rumor?(_rumor, _pubkeys), do: false
defp message_from_rumor(rumor, channel, attrs, giftwraps) do
%Message{
id: rumor["id"] || (List.first(giftwraps || []) && List.first(giftwraps)["id"]),
channel_id: channel.id,
author_id: Map.get(attrs, :author_id) || Map.get(attrs, "author_id"),
author_pubkey: rumor["pubkey"],
body: rumor["content"] || "",
client_message_id: rumor["id"],
metadata: message_metadata(rumor, attrs, giftwraps),
inserted_at: unix_to_datetime(rumor["created_at"])
}
end
defp message_metadata(rumor, attrs, giftwraps) do
attrs_metadata = Map.get(attrs, :metadata) || Map.get(attrs, "metadata") || %{}
attrs_metadata
|> Map.put_new("author_name", get_in(attrs_metadata, ["author_name"]))
|> Map.put("nostr_kind", @kind_dm)
|> Map.put("nostr_rumor_id", rumor["id"])
|> Map.put("nostr_giftwrap_ids", Enum.map(giftwraps || [], & &1["id"]))
end
defp p_tags(event) do
event
|> Map.get("tags", [])
|> Enum.flat_map(fn
["p", pubkey | _rest] when is_binary(pubkey) -> [pubkey]
_tag -> []
end)
end
defp dedupe_messages(messages) do
messages
|> Enum.reduce({MapSet.new(), []}, fn message, {seen, acc} ->
key = message.client_message_id || message.id
if MapSet.member?(seen, key) do
{seen, acc}
else
{MapSet.put(seen, key), [message | acc]}
end
end)
|> elem(1)
end
defp message_sort_key(%Message{inserted_at: %DateTime{} = inserted_at, id: id}) do
{DateTime.to_unix(inserted_at, :microsecond), id || ""}
end
defp maybe_take_latest(messages, limit) when is_integer(limit) and limit > 0 do
if length(messages) > limit do
messages |> Enum.take(-limit)
else
messages
end
end
defp maybe_take_latest(messages, _limit), do: messages
defp unix_to_datetime(timestamp) when is_integer(timestamp) do
case DateTime.from_unix(timestamp, :second) do
{:ok, datetime} -> datetime
{:error, _reason} -> DateTime.utc_now()
end
end
defp unix_to_datetime(_timestamp), do: DateTime.utc_now()
defp request_context(pubkeys) do
%RequestContext{
caller: :local,
authenticated_pubkeys: MapSet.new(Enum.filter(pubkeys, &valid_pubkey?/1))
}
end
defp valid_pubkey?(pubkey) when is_binary(pubkey), do: pubkey =~ ~r/\A[0-9a-fA-F]{64}\z/
defp valid_pubkey?(_pubkey), do: false
end
+35
View File
@@ -0,0 +1,35 @@
defmodule TribeOne.TribesPlugin.Aether.Chat.Backends.PublicSync do
@moduledoc """
Plaintext Ash-backed chat backend for public and local DM-style conversations.
"""
@behaviour TribeOne.TribesPlugin.Aether.Chat.Backend
alias TribeOne.TribesPlugin.Aether.Chat
alias TribeOne.TribesPlugin.Aether.Chat.Channel
@impl true
def capabilities do
%{
canonical_store: :ash,
nostr_compatible?: false,
non_custodial_signing?: false,
conversation_kinds: [:group, :context_group, :dm],
read_only?: false
}
end
@impl true
def ensure_conversation(attrs, opts) when is_map(attrs), do: Chat.ensure_channel(attrs, opts)
@impl true
def list_messages(%Channel{} = channel, opts), do: Chat.list_messages(channel, opts)
@impl true
def send_message(%Channel{} = channel, attrs, opts) when is_map(attrs) do
Chat.post_message(channel, attrs, opts)
end
@impl true
def subscribe(%Channel{} = channel, _pid, _opts), do: Chat.subscribe_channel(channel)
end
+180
View File
@@ -0,0 +1,180 @@
defmodule TribeOne.TribesPlugin.Aether.Chat.Channel do
@moduledoc """
Public chat channel metadata.
"""
use Ash.Resource,
otp_app: :tribe_one_aether,
domain: TribeOne.TribesPlugin.Aether.Chat,
data_layer: AshPostgres.DataLayer,
extensions: [AshNostrSync]
import Ash.Expr
@backends [:public_sync, :nostr_nip17, :nostr_nip04, :marmot]
@conversation_kinds [:group, :context_group, :dm, :legacy_dm, :marmot_group]
postgres do
table("aether_chat_channels")
repo(Tribes.Repo)
custom_indexes do
index([:slug], unique: true, where: "deleted_at IS NULL")
index([:context_provider, :context_type, :context_id], where: "deleted_at IS NULL")
end
end
nostr_sync do
namespace("plugins.aether.chat.channel")
lane(:control)
publish?(true)
consume?(true)
end
actions do
defaults([:read])
read :by_id do
get?(true)
get_by(:id)
end
read :by_slug do
get?(true)
argument :slug, :string do
allow_nil?(false)
end
filter(expr(slug == ^arg(:slug)))
end
create :create do
accept([
:id,
:slug,
:title,
:description,
:backend,
:conversation_kind,
:context_provider,
:context_type,
:context_id,
:metadata
])
change(AshNostrSync.PublishChange)
end
create :sync_upsert do
accept([
:id,
:slug,
:title,
:description,
:backend,
:conversation_kind,
:context_provider,
:context_type,
:context_id,
:metadata
])
upsert?(true)
end
update :update do
require_atomic?(false)
accept([
:title,
:description,
:backend,
:conversation_kind,
:context_provider,
:context_type,
:context_id,
:metadata
])
change(AshNostrSync.PublishChange)
end
destroy :destroy do
require_atomic?(false)
soft?(true)
soft_delete()
change(AshNostrSync.PublishChange)
end
end
relationships do
has_many :messages, TribeOne.TribesPlugin.Aether.Chat.Message do
destination_attribute(:channel_id)
public?(true)
end
has_many :participants, TribeOne.TribesPlugin.Aether.Chat.Participant do
destination_attribute(:channel_id)
public?(true)
end
end
attributes do
attribute :id, :uuid do
allow_nil?(false)
primary_key?(true)
public?(true)
writable?(true)
default(&Ash.UUID.generate/0)
end
attribute :slug, :string do
allow_nil?(false)
public?(true)
end
attribute :title, :string do
allow_nil?(false)
public?(true)
end
attribute :description, :string do
public?(true)
end
attribute :backend, :atom do
constraints(one_of: @backends)
allow_nil?(false)
default(:public_sync)
public?(true)
end
attribute :conversation_kind, :atom do
constraints(one_of: @conversation_kinds)
allow_nil?(false)
default(:group)
public?(true)
end
attribute :context_provider, :string do
public?(true)
end
attribute :context_type, :string do
public?(true)
end
attribute :context_id, :string do
public?(true)
end
attribute :metadata, :map do
allow_nil?(false)
default(%{})
public?(true)
end
timestamps(type: :utc_datetime)
end
end
+139
View File
@@ -0,0 +1,139 @@
defmodule TribeOne.TribesPlugin.Aether.Chat.Message do
@moduledoc """
Public synced chat message.
"""
use Ash.Resource,
otp_app: :tribe_one_aether,
domain: TribeOne.TribesPlugin.Aether.Chat,
data_layer: AshPostgres.DataLayer,
extensions: [AshNostrSync]
import Ash.Expr
postgres do
table("aether_chat_messages")
repo(Tribes.Repo)
custom_indexes do
index([:channel_id, :inserted_at])
index([:author_pubkey])
index([:client_message_id],
unique: true,
where: "client_message_id IS NOT NULL AND deleted_at IS NULL"
)
end
end
nostr_sync do
namespace("plugins.aether.chat.message")
lane(:bulk)
publish?(true)
consume?(true)
end
actions do
defaults([:read])
read :by_id do
get?(true)
get_by(:id)
end
read :by_channel do
argument :channel_id, :uuid do
allow_nil?(false)
end
prepare(
build(filter: expr(channel_id == ^arg(:channel_id)), sort: [inserted_at: :asc, id: :asc])
)
end
create :create do
accept([
:id,
:channel_id,
:author_id,
:author_pubkey,
:body,
:client_message_id,
:metadata
])
change(AshNostrSync.PublishChange)
end
create :sync_upsert do
accept([
:id,
:channel_id,
:author_id,
:author_pubkey,
:body,
:client_message_id,
:metadata
])
upsert?(true)
end
update :update do
require_atomic?(false)
accept([:body, :metadata])
change(AshNostrSync.PublishChange)
end
destroy :destroy do
require_atomic?(false)
soft?(true)
soft_delete()
change(AshNostrSync.PublishChange)
end
end
relationships do
belongs_to :channel, TribeOne.TribesPlugin.Aether.Chat.Channel do
allow_nil?(false)
attribute_type(:uuid)
public?(true)
end
end
attributes do
attribute :id, :uuid do
allow_nil?(false)
primary_key?(true)
public?(true)
writable?(true)
default(&Ash.UUID.generate/0)
end
attribute :author_id, :uuid do
public?(true)
end
attribute :author_pubkey, :string do
public?(true)
end
attribute :body, :string do
allow_nil?(false)
public?(true)
constraints(allow_empty?: false, trim?: true)
end
attribute :client_message_id, :string do
public?(true)
end
attribute :metadata, :map do
allow_nil?(false)
default(%{})
public?(true)
end
timestamps(type: :utc_datetime)
end
end
+157
View File
@@ -0,0 +1,157 @@
defmodule TribeOne.TribesPlugin.Aether.Chat.Nostr.Nak do
@moduledoc false
@timeout_ms 30_000
def available?, do: System.find_executable("nak") != nil
def pubkey_from_private_key(privkey) do
with {:ok, privkey_hex} <- normalize_private_key(privkey) do
run_script(
~S'''
set -euo pipefail
nak key public "$1"
''',
[privkey_hex]
)
|> trim_result()
end
end
def nip17_rumor(sender_privkey, recipient_pubkey, body)
when is_binary(recipient_pubkey) and is_binary(body) do
with {:ok, sender_privkey_hex} <- normalize_private_key(sender_privkey),
:ok <- validate_pubkey(recipient_pubkey) do
run_script(
~S'''
set -euo pipefail
nak event --sec "$1" -k 14 -p "$2" -c "$3" </dev/null 2>/dev/null
''',
[sender_privkey_hex, recipient_pubkey, body]
)
|> decode_json_result()
end
end
def nip17_wrap(sender_privkey, recipient_pubkey, rumor) when is_map(rumor) do
with {:ok, sender_privkey_hex} <- normalize_private_key(sender_privkey),
:ok <- validate_pubkey(recipient_pubkey),
{:ok, rumor_json} <- encode_json(rumor) do
run_script(
~S'''
set -euo pipefail
printf '%s\n' "$3" | nak gift wrap --sec "$1" -p "$2" 2>/dev/null
''',
[sender_privkey_hex, recipient_pubkey, rumor_json]
)
|> decode_json_result()
end
end
def nip17_unwrap(recipient_privkey, giftwrap) when is_map(giftwrap) do
with {:ok, recipient_privkey_hex} <- normalize_private_key(recipient_privkey),
{:ok, giftwrap_json} <- encode_json(giftwrap) do
run_script(
~S'''
set -euo pipefail
printf '%s\n' "$2" | nak gift unwrap --sec "$1" 2>/dev/null
''',
[recipient_privkey_hex, giftwrap_json]
)
|> decode_json_result()
end
end
def nip04_encrypt(sender_privkey, recipient_pubkey, plaintext)
when is_binary(recipient_pubkey) and is_binary(plaintext) do
with {:ok, sender_privkey_hex} <- normalize_private_key(sender_privkey),
:ok <- validate_pubkey(recipient_pubkey) do
run_script(
~S'''
set -euo pipefail
nak encrypt --nip04 --sec "$1" -p "$2" "$3" </dev/null 2>/dev/null
''',
[sender_privkey_hex, recipient_pubkey, plaintext]
)
|> trim_result()
end
end
def nip04_decrypt(recipient_privkey, sender_pubkey, ciphertext)
when is_binary(sender_pubkey) and is_binary(ciphertext) do
with {:ok, recipient_privkey_hex} <- normalize_private_key(recipient_privkey),
:ok <- validate_pubkey(sender_pubkey) do
run_script(
~S'''
set -euo pipefail
nak decrypt --nip04 --sec "$1" -p "$2" "$3" 2>/dev/null
''',
[recipient_privkey_hex, sender_pubkey, ciphertext]
)
|> trim_result()
end
end
def normalize_private_key(<<_::binary-size(32)>> = privkey),
do: {:ok, Base.encode16(privkey, case: :lower)}
def normalize_private_key(privkey) when is_binary(privkey) do
if privkey =~ ~r/\A[0-9a-fA-F]{64}\z/ do
{:ok, String.downcase(privkey)}
else
{:error, :invalid_private_key}
end
end
def normalize_private_key(_privkey), do: {:error, :invalid_private_key}
defp validate_pubkey(pubkey) when is_binary(pubkey) do
if pubkey =~ ~r/\A[0-9a-fA-F]{64}\z/ do
:ok
else
{:error, :invalid_pubkey}
end
end
defp validate_pubkey(_pubkey), do: {:error, :invalid_pubkey}
defp run_script(script, args) do
if available?() do
do_run_script(script, args)
else
{:error, :nak_not_found}
end
end
defp do_run_script(script, args) do
task =
Task.async(fn ->
System.cmd("bash", ["-lc", script, "bash" | args], stderr_to_stdout: true)
end)
case Task.yield(task, @timeout_ms) || Task.shutdown(task, :brutal_kill) do
{:ok, {output, 0}} -> {:ok, output}
{:ok, {output, status}} -> {:error, {:nak_failed, status, output}}
nil -> {:error, :nak_timeout}
end
end
defp trim_result({:ok, output}), do: {:ok, String.trim(output)}
defp trim_result({:error, _reason} = error), do: error
defp decode_json_result({:ok, output}) do
output
|> String.trim()
|> JSON.decode()
rescue
_error -> {:error, {:invalid_json, output}}
end
defp decode_json_result({:error, _reason} = error), do: error
defp encode_json(value) do
{:ok, JSON.encode!(value)}
rescue
_error -> {:error, :invalid_json}
end
end
+127
View File
@@ -0,0 +1,127 @@
defmodule TribeOne.TribesPlugin.Aether.Chat.Participant do
@moduledoc """
Participant projection for Aether conversations.
"""
use Ash.Resource,
otp_app: :tribe_one_aether,
domain: TribeOne.TribesPlugin.Aether.Chat,
data_layer: AshPostgres.DataLayer,
extensions: [AshNostrSync]
import Ash.Expr
@roles [:owner, :member]
postgres do
table("aether_chat_participants")
repo(Tribes.Repo)
custom_indexes do
index([:channel_id, :pubkey], unique: true, where: "deleted_at IS NULL")
index([:pubkey], where: "deleted_at IS NULL")
end
end
nostr_sync do
namespace("plugins.aether.chat.participant")
lane(:control)
publish?(true)
consume?(true)
end
actions do
defaults([:read])
read :by_channel_pubkey do
get?(true)
argument :channel_id, :uuid do
allow_nil?(false)
end
argument :pubkey, :string do
allow_nil?(false)
end
filter(expr(channel_id == ^arg(:channel_id) and pubkey == ^arg(:pubkey)))
end
read :by_pubkey do
argument :pubkey, :string do
allow_nil?(false)
end
prepare(build(filter: expr(pubkey == ^arg(:pubkey)), sort: [inserted_at: :desc]))
end
create :create do
accept([:id, :channel_id, :pubkey, :user_id, :display_name, :role, :metadata])
change(AshNostrSync.PublishChange)
end
create :sync_upsert do
accept([:id, :channel_id, :pubkey, :user_id, :display_name, :role, :metadata])
upsert?(true)
end
update :update do
require_atomic?(false)
accept([:user_id, :display_name, :role, :metadata])
change(AshNostrSync.PublishChange)
end
destroy :destroy do
require_atomic?(false)
soft?(true)
soft_delete()
change(AshNostrSync.PublishChange)
end
end
relationships do
belongs_to :channel, TribeOne.TribesPlugin.Aether.Chat.Channel do
allow_nil?(false)
attribute_type(:uuid)
public?(true)
end
end
attributes do
attribute :id, :uuid do
allow_nil?(false)
primary_key?(true)
public?(true)
writable?(true)
default(&Ash.UUID.generate/0)
end
attribute :pubkey, :string do
allow_nil?(false)
public?(true)
end
attribute :user_id, :uuid do
public?(true)
end
attribute :display_name, :string do
public?(true)
end
attribute :role, :atom do
constraints(one_of: @roles)
allow_nil?(false)
default(:member)
public?(true)
end
attribute :metadata, :map do
allow_nil?(false)
default(%{})
public?(true)
end
timestamps(type: :utc_datetime)
end
end
+111
View File
@@ -0,0 +1,111 @@
defmodule TribeOne.TribesPlugin.Aether.Plugin do
@moduledoc """
Tribes plugin entry point.
"""
use Tribes.Plugin.Base, otp_app: :tribe_one_aether
@behaviour Tribes.Capabilities.Chat.V1
@impl true
def register(context) do
super(context)
|> Map.merge(%{
nav_items: [
%{
label: "Aether",
path: "/aether",
icon: nil,
requires: [],
order: 50
},
%{
label: "Chat",
path: "/aether/chat",
icon: nil,
requires: [],
order: 51
}
],
pages: [
%{
path: "/aether/chat",
live_view: TribeOne.TribesPlugin.AetherWeb.ChatLive,
layout: nil
},
%{
path: "/aether",
live_view: TribeOne.TribesPlugin.AetherWeb.TimelineLive,
layout: nil
}
],
ash_domains: [TribeOne.TribesPlugin.Aether.Chat],
config_schema: %{
title: "Aether",
description: "Social feed and chat defaults.",
groups: [
%{
id: "chat",
label: "Chat",
description: "Group chat backend defaults.",
order: 10,
settings: [
%{
key: "chat.default_backend",
label: "Default chat backend",
description:
"New standalone channels use public synced chat until Marmot is explicitly selected.",
type: :enum,
default: "public_sync",
options: [
%{label: "Public synced", value: "public_sync"},
%{label: "Marmot scaffold", value: "marmot"}
],
order: 10
},
%{
key: "chat.marmot_enabled",
label: "Enable Marmot chat UI",
description:
"Reserved for the Marmot browser transport once storage, relay, and signing adapters are complete.",
type: :boolean,
default: false,
order: 20
}
]
}
]
}
})
end
@impl Tribes.Capabilities.Chat.V1
def embedded_panel(slug, opts \\ []) when is_binary(slug) do
assigns = %{
slug: slug,
backend: Keyword.get(opts, :backend, :public_sync),
embedded?: true,
current_user: Keyword.get(opts, :current_user),
session_privkey: Keyword.get(opts, :session_privkey)
}
{:ok,
Tribes.Plugin.Surface.live_component(
TribeOne.TribesPlugin.AetherWeb.ChatPanelComponent,
Keyword.get(opts, :id, "aether-chat-panel-#{slug}"),
assigns,
capability: "org.tribe-one.caps.chat@1",
provider: __MODULE__
)}
end
@impl Tribes.Capabilities.Chat.V1
def standalone_path(slug), do: TribeOne.TribesPlugin.Aether.Chat.standalone_path(slug)
@impl Tribes.Capabilities.Chat.V1
def handle_surface_info(_surface, {:aether_chat, :message, message}) do
{:ok, %{incoming_message: message}}
end
def handle_surface_info(_surface, _message), do: :ignore
end
+186
View File
@@ -0,0 +1,186 @@
defmodule TribeOne.TribesPlugin.AetherWeb.ChatLive do
use Phoenix.LiveView
on_mount({Tribes.Plugin.LiveUserAuth, :live_user_optional})
alias TribeOne.TribesPlugin.Aether.Chat
alias TribeOne.TribesPlugin.AetherWeb.{ChatPanelComponent, ChatRecipientPickerComponent}
alias Tribes.Plugin.Layouts
@component_id "aether-chat-panel-component"
@impl true
def mount(_params, session, socket) do
request = channel_request(session)
{:ok,
socket
|> assign(:page_title, channel_title(request.slug))
|> assign(:chat_request, request)
|> assign(:component_id, @component_id)}
end
@impl true
def handle_info({:aether_chat, :message, message}, socket) do
send_update(ChatPanelComponent, id: @component_id, incoming_message: message)
{:noreply, socket}
end
def handle_info({:aether_chat, :recipient_selected, recipient}, socket) do
current_user = socket.assigns.current_user
if current_user && is_binary(recipient.pubkey_hex) do
attrs = %{
title: recipient_label(recipient),
metadata: %{"recipient_username" => recipient.username}
}
case Chat.ensure_direct_conversation(current_user.pubkey_hex, recipient.pubkey_hex, attrs,
session_privkey: socket.assigns[:session_privkey]
) do
{:ok, channel} ->
{:noreply, push_navigate(socket, to: Chat.standalone_path(channel))}
{:error, reason} ->
{:noreply, put_flash(socket, :error, "Could not open conversation: #{inspect(reason)}")}
end
else
{:noreply, put_flash(socket, :error, "Sign in to start a direct message")}
end
end
def handle_info({:parrhesia, :events, _ref, _subscription_id, events}, socket)
when is_list(events) do
Enum.each(events, &send_unwrapped_message(socket, &1))
{:noreply, socket}
end
def handle_info({:parrhesia, :event, _ref, _subscription_id, event}, socket) do
send_unwrapped_message(socket, event)
{:noreply, socket}
end
def handle_info({:parrhesia, :eose, _ref, _subscription_id, _hints}, socket),
do: {:noreply, socket}
def handle_info({:parrhesia, :eose, _ref, _subscription_id}, socket), do: {:noreply, socket}
@impl true
def render(%{chat_request: %{mode: :embedded}} = assigns) do
~H"""
<div class="aether aether-chat h-full min-h-[28rem] p-2" id="aether-chat-embed">
<.live_component
module={ChatPanelComponent}
id={@component_id}
slug={@chat_request.slug}
backend={@chat_request.backend}
embedded?={true}
current_user={@current_user}
session_privkey={@session_privkey}
/>
</div>
"""
end
def render(assigns) do
~H"""
<Layouts.app flash={@flash} current_scope={@current_scope}>
<div class="aether aether-chat flex min-h-[calc(100vh-8rem)] flex-col p-4 sm:p-6" id="aether-chat-page">
<section class="mb-4 rounded-3xl border border-base-300 bg-base-100/95 p-5 shadow-sm">
<div class="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
<div>
<p class="text-xs font-semibold uppercase tracking-[0.3em] text-base-content/50">
Aether chat
</p>
<h1 class="mt-1 text-3xl font-semibold tracking-tight text-base-content">
{channel_title(@chat_request.slug)}
</h1>
<p class="mt-2 max-w-2xl text-sm leading-6 text-base-content/70">
Public synced rooms and NIP-17 direct messages share this chat surface.
</p>
</div>
<.live_component
:if={@current_user}
module={ChatRecipientPickerComponent}
id="aether-chat-recipient-picker"
current_user={@current_user}
/>
</div>
</section>
<.live_component
module={ChatPanelComponent}
id={@component_id}
slug={@chat_request.slug}
backend={@chat_request.backend}
embedded?={false}
current_user={@current_user}
session_privkey={@session_privkey}
/>
</div>
</Layouts.app>
"""
end
defp channel_request(%{"plugin_path" => path}) when is_binary(path) do
case String.split(path, "/", trim: true) do
["aether", "chat", "embed", slug | _rest] ->
request(:embedded, slug, :public_sync)
["plugins", "aether", "chat", "embed", slug | _rest] ->
request(:embedded, slug, :public_sync)
["aether", "chat", "marmot", slug | _rest] ->
request(:standalone, slug, :marmot)
["plugins", "aether", "chat", "marmot", slug | _rest] ->
request(:standalone, slug, :marmot)
["aether", "chat", slug | _rest] ->
request(:standalone, slug, :public_sync)
["plugins", "aether", "chat", slug | _rest] ->
request(:standalone, slug, :public_sync)
_other ->
request(:standalone, Chat.default_channel_slug(), :public_sync)
end
end
defp channel_request(_session),
do: request(:standalone, Chat.default_channel_slug(), :public_sync)
defp request(mode, slug, backend), do: %{mode: mode, slug: slug, backend: backend}
defp send_unwrapped_message(socket, event) do
request = socket.assigns.chat_request
with {:ok, %TribeOne.TribesPlugin.Aether.Chat.Channel{} = channel} <-
Chat.get_channel_by_slug(request.slug, Chat.ash_opts()),
backend <- Chat.backend_module(channel.backend),
true <- function_exported?(backend, :unwrap_event, 3),
{:ok, message} <-
backend.unwrap_event(channel, event, session_privkey: socket.assigns[:session_privkey]) do
send_update(ChatPanelComponent, id: @component_id, incoming_message: message)
end
end
defp recipient_label(%{display_name: display_name})
when is_binary(display_name) and display_name != "" do
display_name
end
defp recipient_label(%{username: username}) when is_binary(username) and username != "",
do: username
defp recipient_label(%{pubkey_hex: pubkey}), do: String.slice(pubkey, 0, 8) <> ""
defp channel_title("general"), do: "General Chat"
defp channel_title(slug) do
slug
|> String.replace(["-", "_"], " ")
|> String.split(" ", trim: true)
|> Enum.map_join(" ", &String.capitalize/1)
end
end
+294
View File
@@ -0,0 +1,294 @@
defmodule TribeOne.TribesPlugin.AetherWeb.ChatPanelComponent do
@moduledoc """
Reusable chat panel component for standalone and embedded Aether chat surfaces.
"""
use Phoenix.LiveComponent
alias TribeOne.TribesPlugin.Aether.Chat
alias TribeOne.TribesPlugin.Aether.Chat.{Channel, Message}
@impl true
def update(%{incoming_message: %Message{} = message}, socket) do
{:ok, put_message(socket, message)}
end
def update(assigns, socket) do
slug = Map.fetch!(assigns, :slug)
backend = Map.get(assigns, :backend, :public_sync)
socket =
socket
|> assign(assigns)
|> assign_new(:channel, fn -> nil end)
|> assign_new(:messages, fn -> [] end)
|> assign_new(:message_ids, fn -> MapSet.new() end)
|> assign_new(:message_count, fn -> 0 end)
|> assign_new(:subscribed_channel_id, fn -> nil end)
socket =
if reload_channel?(socket, slug, backend) do
load_channel(socket, slug, backend)
else
socket
end
{:ok, maybe_subscribe(socket)}
end
@impl true
def handle_event("send_message", %{"message" => %{"body" => body}}, socket) do
body = String.trim(body)
cond do
socket.assigns.channel == nil ->
{:noreply, put_flash(socket, :error, "Chat channel is unavailable")}
socket.assigns.current_user == nil ->
{:noreply, put_flash(socket, :error, "Sign in to chat")}
body == "" ->
{:noreply, put_flash(socket, :error, "Message cannot be empty")}
true ->
post_message(socket, body)
end
end
@impl true
def render(assigns) do
~H"""
<div
id="aether-chat-panel"
class={[
"flex min-h-0 flex-1 flex-col overflow-hidden border border-base-300 bg-base-100 shadow-sm",
@embedded? && "h-full rounded-2xl",
!@embedded? && "rounded-3xl"
]}
>
<div
:if={@embedded?}
id="chat-embed-header"
class="flex items-center justify-between gap-3 border-b border-base-300 px-4 py-3"
>
<div>
<p class="text-xs font-semibold uppercase tracking-[0.2em] text-base-content/50">Chat</p>
<h2 class="text-base font-semibold text-base-content">{if @channel, do: @channel.title, else: "Chat"}</h2>
</div>
<span class="text-xs text-base-content/60">{@message_count}</span>
</div>
<div
:if={@backend == :marmot}
id="chat-marmot-notice"
class="border-b border-warning/30 bg-warning/10 px-4 py-3 text-sm text-warning-content"
>
Marmot backend scaffold is enabled for this channel. Browser transport, storage, and signing are not active yet.
</div>
<div
id="chat-messages"
phx-hook="AetherChatScroll"
class={[
"flex-1 space-y-3 overflow-y-auto p-4",
!@embedded? && "min-h-[24rem] sm:p-6",
@embedded? && "min-h-0"
]}
>
<div
:if={@messages == []}
id="chat-empty"
class="rounded-box border border-dashed border-base-300 p-8 text-center text-sm text-base-content/60"
>
No messages yet. Start the conversation.
</div>
<article :for={message <- @messages} id={"chat-message-#{message.id}"} class="aether-chat-message flex gap-3">
<div class="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-primary/10 text-sm font-semibold text-primary">
{author_initial(message)}
</div>
<div class="max-w-[min(42rem,85%)] rounded-2xl bg-base-200 px-4 py-3 shadow-sm">
<div class="mb-1 flex items-center gap-2 text-xs text-base-content/60">
<span class="font-semibold text-base-content/75">{author_label(message)}</span>
<span
phx-hook="DateTime"
id={"chat-time-#{message.id}"}
phx-update="ignore"
data-timestamp={message_timestamp(message)}
data-relative="true"
data-format="LLL"
>
</span>
</div>
<p class="whitespace-pre-wrap break-words text-sm leading-6 text-base-content">{message.body}</p>
</div>
</article>
</div>
<%= cond do %>
<% @backend == :marmot -> %>
<div id="chat-marmot-disabled" class="border-t border-base-300 bg-base-100 p-5 text-center text-sm text-base-content/70">
Marmot chat is not enabled in the web UI yet.
</div>
<% @current_user -> %>
<form id="chat-message-form" phx-submit="send_message" phx-target={@myself} class="border-t border-base-300 bg-base-100 p-3 sm:p-4">
<div class="flex items-end gap-2 rounded-2xl border border-base-300 bg-base-200/60 p-2">
<label for="chat-message-body" class="sr-only">Message</label>
<textarea
id="chat-message-body"
name="message[body]"
rows="2"
class="textarea textarea-ghost min-h-12 flex-1 resize-none bg-transparent focus:outline-none"
placeholder={"Message #{if @channel, do: @channel.title, else: "chat"}"}
></textarea>
<button id="chat-send-button" type="submit" class="btn btn-primary rounded-full">
Send
</button>
</div>
</form>
<% true -> %>
<div id="chat-signed-out" class="border-t border-base-300 bg-base-100 p-5 text-center text-sm text-base-content/70">
Sign in to join the chat.
</div>
<% end %>
</div>
"""
end
defp reload_channel?(socket, slug, backend) do
channel = socket.assigns.channel
channel == nil || channel.slug != slug || channel.backend != backend
end
defp load_channel(socket, slug, backend) do
case Chat.ensure_conversation(%{slug: slug, title: channel_title(slug), backend: backend}) do
{:ok, %Channel{} = channel} ->
messages = load_messages(channel, socket.assigns)
message_ids = messages |> Enum.map(& &1.id) |> MapSet.new()
socket
|> assign(:channel, channel)
|> assign(:backend, channel.backend)
|> assign(:messages, messages)
|> assign(:message_ids, message_ids)
|> assign(:message_count, MapSet.size(message_ids))
{:error, reason} ->
socket
|> assign(:channel, nil)
|> assign(:backend, backend)
|> assign(:messages, [])
|> assign(:message_ids, MapSet.new())
|> assign(:message_count, 0)
|> assign(:chat_error, inspect(reason))
end
end
defp maybe_subscribe(
%{assigns: %{channel: %Channel{id: id}, subscribed_channel_id: id}} = socket
) do
socket
end
defp maybe_subscribe(%{assigns: %{channel: %Channel{} = channel}} = socket) do
if connected?(socket) do
_ = Chat.subscribe_conversation(channel, self(), chat_opts(socket.assigns))
assign(socket, :subscribed_channel_id, channel.id)
else
socket
end
end
defp maybe_subscribe(socket), do: socket
defp post_message(socket, body) do
user = socket.assigns.current_user
attrs = %{
body: body,
author_id: user_attr(user, :id),
author_pubkey: user_attr(user, :pubkey_hex) || user_attr(user, :pubkey),
metadata: %{"author_name" => user_attr(user, :username)}
}
case Chat.send_message(socket.assigns.channel, attrs, chat_opts(socket.assigns)) do
{:ok, message} ->
{:noreply, put_message(socket, message)}
{:error, reason} ->
{:noreply, put_flash(socket, :error, "Failed to send message: #{inspect(reason)}")}
end
end
defp put_message(socket, %Message{} = message) do
if socket.assigns.channel && message.channel_id == socket.assigns.channel.id do
append_message(socket, message)
else
socket
end
end
defp append_message(socket, %Message{} = message) do
if MapSet.member?(socket.assigns.message_ids, message.id) do
socket
else
message_ids = MapSet.put(socket.assigns.message_ids, message.id)
socket
|> assign(:messages, socket.assigns.messages ++ [message])
|> assign(:message_ids, message_ids)
|> assign(:message_count, MapSet.size(message_ids))
end
end
defp load_messages(%Channel{} = channel, assigns) do
case Chat.list_conversation_messages(channel, chat_opts(assigns)) do
{:ok, messages} -> messages
{:error, _reason} -> []
end
end
defp chat_opts(assigns) do
case Map.get(assigns, :session_privkey) do
privkey when is_binary(privkey) -> [session_privkey: privkey]
_other -> []
end
end
defp channel_title("general"), do: "General Chat"
defp channel_title(slug) do
slug
|> String.replace(["-", "_"], " ")
|> String.split(" ", trim: true)
|> Enum.map_join(" ", &String.capitalize/1)
end
defp user_attr(nil, _key), do: nil
defp user_attr(user, key), do: Map.get(user, key)
defp author_label(%Message{metadata: %{"author_name" => name}})
when is_binary(name) and name != "" do
name
end
defp author_label(%Message{author_pubkey: pubkey}) when is_binary(pubkey) and pubkey != "" do
String.slice(pubkey, 0, 8) <> ""
end
defp author_label(_message), do: "Unknown"
defp author_initial(message) do
message
|> author_label()
|> String.first()
|> Kernel.||("?")
|> String.upcase()
end
defp message_timestamp(%Message{inserted_at: %DateTime{} = inserted_at}) do
DateTime.to_unix(inserted_at, :millisecond)
end
defp message_timestamp(_message), do: DateTime.utc_now() |> DateTime.to_unix(:millisecond)
end
@@ -0,0 +1,139 @@
defmodule TribeOne.TribesPlugin.AetherWeb.ChatRecipientPickerComponent do
@moduledoc """
Recipient picker exposed as part of Aether's `org.tribe-one.caps.chat@1` UI contract.
"""
use Phoenix.LiveComponent
alias Tribes.Plugin.Services.Alliance
alias Tribes.Plugin.User
alias Tribes.Scope
@impl true
def update(assigns, socket) do
recipients = Map.get(assigns, :recipients) || load_recipients(Map.get(assigns, :current_user))
{:ok,
socket
|> assign(assigns)
|> assign_new(:query, fn -> "" end)
|> assign(:recipients, recipients)}
end
@impl true
def handle_event("search", %{"recipient" => %{"query" => query}}, socket) do
{:noreply, assign(socket, :query, String.trim(query || ""))}
end
def handle_event("select", %{"pubkey" => pubkey}, socket) do
case Enum.find(socket.assigns.recipients, &(&1.pubkey_hex == pubkey)) do
%User{} = user -> send(self(), {:aether_chat, :recipient_selected, user})
_other -> :ok
end
{:noreply, socket}
end
@impl true
def render(assigns) do
~H"""
<details id="chat-recipient-picker" class="dropdown dropdown-end">
<summary id="chat-recipient-picker-button" class="btn btn-primary btn-sm">
New message
</summary>
<div class="dropdown-content z-30 mt-2 w-80 rounded-box border border-base-300 bg-base-100 p-3 shadow-xl">
<form id="chat-recipient-search-form" phx-change="search" phx-target={@myself}>
<label class="input input-sm input-bordered flex items-center gap-2">
<span class="text-xs text-base-content/50">To</span>
<input
id="chat-recipient-search"
name="recipient[query]"
value={@query}
type="search"
class="grow"
placeholder="Search tribe users"
/>
</label>
</form>
<div id="chat-recipient-options" class="mt-3 max-h-72 space-y-1 overflow-y-auto">
<button
:for={recipient <- filtered_recipients(@recipients, @query, @current_user)}
id={"chat-recipient-#{recipient.id}"}
type="button"
class="flex w-full items-center gap-3 rounded-box px-3 py-2 text-left hover:bg-base-200"
phx-click="select"
phx-target={@myself}
phx-value-pubkey={recipient.pubkey_hex}
>
<span class="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-primary/10 text-sm font-semibold text-primary">
{recipient_initial(recipient)}
</span>
<span class="min-w-0">
<span class="block truncate text-sm font-medium text-base-content">{recipient_label(recipient)}</span>
<span class="block truncate text-xs text-base-content/50">{recipient_pubkey(recipient)}</span>
</span>
</button>
<p
:if={filtered_recipients(@recipients, @query, @current_user) == []}
id="chat-recipient-empty"
class="rounded-box border border-dashed border-base-300 p-4 text-center text-sm text-base-content/60"
>
No matching users.
</p>
</div>
</div>
</details>
"""
end
defp load_recipients(_current_user) do
scope = Scope.new()
with {:ok, tribe} when is_map(tribe) <- Alliance.local_tribe(scope),
{:ok, users} <- Alliance.list_tribe_users(scope, tribe.id) do
Enum.filter(users, &is_binary(&1.pubkey_hex))
else
_other -> []
end
end
defp filtered_recipients(recipients, query, current_user) do
current_pubkey = current_user && current_user.pubkey_hex
query = String.downcase(query || "")
recipients
|> Enum.reject(&(&1.pubkey_hex == current_pubkey))
|> Enum.filter(&matches_query?(&1, query))
end
defp matches_query?(_recipient, ""), do: true
defp matches_query?(recipient, query) do
[recipient.username, recipient.display_name, recipient.npub, recipient.pubkey_hex]
|> Enum.filter(&is_binary/1)
|> Enum.any?(&String.contains?(String.downcase(&1), query))
end
defp recipient_label(%User{display_name: display_name})
when is_binary(display_name) and display_name != "" do
display_name
end
defp recipient_label(%User{username: username}) when is_binary(username) and username != "",
do: username
defp recipient_label(%User{pubkey_hex: pubkey}), do: String.slice(pubkey, 0, 8) <> ""
defp recipient_pubkey(%User{npub: npub}) when is_binary(npub) and npub != "", do: npub
defp recipient_pubkey(%User{pubkey_hex: pubkey}), do: pubkey
defp recipient_initial(recipient) do
recipient
|> recipient_label()
|> String.first()
|> Kernel.||("?")
|> String.upcase()
end
end
+30
View File
@@ -0,0 +1,30 @@
defmodule TribeOne.TribesPlugin.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/aether.
"""
# In dev mode (plugin loaded as path dep), you can use host macros:
# use TribesWeb, :live_view
#
# For release builds (standalone OTP app), use Phoenix.LiveView directly:
use Phoenix.LiveView
def mount(_params, _session, socket) do
{:ok, assign(socket, :page_title, "My Plugin")}
end
def render(assigns) do
~H"""
<div class="p-6">
<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/aether_web/live/home_live.ex</code>.
</p>
</div>
"""
end
end
+339
View File
@@ -0,0 +1,339 @@
defmodule TribeOne.TribesPlugin.AetherWeb.TimelineLive do
use Phoenix.LiveView
on_mount({Tribes.Plugin.LiveUserAuth, :live_user_optional})
alias Tribes.Plugin.Layouts
alias Tribes.Plugin.Nostr.Profile
alias Tribes.Plugin.Services.Alliance
alias Tribes.Plugin.Services.Nostr
alias Tribes.Plugin.Services.Routes
alias Tribes.Scope
import Tribes.Plugin.Gettext
@impl true
def mount(_params, _session, socket) do
feed = load_feed()
notes = load_notes(feed.member_pubkeys)
profile_cache = load_profile_cache(notes)
note_ids = notes |> Enum.map(& &1.id) |> MapSet.new()
socket =
socket
|> assign(:page_title, "Aether")
|> assign(:tribe, feed.tribe)
|> assign(:tribe_member_pubkeys, feed.member_pubkeys)
|> assign(:profile_cache, profile_cache)
|> assign(:note_ids, note_ids)
|> assign(:note_count, MapSet.size(note_ids))
|> assign(:subscription_ref, nil)
|> stream_configure(:notes, dom_id: &("note-" <> &1.id))
|> stream(:notes, notes)
if connected?(socket) do
{:ok, ref} = subscribe_notes(feed.member_pubkeys)
{:ok, assign(socket, :subscription_ref, ref)}
else
{:ok, socket}
end
end
@impl true
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, "Sign in to post")}
content == "" ->
{:noreply, put_flash(socket, :error, "Post cannot be empty")}
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} ->
handle_published_note(socket, event)
{:error, reason} ->
{:noreply, put_flash(socket, :error, "Failed to publish: #{inspect(reason)}")}
end
_other ->
{:noreply, put_flash(socket, :error, "Signing key unavailable in session")}
end
end
end
@impl true
def handle_info({:parrhesia, :event, _ref, _sub_id, %{"kind" => 1} = raw_event}, socket) do
{:noreply, handle_note_event(socket, raw_event)}
end
def handle_info({:parrhesia, :events, _ref, _sub_id, events}, socket) when is_list(events) do
socket =
Enum.reduce(events, socket, fn
%{"kind" => 1} = raw_event, socket -> handle_note_event(socket, raw_event)
_raw_event, socket -> socket
end)
{:noreply, socket}
end
def handle_info({:parrhesia, :event, _ref, _sub_id, _event}, socket), do: {:noreply, socket}
def handle_info({:parrhesia, :events, _ref, _sub_id, _events}, socket), do: {:noreply, socket}
def handle_info({:parrhesia, :eose, _ref, _sub_id, _hints}, 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 socket.assigns[:subscription_ref] do
Nostr.unsubscribe(socket.assigns.subscription_ref)
end
:ok
end
defp handle_note_event(socket, raw_event) do
case Nostr.to_event(raw_event) do
{:ok, event} ->
if note_in_scope?(event, socket.assigns.tribe_member_pubkeys) do
profile_cache = maybe_put_profile(socket.assigns.profile_cache, event.pubkey)
if MapSet.member?(socket.assigns.note_ids, event.id) do
assign(socket, :profile_cache, profile_cache)
else
note_ids = MapSet.put(socket.assigns.note_ids, event.id)
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
socket
end
{:error, _reason} ->
socket
end
end
@impl true
def render(assigns) do
~H"""
<Layouts.app
flash={@flash}
current_scope={@current_scope}
>
<div class="space-y-6 p-6" id="aether-page">
<section class="rounded-3xl border border-base-300 bg-base-100/95 p-6 shadow-sm">
<div class="flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
<div class="space-y-2">
<p class="text-xs font-semibold uppercase tracking-[0.3em] text-base-content/50">
Local feed
</p>
<h1 class="text-3xl font-semibold tracking-tight text-base-content">
{timeline_title(@tribe)}
</h1>
<p class="max-w-2xl text-sm leading-6 text-base-content/70">
{timeline_description(@tribe)}
</p>
</div>
<div class="rounded-full border border-base-300 px-4 py-2 text-sm font-medium text-base-content/70">
{@note_count} notes
</div>
</div>
</section>
<%= if @current_user do %>
<form id="note-form" phx-submit="post" class="card bg-base-100 shadow-sm">
<div class="card-body">
<label class="fieldset mb-2" for="note-content">
<span class="label mb-1">Post</span>
<textarea
id="note-content"
name="note[content]"
rows="3"
class="textarea w-full"
placeholder="Share something with your local network"
></textarea>
</label>
<div class="card-actions justify-end">
<button id="post-note-button" type="submit" class="btn btn-primary btn-sm">
Post note
</button>
</div>
</div>
</form>
<% else %>
<div class="rounded-box border border-dashed border-base-300 bg-base-100 p-6 text-center text-sm text-base-content/70">
{tgettext("Sign in")} to publish notes from your local identity.
</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"
>
No local 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">
<a href={Routes.profile_path(note.pubkey)} class="link link-hover font-medium">
{author_label(@profile_cache, note.pubkey)}
</a>
<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 handle_published_note(socket, event) do
if note_in_scope?(event, socket.assigns.tribe_member_pubkeys) do
note_ids = MapSet.put(socket.assigns.note_ids, event.id)
{:noreply,
socket
|> assign(:note_ids, note_ids)
|> assign(:note_count, MapSet.size(note_ids))
|> stream_insert(:notes, event, at: 0)}
else
{:noreply, put_flash(socket, :info, "Posted outside the local tribe feed")}
end
end
defp load_feed do
scope = Scope.new()
with {:ok, tribe} when is_map(tribe) <- Alliance.local_tribe(scope),
{:ok, users} <- Alliance.list_tribe_users(scope, tribe.id) do
member_pubkeys =
users
|> Enum.map(& &1.pubkey_hex)
|> Enum.filter(&is_binary/1)
|> MapSet.new()
%{tribe: tribe, member_pubkeys: member_pubkeys}
else
_ -> %{tribe: nil, member_pubkeys: MapSet.new()}
end
end
defp load_notes(member_pubkeys) do
query_opts =
[limit: 100]
|> maybe_put_authors(member_pubkeys)
case Nostr.list_notes(query_opts) do
{:ok, notes} ->
notes
|> filter_notes(member_pubkeys)
|> sort_notes()
{:error, _reason} ->
[]
end
end
defp subscribe_notes(member_pubkeys) do
query_opts =
[limit: 100]
|> maybe_put_authors(member_pubkeys)
case Nostr.subscribe_notes(self(), "aether", query_opts) do
{:ok, ref} -> {:ok, ref}
{:error, _reason} -> {:ok, nil}
end
end
defp note_in_scope?(event, member_pubkeys) do
MapSet.member?(member_pubkeys, event.pubkey)
end
defp filter_notes(notes, member_pubkeys) do
Enum.filter(notes, &note_in_scope?(&1, member_pubkeys))
end
defp sort_notes(notes) do
Enum.sort_by(notes, fn note ->
{-note.created_at, note.id}
end)
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, %Profile{} = profile} -> Map.put(cache, pubkey, profile)
{:error, _reason} -> Map.put(cache, pubkey, nil)
end
end
end
defp author_label(profile_cache, pubkey) do
case Map.get(profile_cache, pubkey) do
%Profile{name: name} when is_binary(name) and name != "" -> name
_other -> "#{String.slice(pubkey, 0, 8)}"
end
end
defp timeline_title(nil), do: "Aether"
defp timeline_title(%{name: name}) when is_binary(name) do
"#{name} on Aether"
end
defp timeline_title(_tribe), do: "Aether"
defp timeline_description(%{name: name}) when is_binary(name) do
"Showing notes from #{name} members first. Global discovery can come later without losing the local trust graph."
end
defp timeline_description(_tribe) do
"Showing notes from local tribe members first. Sign in to publish from your local identity."
end
defp maybe_put_authors(opts, member_pubkeys) do
authors = MapSet.to_list(member_pubkeys)
if authors == [] do
opts
else
Keyword.put(opts, :authors, authors)
end
end
end
-33
View File
@@ -1,33 +0,0 @@
defmodule MyPlugin.Plugin do
@moduledoc """
Tribes plugin entry point.
"""
use Tribes.Plugin.Base, otp_app: :my_plugin
@impl true
def register(context) do
super(context)
|> Map.merge(%{
nav_items: [
%{
label: "My Plugin",
path: "/my_plugin",
icon: nil,
requires: [],
order: 50
}
],
pages: [
%{
path: "/my_plugin",
live_view: MyPluginWeb.HomeLive,
layout: nil
}
],
api_routes: [],
plugs: [],
hooks: %{}
})
end
end
-36
View File
@@ -1,36 +0,0 @@
defmodule MyPluginWeb.HomeLive do
@moduledoc """
Example LiveView page for the plugin.
This page is registered in the plugin spec, mounted by the host at
/my_plugin, and rendered inside the Tribes chrome by default.
"""
# In dev mode (plugin loaded as path dep), you can use host macros:
# use TribesWeb, :live_view
#
# For release builds (standalone OTP app), use Phoenix.LiveView directly:
use Phoenix.LiveView
on_mount({Tribes.Plugin.LiveUserAuth, :live_user_optional})
alias Tribes.Plugin.Layouts
def mount(_params, _session, socket) do
{:ok, assign(socket, :page_title, "My Plugin")}
end
def render(assigns) do
~H"""
<Layouts.app flash={@flash} current_scope={@current_scope}>
<div class="my-plugin p-6">
<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>.
</p>
</div>
</Layouts.app>
"""
end
end
-5
View File
@@ -1,5 +0,0 @@
defmodule Tribes.Plugins.MyPlugin.Plugin do
@moduledoc false
defdelegate register(context), to: MyPlugin.Plugin
end
+21 -10
View File
@@ -1,17 +1,28 @@
{
"name": "my_plugin",
"version": "0.1.0",
"description": "TODO: Describe what this plugin does",
"entry_module": "Tribes.Plugins.MyPlugin.Plugin",
"id": "org.tribe-one.plugins.aether",
"slug": "aether",
"display_name": "Aether",
"version": "0.2.0",
"description": "Local social feed and chat for Tribes, including NIP-17 direct messages.",
"entry_module": "TribeOne.TribesPlugin.Aether.Plugin",
"host_api": "1",
"otp_app": "my_plugin",
"provides": [],
"requires": ["ui@1"],
"otp_app": "tribe_one_aether",
"provides": [
"org.tribe-one.caps.social@1",
"org.tribe-one.caps.chat@1"
],
"requires": [
"org.tribe-one.caps.ui@1"
],
"enhances_with": [],
"assets": {
"global_js": ["my_plugin.js"],
"global_css": ["my_plugin.css"]
"global_js": [
"aether.js"
],
"global_css": [
"aether.css"
]
},
"migrations": false,
"migrations": true,
"children": false
}
+77
View File
@@ -0,0 +1,77 @@
;; Guix development manifest for the Aether plugin.
;;
;; Usage:
;; ./guix/guix-dev
;;
;; This intentionally keeps the repo-specific development package list here,
;; similar to devenv.nix. Shared custom packages such as node-24, prek, and
;; prettier come from the pinned guix-tribes channel.
(use-modules (gnu packages bash)
(gnu packages base)
(gnu packages elixir)
(gnu packages erlang)
(gnu packages gnome)
(gnu packages linux)
(gnu packages nss)
(gnu packages package-management)
(gnu packages version-control)
(guix build-system trivial)
(guix gexp)
((guix licenses) #:prefix license:)
(guix packages)
(guix profiles)
(tribes packages devtools)
((tribes packages node) #:select (node-24)))
(define guix-dev-script
(local-file "guix/guix-dev" "guix-dev-script"))
(define guix-dev-command-package
(package
(name "guix-dev-command")
(version "0.1")
(source #f)
(build-system trivial-build-system)
(arguments
(list
#:modules '((guix build utils))
#:builder
#~(begin
(use-modules (guix build utils))
(let* ((bin (string-append #$output "/bin"))
(target (string-append bin "/guix-dev")))
(mkdir-p bin)
(copy-file #$guix-dev-script target)
(substitute* target
(("^#!/usr/bin/env bash")
(string-append "#!" #$(file-append bash-minimal "/bin/bash"))))
(chmod target #o555)))))
(inputs (list bash-minimal))
(home-page "https://git.teralink.net/tribes/tribes-plugin-aether.git")
(synopsis "Pinned Guix development shell helper")
(description
"guix-dev re-enters this checkout's Guix development shell through its
repo-local guix/channels.scm time-machine pin and manifest.scm.")
(license license:asl2.0)))
(packages->manifest
(list bash
coreutils
findutils
grep
sed
nss-certs
git
guix
guix-dev-command-package
pre-commit
prek
prettier
erlang
elixir
elixir-hex
rebar3
node-24
libnotify
inotify-tools))
+6 -6
View File
@@ -1,10 +1,10 @@
defmodule MyPlugin.MixProject do
defmodule TribeOne.TribesPlugin.Aether.MixProject do
use Mix.Project
def project do
[
app: :my_plugin,
version: "0.1.0",
app: :tribe_one_aether,
version: "0.2.0",
elixir: "~> 1.18",
elixirc_paths: elixirc_paths(Mix.env()),
start_permanent: Mix.env() == :prod,
@@ -24,7 +24,7 @@ defmodule MyPlugin.MixProject do
[
extra_applications: [:logger]
# Uncomment if your plugin needs its own supervision tree:
# mod: {MyPlugin.Application, []}
# mod: {TribeOne.TribesPlugin.Aether.Application, []}
]
end
@@ -40,12 +40,12 @@ defmodule MyPlugin.MixProject do
{:tribes_plugin_api, path: "../tribes/tribes_plugin_api", runtime: false},
{:tribes_plugin, path: "../tribes-plugin-new", only: [:dev, :test], runtime: false},
{:igniter, "~> 0.7", only: [:dev, :test], runtime: false},
{:usage_rules, "~> 1.2", only: :dev},
{:credo, "~> 1.7", only: [:dev, :test], runtime: false},
{:lazy_html, ">= 0.1.0", only: :test},
{:phoenix, "~> 1.8"},
{:phoenix_html, "~> 4.1"},
{:phoenix_live_view, "~> 1.1.0"},
{:usage_rules, "~> 1.2", only: :dev}
{:phoenix_live_view, "~> 1.1.0"}
] ++ tribes_deps(Mix.env())
end
+28 -28
View File
@@ -1,34 +1,34 @@
%{
"absinthe": {:hex, :absinthe, "1.10.0", "58e4923c2a96bf12cba9aad9298f36f624533039f2e5305badf0d60904eee0a0", [:mix], [{:dataloader, "~> 1.0.0 or ~> 2.0", [hex: :dataloader, repo: "hexpm", optional: true]}, {:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}, {:nimble_parsec, "~> 1.2.2 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}, {:opentelemetry_process_propagator, "~> 0.2.1 or ~> 0.3", [hex: :opentelemetry_process_propagator, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "6810d21f13a2b4783d1b7f468c63a5174d769a3035f188e3fc93f440128a46af"},
"absinthe": {:hex, :absinthe, "1.9.1", "19fe8614d5cdabefaf127ee224cb89eceea48314de4d709737451b43b5bdedd5", [:mix], [{:dataloader, "~> 1.0.0 or ~> 2.0", [hex: :dataloader, repo: "hexpm", optional: true]}, {:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}, {:nimble_parsec, "~> 1.2.2 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}, {:opentelemetry_process_propagator, "~> 0.2.1 or ~> 0.3", [hex: :opentelemetry_process_propagator, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "d93e1aa61d68b974f48d5660104cb911ae045ee3a5d69954d251f91f3dbe2077"},
"absinthe_phoenix": {:hex, :absinthe_phoenix, "2.0.4", "f36999412fbd6a2339abb5b7e24a4cc9492bbc7909d5806deeef83b06f55c508", [:mix], [{:absinthe, "~> 1.5", [hex: :absinthe, repo: "hexpm", optional: false]}, {:absinthe_plug, "~> 1.5", [hex: :absinthe_plug, repo: "hexpm", optional: false]}, {:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.5", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.13 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.0", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}], "hexpm", "66617ee63b725256ca16264364148b10b19e2ecb177488cd6353584f2e6c1cf3"},
"absinthe_plug": {:hex, :absinthe_plug, "1.5.9", "4f66fd46aecf969b349dd94853e6132db6d832ae6a4b951312b6926ad4ee7ca3", [:mix], [{:absinthe, "~> 1.7", [hex: :absinthe, repo: "hexpm", optional: false]}, {:plug, "~> 1.4", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "dcdc84334b0e9e2cd439bd2653678a822623f212c71088edf0a4a7d03f1fa225"},
"argon2_elixir": {:hex, :argon2_elixir, "4.1.3", "4f28318286f89453364d7fbb53e03d4563fd7ed2438a60237eba5e426e97785f", [:make, :mix], [{:comeonin, "~> 5.3", [hex: :comeonin, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "7c295b8d8e0eaf6f43641698f962526cdf87c6feb7d14bd21e599271b510608c"},
"ash": {:hex, :ash, "3.24.3", "f7280a43c5e64f769a450f3dd59ace6dcd73edcdd0de7599815b1b31f59292fb", [:mix], [{:crux, ">= 0.1.2 and < 1.0.0-0", [hex: :crux, repo: "hexpm", optional: false]}, {:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:ecto, "~> 3.7", [hex: :ecto, repo: "hexpm", optional: false]}, {:ets, "~> 0.8", [hex: :ets, repo: "hexpm", optional: false]}, {:igniter, ">= 0.6.29 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, ">= 1.0.0", [hex: :jason, repo: "hexpm", optional: false]}, {:picosat_elixir, "~> 0.2", [hex: :picosat_elixir, repo: "hexpm", optional: true]}, {:plug, ">= 0.0.0", [hex: :plug, repo: "hexpm", optional: true]}, {:reactor, "~> 1.0", [hex: :reactor, repo: "hexpm", optional: false]}, {:simple_sat, ">= 0.1.1 and < 1.0.0-0", [hex: :simple_sat, repo: "hexpm", optional: true]}, {:spark, ">= 2.6.0", [hex: :spark, repo: "hexpm", optional: false]}, {:splode, "~> 0.3", [hex: :splode, repo: "hexpm", optional: false]}, {:stream_data, "~> 1.0", [hex: :stream_data, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.1", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "c1022f8c549632137cbc8956f07bb4981405297f5abe7a752b4dffac175c3381"},
"ash": {:hex, :ash, "3.21.3", "4cb8b05655664fe65daf6862285f0a6f91991f1a73ff6554b4609a484c1ceb8b", [:mix], [{:crux, ">= 0.1.2 and < 1.0.0-0", [hex: :crux, repo: "hexpm", optional: false]}, {:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:ecto, "~> 3.7", [hex: :ecto, repo: "hexpm", optional: false]}, {:ets, "~> 0.8", [hex: :ets, repo: "hexpm", optional: false]}, {:igniter, ">= 0.6.29 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, ">= 1.0.0", [hex: :jason, repo: "hexpm", optional: false]}, {:picosat_elixir, "~> 0.2", [hex: :picosat_elixir, repo: "hexpm", optional: true]}, {:plug, ">= 0.0.0", [hex: :plug, repo: "hexpm", optional: true]}, {:reactor, "~> 1.0", [hex: :reactor, repo: "hexpm", optional: false]}, {:simple_sat, ">= 0.1.1 and < 1.0.0-0", [hex: :simple_sat, repo: "hexpm", optional: true]}, {:spark, ">= 2.6.0", [hex: :spark, repo: "hexpm", optional: false]}, {:splode, "~> 0.3", [hex: :splode, repo: "hexpm", optional: false]}, {:stream_data, "~> 1.0", [hex: :stream_data, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.1", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "deec4223f11c7cbf3b35cb14b3c8df50b58b06a5c9f64de539a93469fa249b04"},
"ash_authentication": {:hex, :ash_authentication, "4.13.7", "421b5ddb516026f6794435980a632109ec116af2afa68a45e15fb48b41c92cfa", [:mix], [{:argon2_elixir, "~> 4.0", [hex: :argon2_elixir, repo: "hexpm", optional: true]}, {:ash, "~> 3.7", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_postgres, ">= 2.6.8 and < 3.0.0-0", [hex: :ash_postgres, repo: "hexpm", optional: true]}, {:assent, "> 0.2.0 and < 0.3.0", [hex: :assent, repo: "hexpm", optional: false]}, {:bcrypt_elixir, "~> 3.0", [hex: :bcrypt_elixir, repo: "hexpm", optional: false]}, {:castore, "~> 1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:finch, "~> 0.19", [hex: :finch, repo: "hexpm", optional: false]}, {:igniter, "~> 0.4", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:joken, "~> 2.5", [hex: :joken, repo: "hexpm", optional: false]}, {:plug, "~> 1.13", [hex: :plug, repo: "hexpm", optional: false]}, {:spark, "~> 2.0", [hex: :spark, repo: "hexpm", optional: false]}, {:splode, "~> 0.2", [hex: :splode, repo: "hexpm", optional: false]}], "hexpm", "0d45ac3fdcca6902dabbe161ce63e9cea8f90583863c2e14261c9309e5837121"},
"ash_authentication_phoenix": {:hex, :ash_authentication_phoenix, "2.16.0", "02045ecde9eeb30ab1bfffbdf693c64426af24902bcd533765eba725b9b9f46f", [:mix], [{:ash, "~> 3.0", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_authentication, "~> 4.10", [hex: :ash_authentication, repo: "hexpm", optional: false]}, {:ash_phoenix, ">= 2.3.11 and < 3.0.0-0", [hex: :ash_phoenix, repo: "hexpm", optional: false]}, {:bcrypt_elixir, "~> 3.0", [hex: :bcrypt_elixir, repo: "hexpm", optional: false]}, {:gettext, "~> 0.26 or ~> 1.0", [hex: :gettext, repo: "hexpm", optional: true]}, {:igniter, ">= 0.5.25 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.6", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_html_helpers, "~> 1.0", [hex: :phoenix_html_helpers, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 1.1", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: false]}, {:slugify, "~> 1.3", [hex: :slugify, repo: "hexpm", optional: false]}], "hexpm", "1107a45af771ee7c02ebe82abcaf9a778096e66b3e6cb2b6e614d22d1fe385f7"},
"ash_graphql": {:hex, :ash_graphql, "1.9.4", "3e75eaa0917e1085c938aadfec8205cb028a161842d13b9304475f56a8b1c6a0", [:mix], [{:absinthe, "~> 1.7", [hex: :absinthe, repo: "hexpm", optional: false]}, {:absinthe_phoenix, "~> 2.0", [hex: :absinthe_phoenix, repo: "hexpm", optional: true]}, {:absinthe_plug, "~> 1.4", [hex: :absinthe_plug, repo: "hexpm", optional: false]}, {:ash, ">= 3.5.13 and < 4.0.0-0", [hex: :ash, repo: "hexpm", optional: false]}, {:igniter, ">= 0.5.28 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.2", [hex: :jason, repo: "hexpm", optional: false]}, {:spark, ">= 2.2.10", [hex: :spark, repo: "hexpm", optional: false]}], "hexpm", "da3a7b3026ca31b006bf675f6dcbe49c4575c42cad794684373fc7a51893165b"},
"ash_phoenix": {:hex, :ash_phoenix, "2.3.21", "757e227e4d835b748a31932305e307e2db3bd3229c219ddad0277f6598d3e392", [:mix], [{:ash, ">= 3.5.13 and < 4.0.0-0", [hex: :ash, repo: "hexpm", optional: false]}, {:igniter, "~> 0.6", [hex: :igniter, repo: "hexpm", optional: true]}, {:inertia, "~> 2.3", [hex: :inertia, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.5.6 or ~> 1.6", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.20.3 or ~> 1.0-rc.1", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:spark, ">= 2.2.29 and < 3.0.0-0", [hex: :spark, repo: "hexpm", optional: false]}], "hexpm", "4e28b92709e86856927c7c32effa9336f0aa3daec769883993bf42ba49cce90f"},
"ash_postgres": {:hex, :ash_postgres, "2.9.0", "5867a8f91f136add9b11fb44c22f7636538ed29ff8601da80470cfe792b81cf2", [:mix], [{:ash, "~> 3.24", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_sql, "~> 0.6", [hex: :ash_sql, repo: "hexpm", optional: false]}, {:ecto, "~> 3.13", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.13", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:igniter, ">= 0.6.29 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:postgrex, ">= 0.0.0", [hex: :postgrex, repo: "hexpm", optional: false]}, {:spark, ">= 2.3.4 and < 3.0.0-0", [hex: :spark, repo: "hexpm", optional: false]}], "hexpm", "81f284b7550b9b9cd19bf2a68abc222066a8e6e3c4e88143c9c39ede37df6b42"},
"ash_sql": {:hex, :ash_sql, "0.6.1", "d2788d020a5fe7b1052cdf529f97937979634e912baaea957b750b1f8f38ba52", [:mix], [{:ash, "~> 3.24", [hex: :ash, repo: "hexpm", optional: false]}, {:ecto, ">= 3.13.4 and < 4.0.0-0", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.9", [hex: :ecto_sql, repo: "hexpm", optional: false]}], "hexpm", "16efb0a5551269faafb32f173c144fbf85cce1576206005f6dbf244b6b403969"},
"ash_authentication_phoenix": {:hex, :ash_authentication_phoenix, "2.15.0", "89e71e96a3d954aed7ed0c1f511d42cbfd19009b813f580b12749b01bbea5148", [:mix], [{:ash, "~> 3.0", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_authentication, "~> 4.10", [hex: :ash_authentication, repo: "hexpm", optional: false]}, {:ash_phoenix, ">= 2.3.11 and < 3.0.0-0", [hex: :ash_phoenix, repo: "hexpm", optional: false]}, {:bcrypt_elixir, "~> 3.0", [hex: :bcrypt_elixir, repo: "hexpm", optional: false]}, {:gettext, "~> 0.26 or ~> 1.0", [hex: :gettext, repo: "hexpm", optional: true]}, {:igniter, ">= 0.5.25 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.6", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_html_helpers, "~> 1.0", [hex: :phoenix_html_helpers, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 1.1", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: false]}, {:slugify, "~> 1.3", [hex: :slugify, repo: "hexpm", optional: false]}], "hexpm", "d2da66dcf62bc1054ce8f5d9c2829b1dff1dbc3f1d03f9ef0cbe89123d7df107"},
"ash_graphql": {:hex, :ash_graphql, "1.9.3", "02d5a8e34a9fb22267513dab6e91380d5aaaffb9d8403d78a26cf1eba655f743", [:mix], [{:absinthe, "~> 1.7", [hex: :absinthe, repo: "hexpm", optional: false]}, {:absinthe_phoenix, "~> 2.0", [hex: :absinthe_phoenix, repo: "hexpm", optional: true]}, {:absinthe_plug, "~> 1.4", [hex: :absinthe_plug, repo: "hexpm", optional: false]}, {:ash, ">= 3.5.13 and < 4.0.0-0", [hex: :ash, repo: "hexpm", optional: false]}, {:igniter, ">= 0.5.28 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.2", [hex: :jason, repo: "hexpm", optional: false]}, {:spark, ">= 2.2.10", [hex: :spark, repo: "hexpm", optional: false]}], "hexpm", "dc3a8dbab4858a655c3f58813f1e1d8f815886d8eb0a9536601c52ceddbab521"},
"ash_phoenix": {:hex, :ash_phoenix, "2.3.20", "022682396892046f48dc35a137bbea9c1e4c6a6d58e71d795defd2f071c3b138", [:mix], [{:ash, ">= 3.5.13 and < 4.0.0-0", [hex: :ash, repo: "hexpm", optional: false]}, {:igniter, "~> 0.6", [hex: :igniter, repo: "hexpm", optional: true]}, {:inertia, "~> 2.3", [hex: :inertia, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.5.6 or ~> 1.6", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.20.3 or ~> 1.0-rc.1", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:spark, ">= 2.2.29 and < 3.0.0-0", [hex: :spark, repo: "hexpm", optional: false]}], "hexpm", "0655a90b042a5e8873b32ba2f0b52c7c9b8da0fd415518bef41ac03a7b07e02e"},
"ash_postgres": {:hex, :ash_postgres, "2.8.0", "bf1a30c57b15ae3622f1cea34959b3a11c33f0f4319830dd28235a3c0c79f647", [:mix], [{:ash, "~> 3.19", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_sql, ">= 0.4.3 and < 1.0.0-0", [hex: :ash_sql, repo: "hexpm", optional: false]}, {:ecto, "~> 3.13", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.13", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:igniter, ">= 0.6.29 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:postgrex, ">= 0.0.0", [hex: :postgrex, repo: "hexpm", optional: false]}, {:spark, ">= 2.3.4 and < 3.0.0-0", [hex: :spark, repo: "hexpm", optional: false]}], "hexpm", "06cd4278ccc838104fdbc92e0f9508cf75c4f3376a922ec7e6d85bc7192d616d"},
"ash_sql": {:hex, :ash_sql, "0.5.1", "f4fcaa29308bdf417fe85373e57fb6d868b7db1e7ccc2fae5cc7f8f92016d622", [:mix], [{:ash, "~> 3.7", [hex: :ash, repo: "hexpm", optional: false]}, {:ecto, ">= 3.13.4 and < 4.0.0-0", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.9", [hex: :ecto_sql, repo: "hexpm", optional: false]}], "hexpm", "33febc8a84443683c3685a2d65f674391d8f21c073555d7c22d6c484f72faa65"},
"assent": {:hex, :assent, "0.2.13", "11226365d2d8661d23e9a2cf94d3255e81054ff9d88ac877f28bfdf38fa4ef31", [:mix], [{:certifi, ">= 0.0.0", [hex: :certifi, repo: "hexpm", optional: true]}, {:finch, "~> 0.15", [hex: :finch, repo: "hexpm", optional: true]}, {:jose, "~> 1.8", [hex: :jose, repo: "hexpm", optional: true]}, {:mint, "~> 1.0", [hex: :mint, repo: "hexpm", optional: true]}, {:req, "~> 0.4", [hex: :req, repo: "hexpm", optional: true]}, {:ssl_verify_fun, ">= 0.0.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: true]}], "hexpm", "bf9f351b01dd6bceea1d1f157f05438f6765ce606e6eb8d29296003d29bf6eab"},
"bandit": {:hex, :bandit, "1.10.4", "02b9734c67c5916a008e7eb7e2ba68aaea6f8177094a5f8d95f1fb99069aac17", [:mix], [{:hpax, "~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.18", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.0", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "a5faf501042ac1f31d736d9d4a813b3db4ef812e634583b6a457b0928798a51d"},
"bandit": {:hex, :bandit, "1.11.1", "1eb33123cc3c17ae0c3447874eb83399ee530f960c39711ed240342fbd4865fa", [:mix], [{:hpax, "~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.18", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.0", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "d4401016df9abbc6dcd325c0b78b2b193e7c7c96bb68f31e576112be025d84a5"},
"bcrypt_elixir": {:hex, :bcrypt_elixir, "3.3.2", "d50091e3c9492d73e17fc1e1619a9b09d6a5ef99160eb4d736926fd475a16ca3", [:make, :mix], [{:comeonin, "~> 5.3", [hex: :comeonin, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "471be5151874ae7931911057d1467d908955f93554f7a6cd1b7d804cac8cef53"},
"bech32": {:hex, :bech32, "1.0.0", "85a3bb58c408d735b5becb39e8a23536660ec0df1ef0afee72377c130939de1b", [:mix], [], "hexpm", "f781b8524c30a524922613d97c1858c27bd9f639b4e6b350f4a4843ee97607d3"},
"bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"},
"castore": {:hex, :castore, "1.0.18", "5e43ef0ec7d31195dfa5a65a86e6131db999d074179d2ba5a8de11fe14570f55", [:mix], [], "hexpm", "f393e4fe6317829b158fb74d86eb681f737d2fe326aa61ccf6293c4104957e34"},
"castore": {:hex, :castore, "1.0.19", "6903cabdfd9d1af46454126e7c8385186659dd33ecfb74a885cae52221ad6109", [:mix], [], "hexpm", "3669e6cab13f54c2df26b3e6833745d647f35b6e30d8ddd5975df0d5c842ca98"},
"cc_precompiler": {:hex, :cc_precompiler, "0.1.11", "8c844d0b9fb98a3edea067f94f616b3f6b29b959b6b3bf25fee94ffe34364768", [:mix], [{:elixir_make, "~> 0.7", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "3427232caf0835f94680e5bcf082408a70b48ad68a5f5c0b02a3bea9f3a075b9"},
"cldr_utils": {:hex, :cldr_utils, "2.29.5", "f43161e04acb4016f5841b2320d69120d51827f5346babb2227893a2c5916dc8", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:certifi, "~> 2.5", [hex: :certifi, repo: "hexpm", optional: true]}, {:decimal, "~> 1.9 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}], "hexpm", "962d3a2028b232ee0a5373941dc411028a9442f53444a4d5d2c354f687db1835"},
"comeonin": {:hex, :comeonin, "5.5.1", "5113e5f3800799787de08a6e0db307133850e635d34e9fab23c70b6501669510", [:mix], [], "hexpm", "65aac8f19938145377cee73973f192c5645873dcf550a8a6b18187d17c13ccdb"},
"cowboy": {:hex, :cowboy, "2.14.2", "4008be1df6ade45e4f2a4e9e2d22b36d0b5aba4e20b0a0d7049e28d124e34847", [:make, :rebar3], [{:cowlib, ">= 2.16.0 and < 3.0.0", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, ">= 1.8.0 and < 3.0.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "569081da046e7b41b5df36aa359be71a0c8874e5b9cff6f747073fc57baf1ab9"},
"cowboy": {:hex, :cowboy, "2.15.0", "9cfe86ed7117bf045e10adbedb0170af7be57f2a3637e7be143433d8dd267396", [:make, :rebar3], [{:cowlib, ">= 2.16.0 and < 3.0.0", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, ">= 1.8.0 and < 3.0.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "179fb65140fb440a17b767ad53b755081506f9596c4db5c49c0396d8c8643668"},
"cowboy_telemetry": {:hex, :cowboy_telemetry, "0.4.0", "f239f68b588efa7707abce16a84d0d2acf3a0f50571f8bb7f56a15865aae820c", [:rebar3], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7d98bac1ee4565d31b62d59f8823dfd8356a169e7fcbb83831b8a5397404c9de"},
"cowlib": {:hex, :cowlib, "2.16.0", "54592074ebbbb92ee4746c8a8846e5605052f29309d3a873468d76cdf932076f", [:make, :rebar3], [], "hexpm", "7f478d80d66b747344f0ea7708c187645cfcc08b11aa424632f78e25bf05db51"},
"cowlib": {:hex, :cowlib, "2.16.1", "318d385d55f657e9a5005838c4e426e13dcd724a691438384b6165a69687e531", [:make, :rebar3], [], "hexpm", "58f1e425a9e04176f1d30e20116f57c4e90ef0e187552e9741c465bdf4044f70"},
"credo": {:hex, :credo, "1.7.18", "5c5596bf7aedf9c8c227f13272ac499fe8eae6237bd326f2f07dfc173786f042", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "a189d164685fd945809e862fe76a7420c4398fa288d76257662aecb909d6b3e5"},
"crux": {:hex, :crux, "0.1.2", "4441c9e3a34f1e340954ce96b9ad5a2de13ceb4f97b3f910211227bb92e2ca90", [:mix], [{:picosat_elixir, "~> 0.2", [hex: :picosat_elixir, repo: "hexpm", optional: true]}, {:simple_sat, ">= 0.1.1 and < 1.0.0-0", [hex: :simple_sat, repo: "hexpm", optional: true]}, {:stream_data, "~> 1.0", [hex: :stream_data, repo: "hexpm", optional: true]}], "hexpm", "563ea3748ebfba9cc078e6d198a1d6a06015a8fae503f0b721363139f0ddb350"},
"db_connection": {:hex, :db_connection, "2.10.0", "8ff756471e41765bd5563b633f73e9a94bbc138816e8644bb17d0d91bf260a95", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "02cdd01b45efb1b550e68edbbea41be32de9b24bb07e1ea0e9cbc522ac377e54"},
"decimal": {:hex, :decimal, "2.3.0", "3ad6255aa77b4a3c4f818171b12d237500e63525c2fd056699967a3e7ea20f62", [:mix], [], "hexpm", "a4d66355cb29cb47c3cf30e71329e58361cfcb37c34235ef3bf1d7bf3773aeac"},
"db_connection": {:hex, :db_connection, "2.10.1", "d5465f6bcc125c1b8981c1dbf23c193ca16f446ec0b25832dc174f74f18be510", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "18ed94c6e627b4bf452dbd4df61b69a35a1e768525140bc1917b7a685026a6a3"},
"decimal": {:hex, :decimal, "2.4.1", "6c0fbede12fb122ba685e9ab41c6a40c129e322b3aa192f9e072e61f3a6ffaf2", [:mix], [], "hexpm", "7e618897933a8455f19a727d7c5e50a2c071a544b700e5e724298ecb4340187f"},
"digital_token": {:hex, :digital_token, "1.0.0", "454a4444061943f7349a51ef74b7fb1ebd19e6a94f43ef711f7dae88c09347df", [:mix], [{:cldr_utils, "~> 2.17", [hex: :cldr_utils, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm", "8ed6f5a8c2fa7b07147b9963db506a1b4c7475d9afca6492136535b064c9e9e6"},
"dns_cluster": {:hex, :dns_cluster, "0.2.0", "aa8eb46e3bd0326bd67b84790c561733b25c5ba2fe3c7e36f28e88f384ebcb33", [:mix], [], "hexpm", "ba6f1893411c69c01b9e8e8f772062535a4cf70f3f35bcc964a324078d8c8240"},
"ecto": {:hex, :ecto, "3.13.5", "9d4a69700183f33bf97208294768e561f5c7f1ecf417e0fa1006e4a91713a834", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "df9efebf70cf94142739ba357499661ef5dbb559ef902b68ea1f3c1fabce36de"},
"ecto": {:hex, :ecto, "3.13.6", "352135b474f91d1ab99a1b502171d207e9db60421c9e3d0ecab4c7ab96b24d14", [:mix], [{:decimal, "~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "8afa059bc16cd2c94739ec0a11e3e5df69d828125119109bef35f20a21a76af2"},
"ecto_sql": {:hex, :ecto_sql, "3.13.5", "2f8282b2ad97bf0f0d3217ea0a6fff320ead9e2f8770f810141189d182dc304e", [:mix], [{:db_connection, "~> 2.4.1 or ~> 2.5", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.13.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.7", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.19 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "aa36751f4e6a2b56ae79efb0e088042e010ff4935fc8684e74c23b1f49e25fdc"},
"elixir_make": {:hex, :elixir_make, "0.9.0", "6484b3cd8c0cee58f09f05ecaf1a140a8c97670671a6a0e7ab4dc326c3109726", [:mix], [], "hexpm", "db23d4fd8b757462ad02f8aa73431a426fe6671c80b200d9710caf3d1dd0ffdb"},
"esbuild": {:hex, :esbuild, "0.10.0", "b0aa3388a1c23e727c5a3e7427c932d89ee791746b0081bbe56103e9ef3d291f", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "468489cda427b974a7cc9f03ace55368a83e1a7be12fba7e30969af78e5f8c70"},
@@ -40,53 +40,53 @@
"ex_cldr_numbers": {:hex, :ex_cldr_numbers, "2.38.1", "e5124e288a8e672831e10d39530ecb5329bc9af2169709ebfbadc814cae7d4fb", [:mix], [{:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:digital_token, "~> 0.3 or ~> 1.0", [hex: :digital_token, repo: "hexpm", optional: false]}, {:ex_cldr, "~> 2.45", [hex: :ex_cldr, repo: "hexpm", optional: false]}, {:ex_cldr_currencies, "~> 2.17", [hex: :ex_cldr_currencies, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm", "4f95738f1dc4e821485e52226666f7691c9276bf6eba49cba8d23c8a2db05e84"},
"expo": {:hex, :expo, "1.1.1", "4202e1d2ca6e2b3b63e02f69cfe0a404f77702b041d02b58597c00992b601db5", [:mix], [], "hexpm", "5fb308b9cb359ae200b7e23d37c76978673aa1b06e2b3075d814ce12c5811640"},
"file_system": {:hex, :file_system, "1.1.1", "31864f4685b0148f25bd3fbef2b1228457c0c89024ad67f7a81a3ffbc0bbad3a", [:mix], [], "hexpm", "7a15ff97dfe526aeefb090a7a9d3d03aa907e100e262a0f8f7746b78f8f87a5d"},
"finch": {:hex, :finch, "0.21.0", "b1c3b2d48af02d0c66d2a9ebfb5622be5c5ecd62937cf79a88a7f98d48a8290c", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.6.2 or ~> 1.7", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "87dc6e169794cb2570f75841a19da99cfde834249568f2a5b121b809588a4377"},
"fine": {:hex, :fine, "0.1.6", "4bf7151493443c454aac9f2fa2f34f5fefd0346a83fb5586a016c4a135c63247", [:mix], [], "hexpm", "5638eb4495488e885ebec167fa57973e5c35e1a50c344eb7666c90ec1c4e3b12"},
"finch": {:hex, :finch, "0.22.0", "5c48fa6f9706a78eb9036cacb67b8b996b4e66d111c543f4c29bb0f879a6806b", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.8", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "b94e83c47780fc6813f746a1f1a34ee65cda42da4c5ea26a68f0acc4498e23dc"},
"fine": {:hex, :fine, "0.1.4", "b19a89c1476c7c57afb5f9314aed5960b5bc95d5277de4cb5ee8e1d1616ce379", [:mix], [], "hexpm", "be3324cc454a42d80951cf6023b9954e9ff27c6daa255483b3e8d608670303f5"},
"gettext": {:hex, :gettext, "1.0.2", "5457e1fd3f4abe47b0e13ff85086aabae760497a3497909b8473e0acee57673b", [:mix], [{:expo, "~> 0.5.1 or ~> 1.0", [hex: :expo, repo: "hexpm", optional: false]}], "hexpm", "eab805501886802071ad290714515c8c4a17196ea76e5afc9d06ca85fb1bfeb3"},
"glob_ex": {:hex, :glob_ex, "0.1.11", "cb50d3f1ef53f6ca04d6252c7fde09fd7a1cf63387714fe96f340a1349e62c93", [:mix], [], "hexpm", "342729363056e3145e61766b416769984c329e4378f1d558b63e341020525de4"},
"hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"},
"idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"},
"igniter": {:hex, :igniter, "0.7.9", "8c573440b8127fd80be8220fb197e7422317a81072054fcc0b336029f035a416", [:mix], [{:glob_ex, "~> 0.1.7", [hex: :glob_ex, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:owl, "~> 0.11", [hex: :owl, repo: "hexpm", optional: false]}, {:phx_new, "~> 1.7", [hex: :phx_new, repo: "hexpm", optional: true]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}, {:rewrite, ">= 1.1.1 and < 2.0.0-0", [hex: :rewrite, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.4", [hex: :sourceror, repo: "hexpm", optional: false]}, {:spitfire, ">= 0.1.3 and < 1.0.0-0", [hex: :spitfire, repo: "hexpm", optional: false]}], "hexpm", "123513d09f3af149db851aad8492b5b49f861d2c466a72031b2a0cbd9f45526f"},
"iterex": {:hex, :iterex, "0.1.2", "58f9b9b9a22a55cbfc7b5234a9c9c63eaac26d276b3db80936c0e1c60355a5a6", [:mix], [], "hexpm", "2e103b8bcc81757a9af121f6dc0df312c9a17220f302b1193ef720460d03029d"},
"jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"},
"jason": {:hex, :jason, "1.4.5", "2e3a008590b0b8d7388c20293e9dcc9cf3e5d642fd2a114e4cbbb52e595d940a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "b0c823996102bcd0239b3c2444eb00409b72f6a140c1950bc8b457d836b30684"},
"joken": {:hex, :joken, "2.6.2", "5daaf82259ca603af4f0b065475099ada1b2b849ff140ccd37f4b6828ca6892a", [:mix], [{:jose, "~> 1.11.10", [hex: :jose, repo: "hexpm", optional: false]}], "hexpm", "5134b5b0a6e37494e46dbf9e4dad53808e5e787904b7c73972651b51cce3d72b"},
"jose": {:hex, :jose, "1.11.12", "06e62b467b61d3726cbc19e9b5489f7549c37993de846dfb3ee8259f9ed208b3", [:mix, :rebar3], [], "hexpm", "31e92b653e9210b696765cdd885437457de1add2a9011d92f8cf63e4641bab7b"},
"lazy_html": {:hex, :lazy_html, "0.1.11", "136c8e9cd616b4f4e9c1562daa683880891120b759606dc4c3b6b18058ba5d79", [:make, :mix], [{:cc_precompiler, "~> 0.1", [hex: :cc_precompiler, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.9.0", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:fine, "~> 0.1.0", [hex: :fine, repo: "hexpm", optional: false]}], "hexpm", "3b1be592929c31eca1a21673d25696e5c14cddfe922d9d1a3e3b48be4163883b"},
"lazy_html": {:hex, :lazy_html, "0.1.10", "ffe42a0b4e70859cf21a33e12a251e0c76c1dff76391609bd56702a0ef5bc429", [:make, :mix], [{:cc_precompiler, "~> 0.1", [hex: :cc_precompiler, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.9.0", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:fine, "~> 0.1.0", [hex: :fine, repo: "hexpm", optional: false]}], "hexpm", "50f67e5faa09d45a99c1ddf3fac004f051997877dc8974c5797bb5ccd8e27058"},
"lib_secp256k1": {:hex, :lib_secp256k1, "0.7.1", "53cad778b8da3a29e453a7a477517d99fb5f13f615c8050eb2db8fd1dce7a1db", [:make, :mix], [{:elixir_make, "~> 0.9", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "78bdd3661a17448aff5aeec5ca74c8ddbc09b01f0ecfa3ba1aba3e8ae47ab2b3"},
"libgraph": {:hex, :libgraph, "0.16.0", "3936f3eca6ef826e08880230f806bfea13193e49bf153f93edcf0239d4fd1d07", [:mix], [], "hexpm", "41ca92240e8a4138c30a7e06466acc709b0cbb795c643e9e17174a178982d6bf"},
"mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"},
"mint": {:hex, :mint, "1.7.1", "113fdb2b2f3b59e47c7955971854641c61f378549d73e829e1768de90fc1abf1", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "fceba0a4d0f24301ddee3024ae116df1c3f4bb7a563a731f45fdfeb9d39a231b"},
"mint": {:hex, :mint, "1.8.0", "b964eaf4416f2dee2ba88968d52239fca5621b0402b9c95f55a08eb9d74803e9", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "f3c572c11355eccf00f22275e9b42463bc17bd28db13be1e28f8e0bb4adbc849"},
"nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"},
"nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"},
"nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"},
"owl": {:hex, :owl, "0.13.0", "26010e066d5992774268f3163506972ddac0a7e77bfe57fa42a250f24d6b876e", [:mix], [{:ucwidth, "~> 0.2", [hex: :ucwidth, repo: "hexpm", optional: true]}], "hexpm", "59bf9d11ce37a4db98f57cb68fbfd61593bf419ec4ed302852b6683d3d2f7475"},
"parrhesia": {:hex, :parrhesia, "0.12.0", "67f33b62a6d7d32ce8d801e3dd09f3e8f46d82a21e9b15dfda6a161d2c7f7e09", [:mix], [{:bandit, "~> 1.5", [hex: :bandit, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.12", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:lib_secp256k1, "~> 0.7", [hex: :lib_secp256k1, repo: "hexpm", optional: false]}, {:plug, "~> 1.15", [hex: :plug, repo: "hexpm", optional: false]}, {:postgrex, ">= 0.0.0", [hex: :postgrex, repo: "hexpm", optional: false]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}, {:telemetry_metrics, "~> 1.0", [hex: :telemetry_metrics, repo: "hexpm", optional: false]}, {:telemetry_metrics_prometheus, "~> 1.1", [hex: :telemetry_metrics_prometheus, repo: "hexpm", optional: false]}, {:telemetry_poller, "~> 1.0", [hex: :telemetry_poller, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5", [hex: :websock_adapter, repo: "hexpm", optional: false]}, {:websockex, "~> 0.4", [hex: :websockex, repo: "hexpm", optional: false]}], "hexpm", "e91f757972746c73f888706cb084ba745ebf1a602a0f2f6a7c380555c6066cc3"},
"parrhesia": {:hex, :parrhesia, "0.14.0", "7ff96a6d645740a19b66567c3346162735ba821a90377616e74493637c35195e", [:mix], [{:bandit, "~> 1.5", [hex: :bandit, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.12", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:lib_secp256k1, "~> 0.7", [hex: :lib_secp256k1, repo: "hexpm", optional: false]}, {:plug, "~> 1.15", [hex: :plug, repo: "hexpm", optional: false]}, {:postgrex, ">= 0.0.0", [hex: :postgrex, repo: "hexpm", optional: false]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}, {:telemetry_metrics, "~> 1.0", [hex: :telemetry_metrics, repo: "hexpm", optional: false]}, {:telemetry_metrics_prometheus, "~> 1.1", [hex: :telemetry_metrics_prometheus, repo: "hexpm", optional: false]}, {:telemetry_poller, "~> 1.0", [hex: :telemetry_poller, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5", [hex: :websock_adapter, repo: "hexpm", optional: false]}, {:websockex, "~> 0.4", [hex: :websockex, repo: "hexpm", optional: false]}], "hexpm", "2bda91e6f385b6990465f2ccc9e8e0266105a31f72e2b466ae247f846ec3ef28"},
"phoenix": {:hex, :phoenix, "1.8.5", "919db335247e6d4891764dc3063415b0d2457641c5f9b3751b5df03d8e20bbcf", [:mix], [{:bandit, "~> 1.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "83b2bb125127e02e9f475c8e3e92736325b5b01b0b9b05407bcb4083b7a32485"},
"phoenix_ecto": {:hex, :phoenix_ecto, "4.7.0", "75c4b9dfb3efdc42aec2bd5f8bccd978aca0651dbcbc7a3f362ea5d9d43153c6", [:mix], [{:ecto, "~> 3.5", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.1", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.16 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}], "hexpm", "1d75011e4254cb4ddf823e81823a9629559a1be93b4321a6a5f11a5306fbf4cc"},
"phoenix_html": {:hex, :phoenix_html, "4.3.0", "d3577a5df4b6954cd7890c84d955c470b5310bb49647f0a114a6eeecc850f7ad", [:mix], [], "hexpm", "3eaa290a78bab0f075f791a46a981bbe769d94bc776869f4f3063a14f30497ad"},
"phoenix_html_helpers": {:hex, :phoenix_html_helpers, "1.0.1", "7eed85c52eff80a179391036931791ee5d2f713d76a81d0d2c6ebafe1e11e5ec", [:mix], [{:phoenix_html, "~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "cffd2385d1fa4f78b04432df69ab8da63dc5cf63e07b713a4dcf36a3740e3090"},
"phoenix_live_dashboard": {:hex, :phoenix_live_dashboard, "0.8.7", "405880012cb4b706f26dd1c6349125bfc903fb9e44d1ea668adaf4e04d4884b7", [:mix], [{:ecto, "~> 3.6.2 or ~> 3.7", [hex: :ecto, repo: "hexpm", optional: true]}, {:ecto_mysql_extras, "~> 0.5", [hex: :ecto_mysql_extras, repo: "hexpm", optional: true]}, {:ecto_psql_extras, "~> 0.7", [hex: :ecto_psql_extras, repo: "hexpm", optional: true]}, {:ecto_sqlite3_extras, "~> 1.1.7 or ~> 1.2.0", [hex: :ecto_sqlite3_extras, repo: "hexpm", optional: true]}, {:mime, "~> 1.6 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.19 or ~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:telemetry_metrics, "~> 0.6 or ~> 1.0", [hex: :telemetry_metrics, repo: "hexpm", optional: false]}], "hexpm", "3a8625cab39ec261d48a13b7468dc619c0ede099601b084e343968309bd4d7d7"},
"phoenix_live_view": {:hex, :phoenix_live_view, "1.1.28", "8a8e123d018025f756605a2fb02a4854f0d3cd7b207f710fef1fd5d9d72d0254", [:mix], [{:igniter, ">= 0.6.16 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:lazy_html, "~> 0.1.0", [hex: :lazy_html, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0 or ~> 1.8.0-rc", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.15", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "24faad535b65089642c3a7d84088109dc58f49c1f1c5a978659855d643466353"},
"phoenix_live_view": {:hex, :phoenix_live_view, "1.1.27", "9afcab28b0c82afdc51044e661bcd5b8de53d242593d34c964a37710b40a42af", [:mix], [{:igniter, ">= 0.6.16 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:lazy_html, "~> 0.1.0", [hex: :lazy_html, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0 or ~> 1.8.0-rc", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.15", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "415735d0b2c612c9104108b35654e977626a0cb346711e1e4f1ed16e3c827ede"},
"phoenix_pubsub": {:hex, :phoenix_pubsub, "2.2.0", "ff3a5616e1bed6804de7773b92cbccfc0b0f473faf1f63d7daf1206c7aeaaa6f", [:mix], [], "hexpm", "adc313a5bf7136039f63cfd9668fde73bba0765e0614cba80c06ac9460ff3e96"},
"phoenix_template": {:hex, :phoenix_template, "1.0.4", "e2092c132f3b5e5b2d49c96695342eb36d0ed514c5b252a77048d5969330d639", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "2c0c81f0e5c6753faf5cca2f229c9709919aba34fab866d3bc05060c9c444206"},
"phoenix_view": {:hex, :phoenix_view, "2.0.4", "b45c9d9cf15b3a1af5fb555c674b525391b6a1fe975f040fb4d913397b31abf4", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}], "hexpm", "4e992022ce14f31fe57335db27a28154afcc94e9983266835bb3040243eb620b"},
"picosat_elixir": {:hex, :picosat_elixir, "0.2.3", "bf326d0f179fbb3b706bb2c15fbc367dacfa2517157d090fdfc32edae004c597", [:make, :mix], [{:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "f76c9db2dec9d2561ffaa9be35f65403d53e984e8cd99c832383b7ab78c16c66"},
"plug": {:hex, :plug, "1.19.1", "09bac17ae7a001a68ae393658aa23c7e38782be5c5c00c80be82901262c394c0", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "560a0017a8f6d5d30146916862aaf9300b7280063651dd7e532b8be168511e62"},
"plug": {:hex, :plug, "1.19.2", "e4950525b22c6789dfb38a3f95d47171ba159da3fc5a33be9643b43d5e8adb98", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "b6fce20a56af5e60fa5dfecf3f907bb98ec981be43c79a3809a499bc3d133de0"},
"plug_cowboy": {:hex, :plug_cowboy, "2.8.1", "5aa391a5e8d1ac3192e36a3bcaff12b5fd6ef6c7e29b53a38e63a860783e77d0", [:mix], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:cowboy_telemetry, "~> 0.3", [hex: :cowboy_telemetry, repo: "hexpm", optional: false]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "4c200288673d5bc86a0ab7dc6a2a069176a74e5d573ef62740a1c517458a5f26"},
"plug_crypto": {:hex, :plug_crypto, "2.1.1", "19bda8184399cb24afa10be734f84a16ea0a2bc65054e23a62bb10f06bc89491", [:mix], [], "hexpm", "6470bce6ffe41c8bd497612ffde1a7e4af67f36a15eea5f921af71cf3e11247c"},
"postgrex": {:hex, :postgrex, "0.22.0", "fb027b58b6eab1f6de5396a2abcdaaeb168f9ed4eccbb594e6ac393b02078cbd", [:mix], [{:db_connection, "~> 2.9", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "a68c4261e299597909e03e6f8ff5a13876f5caadaddd0d23af0d0a61afcc5d84"},
"postgrex": {:hex, :postgrex, "0.22.2", "4aec14df2a72722aee92492566edbeeb44e233ecb86b1915d03136297ef1385d", [:mix], [{:db_connection, "~> 2.9", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "8946382ddb06294f56026ac4278b3cc212bac8a2c82ed68b4087819ed1abc53b"},
"ranch": {:hex, :ranch, "2.2.0", "25528f82bc8d7c6152c57666ca99ec716510fe0925cb188172f41ce93117b1b0", [:make, :rebar3], [], "hexpm", "fa0b99a1780c80218a4197a59ea8d3bdae32fbff7e88527d7d8a4787eff4f8e7"},
"reactor": {:hex, :reactor, "1.0.1", "ca3b5cf3c04ec8441e67ea2625d0294939822060b1bfd00ffdaaf75b7682d991", [:mix], [{:igniter, "~> 0.4", [hex: :igniter, repo: "hexpm", optional: true]}, {:iterex, "~> 0.1", [hex: :iterex, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:libgraph, "~> 0.16", [hex: :libgraph, repo: "hexpm", optional: false]}, {:spark, ">= 2.3.3 and < 3.0.0-0", [hex: :spark, repo: "hexpm", optional: false]}, {:splode, "~> 0.2", [hex: :splode, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.2", [hex: :telemetry, repo: "hexpm", optional: false]}, {:yaml_elixir, "~> 2.11", [hex: :yaml_elixir, repo: "hexpm", optional: false]}, {:ymlr, "~> 5.0", [hex: :ymlr, repo: "hexpm", optional: false]}], "hexpm", "3497db2b204c9a3cabdaf1b26d2405df1dfbb138ce0ce50e616e9db19fec0043"},
"req": {:hex, :req, "0.5.17", "0096ddd5b0ed6f576a03dde4b158a0c727215b15d2795e59e0916c6971066ede", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.17", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "0b8bc6ffdfebbc07968e59d3ff96d52f2202d0536f10fef4dc11dc02a2a43e39"},
"reactor": {:hex, :reactor, "1.0.0", "024bd13df910bcb8c01cebed4f10bd778269a141a1c8a234e4f67796ac4883cf", [:mix], [{:igniter, "~> 0.4", [hex: :igniter, repo: "hexpm", optional: true]}, {:iterex, "~> 0.1", [hex: :iterex, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:libgraph, "~> 0.16", [hex: :libgraph, repo: "hexpm", optional: false]}, {:spark, ">= 2.3.3 and < 3.0.0-0", [hex: :spark, repo: "hexpm", optional: false]}, {:splode, "~> 0.2", [hex: :splode, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.2", [hex: :telemetry, repo: "hexpm", optional: false]}, {:yaml_elixir, "~> 2.11", [hex: :yaml_elixir, repo: "hexpm", optional: false]}, {:ymlr, "~> 5.0", [hex: :ymlr, repo: "hexpm", optional: false]}], "hexpm", "ae8eb507fffc517f5aa5947db9d2ede2db8bae63b66c94ccb5a2027d30f830a0"},
"req": {:hex, :req, "0.5.18", "48e6431cb4135e8a7815e745177485369a9b4a9924d5fe68ca00eb09ceaed1ef", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.21.0 or ~> 0.22.0", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "fa03812c440a9754bf34355e0c5d4f3ed316458db62e3284b7a352ef8dc0b996"},
"rewrite": {:hex, :rewrite, "1.3.0", "67448ba7975690b35ba7e7f35717efcce317dbd5963cb0577aa7325c1923121a", [:mix], [{:glob_ex, "~> 0.1", [hex: :glob_ex, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.0", [hex: :sourceror, repo: "hexpm", optional: false]}, {:text_diff, "~> 0.1", [hex: :text_diff, repo: "hexpm", optional: false]}], "hexpm", "d111ac7ff3a58a802ef4f193bbd1831e00a9c57b33276e5068e8390a212714a5"},
"slugify": {:hex, :slugify, "1.3.1", "0d3b8b7e5c1eeaa960e44dce94382bee34a39b3ea239293e457a9c5b47cc6fd3", [:mix], [], "hexpm", "cb090bbeb056b312da3125e681d98933a360a70d327820e4b7f91645c4d8be76"},
"sourceror": {:hex, :sourceror, "1.12.0", "da354c5f35aad3cc1132f5d5b0d8437d865e2661c263260480bab51b5eedb437", [:mix], [], "hexpm", "755703683bd014ebcd5de9acc24b68fb874a660a568d1d63f8f98cd8a6ef9cd0"},
"spark": {:hex, :spark, "2.6.1", "b0100216d3883c6a281cb2434af45afbd808695aadb034923cbaf7d8a2ba46ab", [:mix], [{:igniter, ">= 0.3.64 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: true]}, {:sourceror, "~> 1.2", [hex: :sourceror, repo: "hexpm", optional: true]}], "hexpm", "77bbefa5263bb6b70e1195bc0fc662ddb8ef5937a356a77ae072e56983ad13f0"},
"spitfire": {:hex, :spitfire, "0.3.11", "79dfcb033762470de472c1c26ea2b4e3aca74700c685dbffd9a13466272c323d", [:mix], [], "hexpm", "eb6e2dadf63214e8bfe65ca9788cef2b03b01027365d78d3c0e3d9ebd3d5b7b4"},
"splode": {:hex, :splode, "0.3.1", "9843c54f84f71b7833fec3f0be06c3cfb5be6b35960ee195ea4fad84b1c25030", [:mix], [], "hexpm", "8f2309b6ec2ecbb01435656429ed1d9ed04ba28797a3280c3b0d1217018ecfbd"},
"splode": {:hex, :splode, "0.3.0", "ff8effecc509a51245df2f864ec78d849248647c37a75886033e3b1a53ca9470", [:mix], [], "hexpm", "73cfd0892d7316d6f2c93e6e8784bd6e137b2aa38443de52fd0a25171d106d81"},
"stream_data": {:hex, :stream_data, "1.3.0", "bde37905530aff386dea1ddd86ecbf00e6642dc074ceffc10b7d4e41dfd6aac9", [:mix], [], "hexpm", "3cc552e286e817dca43c98044c706eec9318083a1480c52ae2688b08e2936e3c"},
"swoosh": {:hex, :swoosh, "1.25.0", "d60dcba6d1ce538b1994f8712a3d55bc9519ffba4654cc4665a75683881d11dd", [:mix], [{:bandit, ">= 1.0.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:cowboy, "~> 1.1 or ~> 2.4", [hex: :cowboy, repo: "hexpm", optional: true]}, {:ex_aws, "~> 2.1", [hex: :ex_aws, repo: "hexpm", optional: true]}, {:finch, "~> 0.6", [hex: :finch, repo: "hexpm", optional: true]}, {:gen_smtp, "~> 0.13 or ~> 1.0", [hex: :gen_smtp, repo: "hexpm", optional: true]}, {:hackney, "~> 1.9", [hex: :hackney, repo: "hexpm", optional: true]}, {:idna, "~> 6.0", [hex: :idna, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mail, "~> 0.2", [hex: :mail, repo: "hexpm", optional: true]}, {:mime, "~> 1.1 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mua, "~> 0.2.3", [hex: :mua, repo: "hexpm", optional: true]}, {:multipart, "~> 0.4", [hex: :multipart, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: true]}, {:plug_cowboy, ">= 1.0.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:req, "~> 0.5.10 or ~> 0.6 or ~> 1.0", [hex: :req, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "c59db3d838b595b95954a3d0a13782e56881cecfe7ba7b793b1a1a6775273a6e"},
"telemetry": {:hex, :telemetry, "1.4.1", "ab6de178e2b29b58e8256b92b382ea3f590a47152ca3651ea857a6cae05ac423", [:rebar3], [], "hexpm", "2172e05a27531d3d31dd9782841065c50dd5c3c7699d95266b2edd54c2dafa1c"},
"swoosh": {:hex, :swoosh, "1.24.0", "4df9645aeeef925a2eb10f7a588a6a09ddd6d370c5dfbd3e821b699c574bdf57", [:mix], [{:bandit, ">= 1.0.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:cowboy, "~> 1.1 or ~> 2.4", [hex: :cowboy, repo: "hexpm", optional: true]}, {:ex_aws, "~> 2.1", [hex: :ex_aws, repo: "hexpm", optional: true]}, {:finch, "~> 0.6", [hex: :finch, repo: "hexpm", optional: true]}, {:gen_smtp, "~> 0.13 or ~> 1.0", [hex: :gen_smtp, repo: "hexpm", optional: true]}, {:hackney, "~> 1.9", [hex: :hackney, repo: "hexpm", optional: true]}, {:idna, "~> 6.0", [hex: :idna, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mail, "~> 0.2", [hex: :mail, repo: "hexpm", optional: true]}, {:mime, "~> 1.1 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mua, "~> 0.2.3", [hex: :mua, repo: "hexpm", optional: true]}, {:multipart, "~> 0.4", [hex: :multipart, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: true]}, {:plug_cowboy, ">= 1.0.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:req, "~> 0.5.10 or ~> 0.6 or ~> 1.0", [hex: :req, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "6ddd84550800468d0e2c15a8aaff924a64c014ed6cff90318077efd1672b8b3b"},
"telemetry": {:hex, :telemetry, "1.4.2", "a0cb522801dffb1c49fe6e30561badffc7b6d0e180db1300df759faa22062855", [:rebar3], [], "hexpm", "928f6495066506077862c0d1646609eed891a4326bee3126ba54b60af61febb1"},
"telemetry_metrics": {:hex, :telemetry_metrics, "1.1.0", "5bd5f3b5637e0abea0426b947e3ce5dd304f8b3bc6617039e2b5a008adc02f8f", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "e7b79e8ddfde70adb6db8a6623d1778ec66401f366e9a8f5dd0955c56bc8ce67"},
"telemetry_metrics_prometheus": {:hex, :telemetry_metrics_prometheus, "1.1.0", "1cc23e932c1ef9aa3b91db257ead31ea58d53229d407e059b29bb962c1505a13", [:mix], [{:plug_cowboy, "~> 2.1", [hex: :plug_cowboy, repo: "hexpm", optional: false]}, {:telemetry_metrics_prometheus_core, "~> 1.0", [hex: :telemetry_metrics_prometheus_core, repo: "hexpm", optional: false]}], "hexpm", "d43b3659b3244da44fe0275b717701542365d4519b79d9ce895b9719c1ce4d26"},
"telemetry_metrics_prometheus_core": {:hex, :telemetry_metrics_prometheus_core, "1.2.1", "c9755987d7b959b557084e6990990cb96a50d6482c683fb9622a63837f3cd3d8", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:telemetry_metrics, "~> 0.6 or ~> 1.0", [hex: :telemetry_metrics, repo: "hexpm", optional: false]}], "hexpm", "5e2c599da4983c4f88a33e9571f1458bf98b0cf6ba930f1dc3a6e8cf45d5afb6"},
+30
View File
@@ -0,0 +1,30 @@
--- a/command.ts
+++ b/command.ts
@@ -3,8 +3,6 @@ import { toText as streamToText } from "@std/streams";
import type { ArgumentValue } from "@cliffy/command";
import { Command, ValidationError } from "@cliffy/command";
import { CompletionsCommand } from "@cliffy/command/completions";
-import { UpgradeCommand } from "@cliffy/command/upgrade";
-import { JsrProvider } from "@cliffy/command/upgrade/provider/jsr";
import type { AllEventsIterOptions, FetchFilter, FetchTimeRangeFilter } from "nostr-fetch";
import * as nip19 from "nostr-tools/nip19";
@@ -29,18 +27,6 @@ export const nosdumpCommand = new Command()
.description("A tool to dump events stored in Nostr relays")
.usage("[options...] <relays...>")
.command("completions", new CompletionsCommand())
- .command(
- "upgrade",
- new UpgradeCommand({
- provider: [
- new JsrProvider({
- scope: "jiftechnify",
- package: "@jiftechnify/nosdump",
- }),
- ],
- args: ["--allow-all"], // grant all permissions while upgrading
- }),
- )
.command("relay-alias", relayAliasCommand).alias("alias")
.command("relay-set", relaySetCommand).alias("rset")
.reset()
+49
View File
@@ -0,0 +1,49 @@
{
lib,
stdenv,
fetchFromGitHub,
deno,
makeWrapper,
}: let
pname = "nosdump";
version = "0.7.1";
src = fetchFromGitHub {
owner = "jiftechnify";
repo = "nosdump";
rev = version;
hash = "sha256-sn6KUZWA2Y65Y/f1J718pT/a2BvX1Vr1LMe5RL4KtrM=";
};
in
stdenv.mkDerivation {
inherit pname version src;
nativeBuildInputs = [makeWrapper];
patches = [./nosdump-no-upgrade.patch];
installPhase = ''
runHook preInstall
mkdir -p "$out/share/${pname}" "$out/bin"
cp -R . "$out/share/${pname}/"
makeWrapper ${deno}/bin/deno "$out/bin/nosdump" \
--add-flags "run" \
--add-flags "--frozen" \
--add-flags "--no-check" \
--add-flags "--allow-env" \
--add-flags "--allow-net" \
--add-flags "--allow-read" \
--add-flags "--allow-write" \
--add-flags "--allow-sys" \
--add-flags "$out/share/${pname}/mod.ts"
runHook postInstall
'';
meta = with lib; {
description = "Dump events from Nostr relays";
homepage = "https://github.com/jiftechnify/nosdump";
license = licenses.mit;
mainProgram = "nosdump";
platforms = platforms.linux ++ platforms.darwin;
};
}
@@ -0,0 +1,143 @@
defmodule Tribes.Repo.Migrations.AddChatExtensions1 do
@moduledoc """
Installs any extensions that are mentioned in the repo's `installed_extensions/0` callback
This file was autogenerated with `mix ash_postgres.generate_migrations`
"""
use Ecto.Migration
def up do
execute("""
CREATE OR REPLACE FUNCTION ash_elixir_or(left BOOLEAN, in right ANYCOMPATIBLE, out f1 ANYCOMPATIBLE)
AS $$ SELECT COALESCE(NULLIF($1, FALSE), $2) $$
LANGUAGE SQL
SET search_path = ''
IMMUTABLE;
""")
execute("""
CREATE OR REPLACE FUNCTION ash_elixir_or(left ANYCOMPATIBLE, in right ANYCOMPATIBLE, out f1 ANYCOMPATIBLE)
AS $$ SELECT COALESCE($1, $2) $$
LANGUAGE SQL
SET search_path = ''
IMMUTABLE;
""")
execute("""
CREATE OR REPLACE FUNCTION ash_elixir_and(left BOOLEAN, in right ANYCOMPATIBLE, out f1 ANYCOMPATIBLE) AS $$
SELECT CASE
WHEN $1 IS TRUE THEN $2
ELSE $1
END $$
LANGUAGE SQL
SET search_path = ''
IMMUTABLE;
""")
execute("""
CREATE OR REPLACE FUNCTION ash_elixir_and(left ANYCOMPATIBLE, in right ANYCOMPATIBLE, out f1 ANYCOMPATIBLE) AS $$
SELECT CASE
WHEN $1 IS NOT NULL THEN $2
ELSE $1
END $$
LANGUAGE SQL
SET search_path = ''
IMMUTABLE;
""")
execute("""
CREATE OR REPLACE FUNCTION ash_trim_whitespace(arr text[])
RETURNS text[] AS $$
DECLARE
start_index INT = 1;
end_index INT = array_length(arr, 1);
BEGIN
WHILE start_index <= end_index AND arr[start_index] = '' LOOP
start_index := start_index + 1;
END LOOP;
WHILE end_index >= start_index AND arr[end_index] = '' LOOP
end_index := end_index - 1;
END LOOP;
IF start_index > end_index THEN
RETURN ARRAY[]::text[];
ELSE
RETURN arr[start_index : end_index];
END IF;
END; $$
LANGUAGE plpgsql
SET search_path = ''
IMMUTABLE;
""")
execute("""
CREATE OR REPLACE FUNCTION ash_raise_error(json_data jsonb)
RETURNS BOOLEAN AS $$
BEGIN
-- Raise an error with the provided JSON data.
-- The JSON object is converted to text for inclusion in the error message.
RAISE EXCEPTION 'ash_error: %', json_data::text;
RETURN NULL;
END;
$$ LANGUAGE plpgsql
STABLE
SET search_path = '';
""")
execute("""
CREATE OR REPLACE FUNCTION ash_raise_error(json_data jsonb, type_signal ANYCOMPATIBLE)
RETURNS ANYCOMPATIBLE AS $$
BEGIN
-- Raise an error with the provided JSON data.
-- The JSON object is converted to text for inclusion in the error message.
RAISE EXCEPTION 'ash_error: %', json_data::text;
RETURN NULL;
END;
$$ LANGUAGE plpgsql
STABLE
SET search_path = '';
""")
execute("""
CREATE OR REPLACE FUNCTION uuid_generate_v7()
RETURNS UUID
AS $$
DECLARE
timestamp TIMESTAMPTZ;
microseconds INT;
BEGIN
timestamp = clock_timestamp();
microseconds = (cast(extract(microseconds FROM timestamp)::INT - (floor(extract(milliseconds FROM timestamp))::INT * 1000) AS DOUBLE PRECISION) * 4.096)::INT;
RETURN encode(
set_byte(
set_byte(
overlay(uuid_send(gen_random_uuid()) placing substring(int8send(floor(extract(epoch FROM timestamp) * 1000)::BIGINT) FROM 3) FROM 1 FOR 6
),
6, (b'0111' || (microseconds >> 8)::bit(4))::bit(8)::int
),
7, microseconds::bit(8)::int
),
'hex')::UUID;
END
$$
LANGUAGE PLPGSQL
SET search_path = ''
VOLATILE;
""")
execute("CREATE EXTENSION IF NOT EXISTS \"citext\"")
end
def down do
# Uncomment this if you actually want to uninstall the extensions
# when this migration is rolled back:
execute(
"DROP FUNCTION IF EXISTS uuid_generate_v7(), timestamp_from_uuid_v7(uuid), ash_raise_error(jsonb), ash_raise_error(jsonb, ANYCOMPATIBLE), ash_elixir_and(BOOLEAN, ANYCOMPATIBLE), ash_elixir_and(ANYCOMPATIBLE, ANYCOMPATIBLE), ash_elixir_or(ANYCOMPATIBLE, ANYCOMPATIBLE), ash_elixir_or(BOOLEAN, ANYCOMPATIBLE), ash_trim_whitespace(text[])"
)
# execute("DROP EXTENSION IF EXISTS \"citext\"")
end
end
@@ -0,0 +1,134 @@
defmodule Tribes.Repo.Migrations.AddChat do
@moduledoc """
Updates resources based on their most recent snapshots.
This file was autogenerated with `mix ash_postgres.generate_migrations`
"""
use Ecto.Migration
def up do
create table(:aether_chat_messages, primary_key: false) do
add(:id, :uuid, null: false, default: fragment("gen_random_uuid()"), primary_key: true)
add(:author_id, :uuid)
add(:author_pubkey, :text)
add(:body, :text, null: false)
add(:client_message_id, :text)
add(:metadata, :map, null: false, default: %{})
add(:inserted_at, :utc_datetime,
null: false,
default: fragment("(now() AT TIME ZONE 'utc')")
)
add(:updated_at, :utc_datetime,
null: false,
default: fragment("(now() AT TIME ZONE 'utc')")
)
add(:channel_id, :uuid, null: false)
add(:deleted_at, :utc_datetime)
end
create(
index(:aether_chat_messages, [:client_message_id],
unique: true,
where: "deleted_at IS NULL AND client_message_id IS NOT NULL AND deleted_at IS NULL"
)
)
create(index(:aether_chat_messages, [:author_pubkey], where: "deleted_at IS NULL"))
create(index(:aether_chat_messages, [:channel_id, :inserted_at], where: "deleted_at IS NULL"))
create table(:aether_chat_channels, primary_key: false) do
add(:id, :uuid, null: false, default: fragment("gen_random_uuid()"), primary_key: true)
end
alter table(:aether_chat_messages) do
modify(
:channel_id,
references(:aether_chat_channels,
column: :id,
name: "aether_chat_messages_channel_id_fkey",
type: :uuid,
prefix: "public"
)
)
end
alter table(:aether_chat_channels) do
add(:slug, :text, null: false)
add(:title, :text, null: false)
add(:description, :text)
add(:backend, :text, null: false, default: "public_sync")
add(:conversation_kind, :text, null: false, default: "group")
add(:context_provider, :text)
add(:context_type, :text)
add(:context_id, :text)
add(:metadata, :map, null: false, default: %{})
add(:inserted_at, :utc_datetime,
null: false,
default: fragment("(now() AT TIME ZONE 'utc')")
)
add(:updated_at, :utc_datetime,
null: false,
default: fragment("(now() AT TIME ZONE 'utc')")
)
add(:deleted_at, :utc_datetime)
end
create(
index(:aether_chat_channels, [:context_provider, :context_type, :context_id],
where: "deleted_at IS NULL AND deleted_at IS NULL"
)
)
create(
index(:aether_chat_channels, [:slug],
unique: true,
where: "deleted_at IS NULL AND deleted_at IS NULL"
)
)
end
def down do
drop_if_exists(index(:aether_chat_channels, [:slug]))
drop_if_exists(index(:aether_chat_channels, [:context_provider, :context_type, :context_id]))
alter table(:aether_chat_channels) do
remove(:deleted_at)
remove(:updated_at)
remove(:inserted_at)
remove(:metadata)
remove(:context_id)
remove(:context_type)
remove(:context_provider)
remove(:conversation_kind)
remove(:backend)
remove(:description)
remove(:title)
remove(:slug)
end
drop(constraint(:aether_chat_messages, "aether_chat_messages_channel_id_fkey"))
alter table(:aether_chat_messages) do
modify(:channel_id, :uuid)
end
drop(table(:aether_chat_channels))
drop_if_exists(index(:aether_chat_messages, [:channel_id, :inserted_at]))
drop_if_exists(index(:aether_chat_messages, [:author_pubkey]))
drop_if_exists(index(:aether_chat_messages, [:client_message_id]))
drop(table(:aether_chat_messages))
end
end
@@ -0,0 +1,66 @@
defmodule Tribes.Repo.Migrations.AddChatParticipants do
@moduledoc """
Updates resources based on their most recent snapshots.
This file was autogenerated with `mix ash_postgres.generate_migrations`
"""
use Ecto.Migration
def up do
create table(:aether_chat_participants, primary_key: false) do
add(:id, :uuid, null: false, default: fragment("gen_random_uuid()"), primary_key: true)
add(:pubkey, :text, null: false)
add(:user_id, :uuid)
add(:display_name, :text)
add(:role, :text, null: false, default: "member")
add(:metadata, :map, null: false, default: %{})
add(:inserted_at, :utc_datetime,
null: false,
default: fragment("(now() AT TIME ZONE 'utc')")
)
add(:updated_at, :utc_datetime,
null: false,
default: fragment("(now() AT TIME ZONE 'utc')")
)
add(
:channel_id,
references(:aether_chat_channels,
column: :id,
name: "aether_chat_participants_channel_id_fkey",
type: :uuid,
prefix: "public"
),
null: false
)
add(:deleted_at, :utc_datetime)
end
create(
index(:aether_chat_participants, [:pubkey],
where: "deleted_at IS NULL AND deleted_at IS NULL"
)
)
create(
index(:aether_chat_participants, [:channel_id, :pubkey],
unique: true,
where: "deleted_at IS NULL AND deleted_at IS NULL"
)
)
end
def down do
drop(constraint(:aether_chat_participants, "aether_chat_participants_channel_id_fkey"))
drop_if_exists(index(:aether_chat_participants, [:channel_id, :pubkey]))
drop_if_exists(index(:aether_chat_participants, [:pubkey]))
drop(table(:aether_chat_participants))
end
end
@@ -0,0 +1,231 @@
{
"attributes": [
{
"allow_nil?": false,
"default": "fragment(\"gen_random_uuid()\")",
"generated?": false,
"precision": null,
"primary_key?": true,
"references": null,
"scale": null,
"size": null,
"source": "id",
"type": "uuid"
},
{
"allow_nil?": false,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "slug",
"type": "text"
},
{
"allow_nil?": false,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "title",
"type": "text"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "description",
"type": "text"
},
{
"allow_nil?": false,
"default": "\"public_sync\"",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "backend",
"type": "text"
},
{
"allow_nil?": false,
"default": "\"group\"",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "conversation_kind",
"type": "text"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "context_provider",
"type": "text"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "context_type",
"type": "text"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "context_id",
"type": "text"
},
{
"allow_nil?": false,
"default": "%{}",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "metadata",
"type": "map"
},
{
"allow_nil?": false,
"default": "fragment(\"(now() AT TIME ZONE 'utc')\")",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "inserted_at",
"type": "utc_datetime"
},
{
"allow_nil?": false,
"default": "fragment(\"(now() AT TIME ZONE 'utc')\")",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "updated_at",
"type": "utc_datetime"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "deleted_at",
"type": "utc_datetime"
}
],
"base_filter": "deleted_at IS NULL",
"check_constraints": [],
"create_table_options": null,
"custom_indexes": [
{
"all_tenants?": false,
"concurrently": false,
"error_fields": [
"slug"
],
"fields": [
{
"type": "atom",
"value": "slug"
}
],
"include": null,
"message": null,
"name": null,
"nulls_distinct": true,
"prefix": null,
"table": null,
"unique": true,
"using": null,
"where": "deleted_at IS NULL"
},
{
"all_tenants?": false,
"concurrently": false,
"error_fields": [
"context_provider",
"context_type",
"context_id"
],
"fields": [
{
"type": "atom",
"value": "context_provider"
},
{
"type": "atom",
"value": "context_type"
},
{
"type": "atom",
"value": "context_id"
}
],
"include": null,
"message": null,
"name": null,
"nulls_distinct": true,
"prefix": null,
"table": null,
"unique": false,
"using": null,
"where": "deleted_at IS NULL"
}
],
"custom_statements": [],
"has_create_action": true,
"hash": "274584387DB5ECF41145DE06404A06BF41F74821287238F3424C60E1B3FCEF21",
"identities": [],
"multitenancy": {
"attribute": null,
"global": null,
"strategy": null
},
"repo": "Elixir.Tribes.Repo",
"schema": null,
"table": "aether_chat_channels"
}
@@ -0,0 +1,231 @@
{
"attributes": [
{
"allow_nil?": false,
"default": "fragment(\"gen_random_uuid()\")",
"generated?": false,
"precision": null,
"primary_key?": true,
"references": null,
"scale": null,
"size": null,
"source": "id",
"type": "uuid"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "author_id",
"type": "uuid"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "author_pubkey",
"type": "text"
},
{
"allow_nil?": false,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "body",
"type": "text"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "client_message_id",
"type": "text"
},
{
"allow_nil?": false,
"default": "%{}",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "metadata",
"type": "map"
},
{
"allow_nil?": false,
"default": "fragment(\"(now() AT TIME ZONE 'utc')\")",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "inserted_at",
"type": "utc_datetime"
},
{
"allow_nil?": false,
"default": "fragment(\"(now() AT TIME ZONE 'utc')\")",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "updated_at",
"type": "utc_datetime"
},
{
"allow_nil?": false,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": {
"deferrable": false,
"destination_attribute": "id",
"destination_attribute_default": null,
"destination_attribute_generated": null,
"index?": false,
"match_type": null,
"match_with": null,
"multitenancy": {
"attribute": null,
"global": null,
"strategy": null
},
"name": "aether_chat_messages_channel_id_fkey",
"on_delete": null,
"on_update": null,
"primary_key?": true,
"schema": "public",
"table": "aether_chat_channels"
},
"scale": null,
"size": null,
"source": "channel_id",
"type": "uuid"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "deleted_at",
"type": "utc_datetime"
}
],
"base_filter": "deleted_at IS NULL",
"check_constraints": [],
"create_table_options": null,
"custom_indexes": [
{
"all_tenants?": false,
"concurrently": false,
"error_fields": [
"channel_id",
"inserted_at"
],
"fields": [
{
"type": "atom",
"value": "channel_id"
},
{
"type": "atom",
"value": "inserted_at"
}
],
"include": null,
"message": null,
"name": null,
"nulls_distinct": true,
"prefix": null,
"table": null,
"unique": false,
"using": null,
"where": null
},
{
"all_tenants?": false,
"concurrently": false,
"error_fields": [
"author_pubkey"
],
"fields": [
{
"type": "atom",
"value": "author_pubkey"
}
],
"include": null,
"message": null,
"name": null,
"nulls_distinct": true,
"prefix": null,
"table": null,
"unique": false,
"using": null,
"where": null
},
{
"all_tenants?": false,
"concurrently": false,
"error_fields": [
"client_message_id"
],
"fields": [
{
"type": "atom",
"value": "client_message_id"
}
],
"include": null,
"message": null,
"name": null,
"nulls_distinct": true,
"prefix": null,
"table": null,
"unique": true,
"using": null,
"where": "client_message_id IS NOT NULL AND deleted_at IS NULL"
}
],
"custom_statements": [],
"has_create_action": true,
"hash": "476E6D316A778142AA734E0506BCC0CE1726F1CE12BBE1798BF41A34A5946BFA",
"identities": [],
"multitenancy": {
"attribute": null,
"global": null,
"strategy": null
},
"repo": "Elixir.Tribes.Repo",
"schema": null,
"table": "aether_chat_messages"
}
@@ -0,0 +1,209 @@
{
"attributes": [
{
"allow_nil?": false,
"default": "fragment(\"gen_random_uuid()\")",
"generated?": false,
"precision": null,
"primary_key?": true,
"references": null,
"scale": null,
"size": null,
"source": "id",
"type": "uuid"
},
{
"allow_nil?": false,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "pubkey",
"type": "text"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "user_id",
"type": "uuid"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "display_name",
"type": "text"
},
{
"allow_nil?": false,
"default": "\"member\"",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "role",
"type": "text"
},
{
"allow_nil?": false,
"default": "%{}",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "metadata",
"type": "map"
},
{
"allow_nil?": false,
"default": "fragment(\"(now() AT TIME ZONE 'utc')\")",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "inserted_at",
"type": "utc_datetime"
},
{
"allow_nil?": false,
"default": "fragment(\"(now() AT TIME ZONE 'utc')\")",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "updated_at",
"type": "utc_datetime"
},
{
"allow_nil?": false,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": {
"deferrable": false,
"destination_attribute": "id",
"destination_attribute_default": null,
"destination_attribute_generated": null,
"index?": false,
"match_type": null,
"match_with": null,
"multitenancy": {
"attribute": null,
"global": null,
"strategy": null
},
"name": "aether_chat_participants_channel_id_fkey",
"on_delete": null,
"on_update": null,
"primary_key?": true,
"schema": "public",
"table": "aether_chat_channels"
},
"scale": null,
"size": null,
"source": "channel_id",
"type": "uuid"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "deleted_at",
"type": "utc_datetime"
}
],
"base_filter": "deleted_at IS NULL",
"check_constraints": [],
"create_table_options": null,
"custom_indexes": [
{
"all_tenants?": false,
"concurrently": false,
"error_fields": [
"channel_id",
"pubkey"
],
"fields": [
{
"type": "atom",
"value": "channel_id"
},
{
"type": "atom",
"value": "pubkey"
}
],
"include": null,
"message": null,
"name": null,
"nulls_distinct": true,
"prefix": null,
"table": null,
"unique": true,
"using": null,
"where": "deleted_at IS NULL"
},
{
"all_tenants?": false,
"concurrently": false,
"error_fields": [
"pubkey"
],
"fields": [
{
"type": "atom",
"value": "pubkey"
}
],
"include": null,
"message": null,
"name": null,
"nulls_distinct": true,
"prefix": null,
"table": null,
"unique": false,
"using": null,
"where": "deleted_at IS NULL"
}
],
"custom_statements": [],
"has_create_action": true,
"hash": "FE4AA6C74C1C2B9D844C47CDFA9418A41D4ACAFD7B2EF6D15CDA264295CBD6B3",
"identities": [],
"multitenancy": {
"attribute": null,
"global": null,
"strategy": null
},
"repo": "Elixir.Tribes.Repo",
"schema": null,
"table": "aether_chat_participants"
}
@@ -0,0 +1,7 @@
{
"ash_functions_version": 5,
"installed": [
"ash-functions",
"citext"
]
}
+38
View File
@@ -0,0 +1,38 @@
#!/usr/bin/env sh
set -eu
maxkb=500
while [ "$#" -gt 0 ]; do
case "$1" in
--maxkb=*)
maxkb=${1#--maxkb=}
shift
;;
--)
shift
break
;;
-*)
echo "check-added-large-files: unknown option: $1" >&2
exit 2
;;
*)
break
;;
esac
done
limit=$((maxkb * 1024))
failed=0
for file in "$@"; do
[ -f "$file" ] || continue
size=$(wc -c < "$file" | tr -d '[:space:]')
if [ "$size" -gt "$limit" ]; then
kb=$(((size + 1023) / 1024))
echo "$file (${kb} KB) exceeds ${maxkb} KB" >&2
failed=1
fi
done
exit "$failed"
+3 -1
View File
@@ -7,6 +7,8 @@ Usage:
plugin validate
plugin test [mix test args...]
plugin precommit [mix precommit args...]
plugin ecto.migration <name> [mix ecto.gen.migration args...]
plugin ash.codegen <name> [mix ash_postgres.generate_migrations args...]
plugin smoke
plugin shell
EOF
@@ -77,7 +79,7 @@ fi
shift
case "$command_name" in
validate | test | precommit | smoke | shell) ;;
validate | test | precommit | ecto.migration | ash.codegen | smoke | shell) ;;
-h | --help | help)
usage
exit 0
+4 -7
View File
@@ -44,9 +44,6 @@ fi
if [ -d "test/my_plugin" ]; then
mv "test/my_plugin" "test/$SNAKE"
fi
if [ -d "lib/tribes/plugins/my_plugin" ]; then
mv "lib/tribes/plugins/my_plugin" "lib/tribes/plugins/$SNAKE"
fi
# Replace in all text files (portable across GNU/BSD sed)
sed_in_place() {
@@ -70,13 +67,13 @@ sed_in_place() {
while IFS= read -r -d '' file; do
sed_in_place "$file"
done < <(
find . \( -name ".devenv" -o -name ".git" -o -name "deps" -o -name "node_modules" \) -prune -o -type f \
\( -name '*.ex' -o -name '*.exs' -o -name '*.json' -o -name '*.js' -o -name '*.ts' -o -name '*.css' -o -name '*.md' -o -name '*.yml' -o -name '*.yaml' -o -name '.formatter.exs' \) \
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 ts css; do
for ext in js css; do
if [ -f "assets/$ext/my_plugin.$ext" ]; then
mv "assets/$ext/my_plugin.$ext" "assets/$ext/$SNAKE.$ext"
fi
@@ -87,4 +84,4 @@ done
echo "Done. Review the changes, then:"
echo " 1. Edit manifest.json — set description, capabilities"
echo " 2. Run: mix deps.get && scripts/plugin smoke && scripts/plugin test"
echo " 2. Run: mix deps.get && mix test"
+244
View File
@@ -0,0 +1,244 @@
defmodule TribeOne.TribesPlugin.Aether.ChatBackendTest do
use Tribes.PluginTest.PageCase, plugin: TribeOne.TribesPlugin.Aether.Plugin
alias TribeOne.TribesPlugin.Aether.Chat
alias TribeOne.TribesPlugin.Aether.Chat.Nostr.Nak
alias Parrhesia.API.Events
alias Parrhesia.API.RequestContext
test "NIP-17 DM flow sends a user-to-user message", %{
signed_in_conn: sender_conn,
current_user: sender,
conn: conn
} do
recipient = create_user!()
{:ok, view, _html} = live(sender_conn, "/aether/chat")
assert has_element?(view, "#chat-recipient-picker")
view
|> form("#chat-recipient-search-form", %{"recipient" => %{"query" => recipient.username}})
|> render_change()
view
|> element("#chat-recipient-#{recipient.id}")
|> render_click()
assert_redirect(
view,
Chat.standalone_path(Chat.direct_slug(sender.pubkey_hex, recipient.pubkey_hex))
)
{:ok, sender_view, _html} =
live(
sender_conn,
Chat.standalone_path(Chat.direct_slug(sender.pubkey_hex, recipient.pubkey_hex))
)
sender_view
|> form("#chat-message-form", %{"message" => %{"body" => "hello recipient"}})
|> render_submit()
assert has_element?(sender_view, "#chat-messages", "hello recipient")
recipient_conn = sign_in(conn, recipient.username)
{:ok, recipient_view, _html} =
live(
recipient_conn,
Chat.standalone_path(Chat.direct_slug(sender.pubkey_hex, recipient.pubkey_hex))
)
assert has_element?(recipient_view, "#chat-messages", "hello recipient")
end
test "Aether publishes real NIP-17 giftwraps that nak can unwrap" do
require_executable!("nak")
{sender_pubkey, sender_privkey} = Tribes.Keyring.generate_keypair()
recipient_privkey = nak_key_generate!()
recipient_pubkey = nak_key_public!(recipient_privkey)
{:ok, channel} =
Chat.ensure_direct_conversation(sender_pubkey, recipient_pubkey, %{
title: "NIP-17 interop"
})
assert {:ok, message} =
Chat.send_message(
channel,
%{body: "hello nak recipient", author_pubkey: sender_pubkey},
session_privkey: sender_privkey
)
assert message.body == "hello nak recipient"
[giftwrap] = giftwraps_for(recipient_pubkey)
rumor = nak_gift_unwrap!(recipient_privkey, giftwrap)
assert giftwrap["kind"] == 1059
assert ["p", recipient_pubkey] in Enum.map(giftwrap["tags"], &Enum.take(&1, 2))
assert rumor["kind"] == 14
assert rumor["pubkey"] == sender_pubkey
assert rumor["content"] == "hello nak recipient"
assert ["p", recipient_pubkey] in Enum.map(rumor["tags"], &Enum.take(&1, 2))
end
test "Aether reads external NIP-17 giftwraps produced by nak" do
require_executable!("nak")
external_sender_privkey = nak_key_generate!()
external_sender_pubkey = nak_key_public!(external_sender_privkey)
{recipient_pubkey, recipient_privkey} = Tribes.Keyring.generate_keypair()
{:ok, channel} =
Chat.ensure_direct_conversation(recipient_pubkey, external_sender_pubkey, %{
title: "External NIP-17"
})
giftwrap =
nak_nip17_giftwrap!(external_sender_privkey, recipient_pubkey, "hello from external nak")
:ok = publish_event!(giftwrap)
assert {:ok, [message]} =
Chat.list_conversation_messages(channel, session_privkey: recipient_privkey)
assert message.author_pubkey == external_sender_pubkey
assert message.body == "hello from external nak"
end
test "NIP-04 backend imports decryptable legacy DMs as read-only" do
require_executable!("nak")
external_sender_privkey = nak_key_generate!()
external_sender_pubkey = nak_key_public!(external_sender_privkey)
{recipient_pubkey, recipient_privkey} = Tribes.Keyring.generate_keypair()
{:ok, channel} =
Chat.ensure_direct_conversation(recipient_pubkey, external_sender_pubkey, %{
title: "Legacy NIP-04",
backend: :nostr_nip04,
conversation_kind: :legacy_dm
})
{:ok, ciphertext} =
Nak.nip04_encrypt(external_sender_privkey, recipient_pubkey, "legacy hello from nak")
legacy_event = nak_event!(external_sender_privkey, 4, recipient_pubkey, ciphertext)
:ok = publish_event!(legacy_event)
assert {:ok, [message]} =
Chat.list_conversation_messages(channel, session_privkey: recipient_privkey)
assert message.author_pubkey == external_sender_pubkey
assert message.body == "legacy hello from nak"
assert {:error, :read_only_backend} =
Chat.send_message(channel, %{body: "nope"}, session_privkey: recipient_privkey)
end
test "backend capabilities prepare Nostr-compatible non-custodial backends" do
assert %{canonical_store: :parrhesia_events, non_custodial_signing?: true} =
Chat.backend_capabilities(:nostr_nip17)
assert %{read_only?: true, required_protocols: [:nip04]} =
Chat.backend_capabilities(:nostr_nip04)
end
defp create_user! do
username = "chat_recipient_#{System.unique_integer([:positive])}"
{:ok, user} =
Ash.create(
Tribes.Accounts.User,
%{username: username, password: "password_123", password_confirmation: "password_123"},
action: :register_with_password,
domain: Tribes.Accounts
)
Tribes.Plugin.User.from_host(user)
end
defp publish_event!(event) do
assert {:ok, result} =
Events.publish(event,
context: %RequestContext{
caller: :local,
authenticated_pubkeys: MapSet.new([event["pubkey"]])
}
)
assert result.accepted
:ok
end
defp giftwraps_for(recipient_pubkey) do
assert {:ok, events} =
Events.query(
[%{"kinds" => [1059], "#p" => [recipient_pubkey], "limit" => 10}],
context: %RequestContext{
caller: :local,
authenticated_pubkeys: MapSet.new([recipient_pubkey])
}
)
events
end
defp nak_nip17_giftwrap!(sender_privkey, recipient_pubkey, body) do
script = ~S'''
set -euo pipefail
nak event --sec "$1" -k 14 -p "$2" -c "$3" </dev/null 2>/dev/null |
nak gift wrap --sec "$1" -p "$2" 2>/dev/null
'''
script |> run_script!([sender_privkey, recipient_pubkey, body]) |> JSON.decode!()
end
defp nak_event!(sender_privkey, kind, recipient_pubkey, content) do
script = ~S'''
set -euo pipefail
nak event --sec "$1" -k "$2" -p "$3" -c "$4" </dev/null 2>/dev/null
'''
script
|> run_script!([sender_privkey, to_string(kind), recipient_pubkey, content])
|> JSON.decode!()
end
defp nak_gift_unwrap!(recipient_privkey, giftwrap) do
script = ~S'''
set -euo pipefail
printf '%s\n' "$2" | nak gift unwrap --sec "$1" 2>/dev/null
'''
script
|> run_script!([recipient_privkey, JSON.encode!(giftwrap)])
|> JSON.decode!()
end
defp nak_key_generate!, do: run_script!("nak key generate", [])
defp nak_key_public!(privkey), do: run_script!("nak key public \"$1\"", [privkey])
defp require_executable!(name) do
unless System.find_executable(name) do
flunk("expected #{name} in PATH")
end
end
defp run_script!(script, args) do
task =
Task.async(fn ->
System.cmd("bash", ["-lc", script, "bash" | args], stderr_to_stdout: true)
end)
case Task.yield(task, 30_000) || Task.shutdown(task, :brutal_kill) do
{:ok, {output, 0}} -> String.trim(output)
{:ok, {output, status}} -> flunk("script failed with status #{status}:\n#{output}")
nil -> flunk("script timed out")
end
end
end
+74
View File
@@ -0,0 +1,74 @@
defmodule TribeOne.TribesPlugin.Aether.ChatPageTest do
use Tribes.PluginTest.PageCase, plugin: TribeOne.TribesPlugin.Aether.Plugin
alias TribeOne.TribesPlugin.Aether.Chat
test "renders the public chat for signed-out visitors", %{conn: conn} do
{:ok, view, _html} = live(conn, "/aether/chat")
assert has_element?(view, "#aether-chat-page")
assert has_element?(view, "#chat-signed-out", "Sign in to join the chat.")
end
test "renders the message composer for signed-in users", %{signed_in_conn: conn} do
{:ok, view, _html} = live(conn, "/aether/chat")
assert has_element?(view, "#chat-message-form")
assert has_element?(view, "#chat-send-button", "Send")
end
test "posts a public synced message", %{signed_in_conn: conn} do
slug = "test-chat-#{System.unique_integer([:positive])}"
{:ok, view, _html} = live(conn, "/aether/chat/#{slug}")
view
|> form("#chat-message-form", %{"message" => %{"body" => "Hello from chat test"}})
|> render_submit()
assert has_element?(view, "#chat-messages", "Hello from chat test")
end
test "renders compact embedded chat", %{conn: conn} do
slug = "embed-chat-#{System.unique_integer([:positive])}"
{:ok, view, _html} = live(conn, "/aether/chat/embed/#{slug}")
assert has_element?(view, "#aether-chat-embed")
assert has_element?(view, "#chat-embed-header")
refute has_element?(view, "#aether-chat-page")
end
test "renders Marmot scaffold without public composer", %{signed_in_conn: conn} do
slug = "marmot-chat-#{System.unique_integer([:positive])}"
{:ok, view, _html} = live(conn, "/aether/chat/marmot/#{slug}")
assert has_element?(view, "#chat-marmot-notice")
assert has_element?(view, "#chat-marmot-disabled")
refute has_element?(view, "#chat-message-form")
end
test "ensures context group channels with stable slugs" do
attrs = %{
context_provider: "sender",
context_type: "stream",
context_id: "stream-123",
title: "Stream chat"
}
assert {:ok, first} = Chat.ensure_channel(attrs)
assert {:ok, second} = Chat.ensure_channel(attrs)
assert first.id == second.id
assert first.slug == "sender-stream-stream-123"
assert first.conversation_kind == :context_group
assert Chat.embed_path(first) == "/aether/chat/embed/sender-stream-stream-123"
assert Chat.marmot_path(first) == "/aether/chat/marmot/sender-stream-stream-123"
end
test "ensures context group channels through provider helper" do
assert {:ok, channel} =
Chat.ensure_context_channel("sender", "stream", "stream-456", %{title: "Stream Chat"})
assert channel.slug == "sender-stream-stream-456"
assert channel.conversation_kind == :context_group
end
end
@@ -0,0 +1,90 @@
defmodule TribeOne.TribesPlugin.Aether.ExternalClientInteropTest do
use ExUnit.Case, async: false
test "nak can produce and consume NIP-17/NIP-59 and NIP-04 DM artifacts" do
require_executable!("nak")
output =
run_script!(~S'''
set -euo pipefail
sender_sec=$(nak key generate)
recipient_sec=$(nak key generate)
sender_pub=$(nak key public "$sender_sec")
recipient_pub=$(nak key public "$recipient_sec")
gift=$(nak event --sec "$sender_sec" -k 14 -p "$recipient_pub" -c "hello from nak" </dev/null 2>/dev/null | nak gift wrap --sec "$sender_sec" -p "$recipient_pub" 2>/dev/null)
rumor=$(printf '%s\n' "$gift" | nak gift unwrap --sec "$recipient_sec" 2>/dev/null)
legacy_ciphertext=$(nak encrypt --nip04 --sec "$sender_sec" -p "$recipient_pub" "legacy hello from nak" </dev/null 2>/dev/null)
legacy_plaintext=$(nak decrypt --nip04 --sec "$recipient_sec" -p "$sender_pub" "$legacy_ciphertext" 2>/dev/null)
printf 'SENDER_PUB\t%s\n' "$sender_pub"
printf 'RECIPIENT_PUB\t%s\n' "$recipient_pub"
printf 'GIFT\t%s\n' "$gift"
printf 'RUMOR\t%s\n' "$rumor"
printf 'NIP04\t%s\n' "$legacy_plaintext"
''')
lines = prefixed_lines(output)
sender_pub = Map.fetch!(lines, "SENDER_PUB")
recipient_pub = Map.fetch!(lines, "RECIPIENT_PUB")
gift = lines |> Map.fetch!("GIFT") |> JSON.decode!()
rumor = lines |> Map.fetch!("RUMOR") |> JSON.decode!()
assert gift["kind"] == 1059
assert ["p", recipient_pub] in Enum.map(gift["tags"], &Enum.take(&1, 2))
assert rumor["kind"] == 14
assert rumor["pubkey"] == sender_pub
assert rumor["content"] == "hello from nak"
assert ["p", recipient_pub] in Enum.map(rumor["tags"], &Enum.take(&1, 2))
assert Map.fetch!(lines, "NIP04") == "legacy hello from nak"
end
test "algia can consume Nostr event JSON with isolated config" do
require_executable!("nak")
require_executable!("algia")
output =
run_script!(~S'''
set -euo pipefail
home=$(mktemp -d)
mkdir -p "$home/.config/algia"
sec=$(nak key generate)
nsec=$(nak encode nsec "$sec")
printf '{"privateKey":"%s","relays":{}}\n' "$nsec" > "$home/.config/algia/config.json"
nak event --sec "$sec" -k 1 -c "hello from algia cat" </dev/null 2>/dev/null | HOME="$home" algia cat --json 2>/dev/null
''')
event = JSON.decode!(String.trim(output))
assert event["kind"] == 1
assert event["content"] == "hello from algia cat"
assert is_binary(event["id"])
assert is_binary(event["sig"])
end
defp require_executable!(name) do
unless System.find_executable(name) do
flunk("expected #{name} in PATH")
end
end
defp run_script!(script) do
case System.cmd("timeout", ["30s", "bash", "-lc", script], stderr_to_stdout: true) do
{output, 0} -> output
{output, status} -> flunk("script failed with status #{status}:\n#{output}")
end
end
defp prefixed_lines(output) do
output
|> String.split("\n", trim: true)
|> Map.new(fn line ->
[key, value] = String.split(line, "\t", parts: 2)
{key, value}
end)
end
end
+82
View File
@@ -0,0 +1,82 @@
defmodule TribeOne.TribesPlugin.Aether.ManifestTest do
use ExUnit.Case, async: true
@manifest_path Path.join(__DIR__, "../../manifest.json") |> Path.expand()
@capability_regex ~r/^[a-z][a-z0-9_-]*(\.[a-z][a-z0-9_-]*)+@[1-9][0-9]*$/
setup_all do
content = File.read!(@manifest_path)
manifest = JSON.decode!(content)
%{manifest: manifest}
end
describe "manifest.json" do
test "is valid JSON", %{manifest: manifest} do
assert is_map(manifest)
end
test "has required fields", %{manifest: manifest} do
required = [
"id",
"slug",
"display_name",
"version",
"entry_module",
"host_api",
"otp_app",
"provides",
"requires"
]
for field <- required do
assert Map.has_key?(manifest, field),
"manifest.json must contain #{inspect(field)}"
end
end
test "identity fields are namespaced", %{manifest: manifest} do
assert manifest["id"] == "org.tribe-one.plugins.aether"
assert manifest["slug"] == "aether"
assert manifest["otp_app"] == "tribe_one_aether"
end
test "entry_module uses the TribeOne namespace and Plugin suffix", %{manifest: manifest} do
module_name = manifest["entry_module"]
assert is_binary(module_name)
assert Regex.match?(
~r/^TribeOne\.[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
for cap <- manifest["provides"] do
assert Regex.match?(@capability_regex, cap),
"invalid capability identifier: #{inspect(cap)}"
end
end
test "requires contains valid capability identifiers", %{manifest: manifest} do
for cap <- manifest["requires"] do
assert Regex.match?(@capability_regex, cap),
"invalid capability identifier: #{inspect(cap)}"
end
end
test "declares the chat capability", %{manifest: manifest} do
assert "org.tribe-one.caps.chat@1" in manifest["provides"]
end
test "host_api is a string version", %{manifest: manifest} do
assert is_binary(manifest["host_api"])
end
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"
end
end
end
+11
View File
@@ -0,0 +1,11 @@
defmodule TribeOne.TribesPlugin.Aether.MarmotAssetsTest do
use ExUnit.Case, async: true
@package_path Path.expand("../../assets/package.json", __DIR__)
test "pins marmot-ts to the selected client version" do
package = @package_path |> File.read!() |> JSON.decode!()
assert get_in(package, ["dependencies", "@internet-privacy/marmot-ts"]) == "0.5.1"
end
end
+3
View File
@@ -0,0 +1,3 @@
defmodule TribeOne.TribesPlugin.Aether.PluginContractTest do
use Tribes.PluginTest.ContractTest, plugin: TribeOne.TribesPlugin.Aether.Plugin
end
+44
View File
@@ -0,0 +1,44 @@
defmodule TribeOne.TribesPlugin.Aether.TimelinePageTest do
use Tribes.PluginTest.PageCase, plugin: TribeOne.TribesPlugin.Aether.Plugin
test "renders the timeline for signed-out visitors", %{conn: conn} do
{:ok, _view, html} = live(conn, "/aether")
assert html =~ "Aether"
assert html =~ "Sign in to publish notes from your local identity."
end
test "renders the posting form for signed-in users", %{signed_in_conn: conn, current_user: user} do
{:ok, view, _html} = live(conn, "/aether")
assert has_element?(view, "#note-form")
assert has_element?(view, "#post-note-button", "Post note")
assert is_binary(user.username)
end
test "handles batched Parrhesia note events", %{signed_in_conn: conn, current_user: user} do
{:ok, view, _html} = live(conn, "/aether")
send(view.pid, {:parrhesia, :events, make_ref(), "aether", [note_event(user.pubkey_hex)]})
assert render(view) =~ "Streamed batch note"
end
test "dispatches top-level subpaths to the timeline page", %{conn: conn} do
{:ok, _view, html} = live(conn, "/aether/tribe-123")
assert html =~ "Aether"
end
defp note_event(pubkey) do
%{
"id" => "batch-note-#{System.unique_integer([:positive])}",
"pubkey" => pubkey,
"created_at" => 1_777_237_381,
"kind" => 1,
"tags" => [],
"content" => "Streamed batch note",
"sig" => "test-signature"
}
end
end
-17
View File
@@ -1,17 +0,0 @@
defmodule MyPlugin.HomePageTest do
use Tribes.PluginTest.PageCase, plugin: Tribes.Plugins.MyPlugin.Plugin
test "renders the plugin home page for signed-out visitors", %{conn: conn} do
{:ok, view, html} = live(conn, "/my_plugin")
assert html =~ "My Plugin"
assert html =~ "This is a Tribes plugin."
assert has_element?(view, "#plugin-nav-my-plugin", "My Plugin")
end
test "dispatches top-level subpaths to the plugin page", %{conn: conn} do
{:ok, _view, html} = live(conn, "/my_plugin/example")
assert html =~ "My Plugin"
end
end
-3
View File
@@ -1,3 +0,0 @@
defmodule MyPlugin.PluginContractTest do
use Tribes.PluginTest.ContractTest, plugin: Tribes.Plugins.MyPlugin.Plugin
end
+9
View File
@@ -1 +1,10 @@
ExUnit.start()
{:ok, _apps} = Application.ensure_all_started(:tribes)
Ecto.Migrator.run(
Tribes.Repo,
Path.expand("../priv/repo/migrations", __DIR__),
:up,
all: true
)