You've already forked tribes-plugin-aether
forked from tribes/tribes-plugin-template
Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ab297d508d | |||
| 0ba524bad9 | |||
| 36c40e3e25 | |||
| 2e7d201866 | |||
| c338e12170 | |||
| 493a2e0a65 | |||
| 664ed5222f | |||
| fddd616772 | |||
| 569f7e9785 | |||
| 22fd27da14 | |||
| f6b767fbec | |||
| 41d35103dd | |||
| ed0d4f9c0d | |||
| 54d9bdd99c |
@@ -2,66 +2,11 @@
|
||||
|
||||
export DIRENV_WARN_TIMEOUT=20s
|
||||
|
||||
# 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
|
||||
eval "$(devenv direnvrc)"
|
||||
|
||||
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
|
||||
# `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
|
||||
|
||||
+12
-16
@@ -5,11 +5,21 @@
|
||||
/result
|
||||
/result-*
|
||||
|
||||
# Devenv
|
||||
.devenv*
|
||||
devenv.local.nix
|
||||
|
||||
# Pre-commit
|
||||
.pre-commit-config.yaml
|
||||
|
||||
# Node
|
||||
/node_modules/
|
||||
/assets/node_modules/
|
||||
/priv/static/*
|
||||
!/priv/static/.gitkeep
|
||||
|
||||
# Built plugin assets
|
||||
/priv/static/*.css
|
||||
/priv/static/*.js
|
||||
/priv/static/vendor/
|
||||
|
||||
# Browser tooling
|
||||
.playwright-mcp/
|
||||
@@ -30,17 +40,3 @@ 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
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
# 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]
|
||||
@@ -1,33 +1,32 @@
|
||||
# Tribes Aether Plugin Agent Guide
|
||||
# Tribes Plugin Template Agent Guide
|
||||
|
||||
This repository is the Tribes Aether plugin. Keep changes scoped to the plugin
|
||||
contract, migrations, assets, and tests owned by this repo.
|
||||
This repository is a template for Tribes plugins. Preserve template placeholders
|
||||
unless a task explicitly asks to rename the template.
|
||||
|
||||
## Required Workflow
|
||||
|
||||
- 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.
|
||||
- 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.
|
||||
|
||||
## Plugin Contract
|
||||
|
||||
- `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.
|
||||
- `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`.
|
||||
|
||||
## 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 -->
|
||||
@@ -363,18 +362,20 @@ when writing scripts inside the template**:
|
||||
}
|
||||
</script>
|
||||
|
||||
- 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 are automatically integrated into the app.js bundle
|
||||
- colocated hooks names **MUST ALWAYS** start with a `.` prefix, i.e. `.PhoneNumber`
|
||||
|
||||
#### External phx-hook
|
||||
|
||||
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:
|
||||
External JS hooks (`<div id="myhook" phx-hook="MyHook">`) must be placed in `assets/js/` and passed to the
|
||||
LiveSocket constructor:
|
||||
|
||||
window.TribesPluginHooks = window.TribesPluginHooks || {};
|
||||
window.TribesPluginHooks.MyHook = {
|
||||
const MyHook = {
|
||||
mounted() { ... }
|
||||
};
|
||||
}
|
||||
let liveSocket = new LiveSocket("/live", Socket, {
|
||||
hooks: { MyHook }
|
||||
});
|
||||
|
||||
#### Pushing events between client and server
|
||||
|
||||
@@ -548,10 +549,8 @@ 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 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`.
|
||||
- Keep the host-facing entry module under `Tribes.Plugins.*.Plugin`. It may be a
|
||||
thin delegate to the plugin application's own module.
|
||||
- `register/1` must return a `Tribes.Plugin.Spec` struct or a map/struct that
|
||||
validates into that spec.
|
||||
|
||||
@@ -620,9 +619,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 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.
|
||||
- `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.
|
||||
- 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
|
||||
@@ -631,14 +630,11 @@ 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 `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
|
||||
`Tribes.Plugin.Layouts.app` and keep `ui@1` in `manifest.json` `requires`.
|
||||
- Consumers should target the `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.
|
||||
|
||||
|
||||
@@ -1,158 +1,209 @@
|
||||
# Aether
|
||||
# Tribes Plugin Template
|
||||
|
||||
Aether is an external Tribes plugin that adds local social posting and chat to a
|
||||
Tribes node.
|
||||
**DEPRECATED!** Use [the generator (`tribes-plugin-new`)](https://git.teralink.net/tribes/tribes-plugin-new) instead.
|
||||
|
||||
It provides:
|
||||
Template for creating [Tribes](https://github.com/your-org/tribes) plugins.
|
||||
|
||||
- `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.
|
||||
## Getting Started
|
||||
|
||||
## Features
|
||||
1. Click **"Use this template"** on GitHub to create your own repo
|
||||
2. Clone and rename:
|
||||
|
||||
### 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
|
||||
}
|
||||
```bash
|
||||
git clone https://github.com/you/your-plugin.git
|
||||
cd your-plugin
|
||||
./scripts/rename.sh your_plugin YourPlugin
|
||||
```
|
||||
|
||||
Runtime contributions from `TribeOne.TribesPlugin.Aether.Plugin` include:
|
||||
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:
|
||||
|
||||
- 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.
|
||||
```bash
|
||||
mix deps.get
|
||||
mix tribes.plugin.validate
|
||||
scripts/plugin smoke
|
||||
scripts/plugin test
|
||||
```
|
||||
|
||||
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:
|
||||
If you are using the shared `tribes` devenv, run through the local helper instead:
|
||||
|
||||
```bash
|
||||
devenv shell -- plugin validate
|
||||
devenv shell -- plugin smoke
|
||||
devenv shell -- plugin test
|
||||
devenv shell -- plugin precommit
|
||||
```
|
||||
|
||||
Outside the devenv shell, use the local wrapper:
|
||||
## Development
|
||||
|
||||
```bash
|
||||
scripts/plugin validate
|
||||
scripts/plugin test
|
||||
scripts/plugin precommit
|
||||
```
|
||||
|
||||
Plain `mix test` and `mix precommit` are not the normal entrypoints; this plugin
|
||||
suite is host-backed and expects the Tribes test environment.
|
||||
|
||||
For local development alongside a Tribes checkout, symlink this repo into the
|
||||
host plugin directory:
|
||||
For local development alongside a Tribes checkout:
|
||||
|
||||
```bash
|
||||
# Symlink into the host plugins directory once
|
||||
cd /path/to/tribes
|
||||
ln -s /path/to/tribes-plugin-aether plugins/aether
|
||||
ln -s /path/to/your-plugin plugins/your_plugin
|
||||
|
||||
# Start Tribes dev server
|
||||
iex --sname dev -S mix phx.server
|
||||
```
|
||||
|
||||
Then edit this repo normally. The host watches symlinked external plugins and
|
||||
reloads changed Elixir, HEEx, assets, manifests, and migrations.
|
||||
Then edit the plugin in its own repo. In development, the host now watches
|
||||
symlinked external plugins and automatically:
|
||||
|
||||
## Assets and hooks
|
||||
- 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
|
||||
|
||||
Plugin browser assets are declared in `manifest.json` and served by the host.
|
||||
Aether currently uses:
|
||||
That means the normal edit/compile loop is:
|
||||
|
||||
- `assets/js/aether.js` for LiveView hooks such as chat auto-scroll,
|
||||
- `assets/css/aether.css` for plugin-scoped styles.
|
||||
```bash
|
||||
cd /path/to/your-plugin
|
||||
mix deps.get
|
||||
|
||||
External plugin hooks are registered through `window.TribesPluginHooks`; Phoenix
|
||||
colocated hooks from external plugin OTP apps are not auto-imported by the host.
|
||||
# in another terminal
|
||||
cd /path/to/tribes
|
||||
iex --sname dev -S mix phx.server
|
||||
```
|
||||
|
||||
## Runtime requirements
|
||||
Inside the plugin repo's devenv shell, the `plugin` helper forwards to the host devenv automatically:
|
||||
|
||||
- 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.
|
||||
```bash
|
||||
plugin validate
|
||||
plugin smoke
|
||||
plugin test
|
||||
plugin precommit
|
||||
```
|
||||
|
||||
## Release packaging
|
||||
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`.
|
||||
|
||||
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.
|
||||
## 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 test
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
## Building for Release
|
||||
|
||||
```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/
|
||||
```
|
||||
|
||||
For Guix-based deployment, package your plugin in the `guix-tribes` channel and
|
||||
enable it from the node config.
|
||||
|
||||
## Licence
|
||||
|
||||
TODO: Choose a licence.
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
min-release-age=7
|
||||
@@ -1,17 +1,13 @@
|
||||
/*
|
||||
* Plugin CSS entry point.
|
||||
*
|
||||
* Served at /plugins-assets/aether/aether.css
|
||||
* Served at /plugins-assets/my_plugin/my_plugin.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.
|
||||
*/
|
||||
|
||||
.aether {
|
||||
.my-plugin {
|
||||
/* Plugin-scoped styles go here */
|
||||
}
|
||||
|
||||
.aether-chat-message {
|
||||
scroll-margin-block-end: 1rem;
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
// 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;
|
||||
},
|
||||
};
|
||||
Generated
+14
-460
@@ -1,475 +1,29 @@
|
||||
{
|
||||
"name": "aether-assets",
|
||||
"name": "my-plugin-assets",
|
||||
"version": "0.1.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "aether-assets",
|
||||
"name": "my-plugin-assets",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@internet-privacy/marmot-ts": "0.5.1"
|
||||
"devDependencies": {
|
||||
"typescript": "^6.0.3"
|
||||
}
|
||||
},
|
||||
"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": ">=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==",
|
||||
"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",
|
||||
"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"
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
},
|
||||
"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
|
||||
}
|
||||
"engines": {
|
||||
"node": ">=14.17"
|
||||
}
|
||||
},
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+5
-4
@@ -1,11 +1,12 @@
|
||||
{
|
||||
"name": "aether-assets",
|
||||
"name": "my-plugin-assets",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"scripts": {
|
||||
"build": "mkdir -p ../priv/static && cp -r css/. ../priv/static && cp -r js/. ../priv/static"
|
||||
"build": "npm run check && mkdir -p ../priv/static && cp -r css/. ../priv/static && tsc --project tsconfig.json",
|
||||
"check": "tsc --project tsconfig.json --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@internet-privacy/marmot-ts": "0.5.1"
|
||||
"devDependencies": {
|
||||
"typescript": "^6.0.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
// 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 {};
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ES2022",
|
||||
"moduleResolution": "Bundler",
|
||||
"strict": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"exactOptionalPropertyTypes": true,
|
||||
"skipLibCheck": true,
|
||||
"outDir": "../priv/static",
|
||||
"rootDir": "ts"
|
||||
},
|
||||
"include": ["ts/**/*.ts"]
|
||||
}
|
||||
@@ -1,5 +1,3 @@
|
||||
import Config
|
||||
|
||||
config :tribe_one_aether, ash_domains: [TribeOne.TribesPlugin.Aether.Chat]
|
||||
|
||||
import_config "#{config_env()}.exs"
|
||||
|
||||
@@ -1,5 +1 @@
|
||||
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
|
||||
|
||||
+15
-34
@@ -3,11 +3,10 @@
|
||||
"devenv": {
|
||||
"locked": {
|
||||
"dir": "src/modules",
|
||||
"lastModified": 1779566536,
|
||||
"narHash": "sha256-KlMZMcgyU0BFUW2JCrCb9IHJMgCdG0eyhukpu9pwXLs=",
|
||||
"lastModified": 1777109791,
|
||||
"owner": "cachix",
|
||||
"repo": "devenv",
|
||||
"rev": "6715fab7a826d108278fbfd6ef35a61f49ab7ac9",
|
||||
"rev": "025b6ba9903b96a55ac21a9a63fa290a6da5afe6",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -21,7 +20,6 @@
|
||||
"flake": false,
|
||||
"locked": {
|
||||
"lastModified": 1767039857,
|
||||
"narHash": "sha256-vNpUSpF5Nuw8xvDLj2KCwwksIbjua2LZCqhV1LNRDns=",
|
||||
"owner": "NixOS",
|
||||
"repo": "flake-compat",
|
||||
"rev": "5edf11c44bc78a0d334f6334cdaf7d60d732daab",
|
||||
@@ -42,11 +40,10 @@
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1778507602,
|
||||
"narHash": "sha256-kTwur1wV+01SdqskVMSo6JMEpg71ps3HpbFY2GsflKs=",
|
||||
"lastModified": 1776796298,
|
||||
"owner": "cachix",
|
||||
"repo": "git-hooks.nix",
|
||||
"rev": "61ab0e80d9c7ab14c256b5b453d8b3fb0189ba0a",
|
||||
"rev": "3cfd774b0a530725a077e17354fbdb87ea1c4aad",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -63,11 +60,10 @@
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1709087332,
|
||||
"narHash": "sha256-HG2cCnktfHsKV0s4XW83gU3F57gaTljL9KNSuG6bnQs=",
|
||||
"lastModified": 1762808025,
|
||||
"owner": "hercules-ci",
|
||||
"repo": "gitignore.nix",
|
||||
"rev": "637db329424fd7e46cf4185293b9cc8c88c95394",
|
||||
"rev": "cb5e3fdca1de58ccbc3ef53de65bd372b48f567c",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -81,11 +77,10 @@
|
||||
"nixpkgs-src": "nixpkgs-src"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1778507786,
|
||||
"narHash": "sha256-HzSQCKMsMr8r55LwM1JuzIOB+8bzk0FEv6sItKvsfoY=",
|
||||
"lastModified": 1776852779,
|
||||
"owner": "cachix",
|
||||
"repo": "devenv-nixpkgs",
|
||||
"rev": "8f24a228a782e24576b155d1e39f0d914b380691",
|
||||
"rev": "ec3063523dcd911aeadb50faa589f237cdab5853",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -98,11 +93,11 @@
|
||||
"nixpkgs-src": {
|
||||
"flake": false,
|
||||
"locked": {
|
||||
"lastModified": 1778274207,
|
||||
"narHash": "sha256-I4puXmX1iovcCHZlRmztO3vW0mAbbRvq4F8wgIMQ1MM=",
|
||||
"lastModified": 1776329215,
|
||||
"narHash": "sha256-a8BYi3mzoJ/AcJP8UldOx8emoPRLeWqALZWu4ZvjPXw=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "b3da656039dc7a6240f27b2ef8cc6a3ef3bccae7",
|
||||
"rev": "b86751bc4085f48661017fa226dee99fab6c651b",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -112,31 +107,17 @@
|
||||
"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",
|
||||
"nixpkgs-unstable": "nixpkgs-unstable"
|
||||
"pre-commit-hooks": [
|
||||
"git-hooks"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"root": "root",
|
||||
"version": 7
|
||||
}
|
||||
}
|
||||
|
||||
+8
-58
@@ -1,69 +1,23 @@
|
||||
{
|
||||
pkgs,
|
||||
lib,
|
||||
inputs,
|
||||
...
|
||||
}: 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";
|
||||
};
|
||||
}: {
|
||||
env = {
|
||||
MIX_OS_DEPS_COMPILE_PARTITION_COUNT = 8;
|
||||
NODE_ENV = "development";
|
||||
};
|
||||
|
||||
# https://devenv.sh/packages/
|
||||
packages = with pkgs;
|
||||
[
|
||||
git
|
||||
# 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 {})
|
||||
prettier
|
||||
]
|
||||
++
|
||||
# Linux only
|
||||
lib.optionals pkgs.stdenv.isLinux [
|
||||
# for ExUnit notifier
|
||||
libnotify
|
||||
|
||||
# for package - file_system
|
||||
++ lib.optionals pkgs.stdenv.isLinux [
|
||||
inotify-tools
|
||||
];
|
||||
|
||||
# https://devenv.sh/tests/
|
||||
# enterTest = ''
|
||||
# echo "Running tests"
|
||||
# git --version | grep "2.42.0"
|
||||
# '';
|
||||
|
||||
# https://devenv.sh/languages/
|
||||
languages = {
|
||||
elixir = {
|
||||
enable = true;
|
||||
@@ -77,10 +31,9 @@ in {
|
||||
};
|
||||
};
|
||||
|
||||
# dotenv.enable = true;
|
||||
dotenv.enable = true;
|
||||
devenv.warnOnNewVersion = false;
|
||||
|
||||
# https://devenv.sh/pre-commit-hooks/
|
||||
git-hooks.hooks = {
|
||||
alejandra.enable = true;
|
||||
prettier.enable = true;
|
||||
@@ -93,7 +46,6 @@ in {
|
||||
mix-format.files = "\\.(ex|exs|heex)$";
|
||||
};
|
||||
|
||||
# https://devenv.sh/scripts/
|
||||
enterShell = ''
|
||||
echo
|
||||
elixir --version
|
||||
@@ -105,6 +57,4 @@ in {
|
||||
scripts = {
|
||||
plugin.exec = ''bash "$DEVENV_ROOT/scripts/plugin" "$@"'';
|
||||
};
|
||||
|
||||
# See full reference at https://devenv.sh/reference/options/
|
||||
}
|
||||
|
||||
+3
-14
@@ -2,21 +2,10 @@
|
||||
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'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
|
||||
# If you have more than one devenv you can merge them.
|
||||
# imports:
|
||||
# - ./backend
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,53 @@
|
||||
# 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
|
||||
```
|
||||
@@ -1,144 +0,0 @@
|
||||
# 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
|
||||
```
|
||||
@@ -0,0 +1,62 @@
|
||||
# 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.
|
||||
@@ -1,24 +0,0 @@
|
||||
(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")))))
|
||||
@@ -1,26 +0,0 @@
|
||||
#!/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,352 +0,0 @@
|
||||
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
|
||||
@@ -1,25 +0,0 @@
|
||||
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
|
||||
@@ -1,41 +0,0 @@
|
||||
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
|
||||
@@ -1,169 +0,0 @@
|
||||
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
|
||||
@@ -1,294 +0,0 @@
|
||||
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
|
||||
@@ -1,35 +0,0 @@
|
||||
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
|
||||
@@ -1,180 +0,0 @@
|
||||
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
|
||||
@@ -1,139 +0,0 @@
|
||||
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
|
||||
@@ -1,157 +0,0 @@
|
||||
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
|
||||
@@ -1,127 +0,0 @@
|
||||
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
|
||||
@@ -1,111 +0,0 @@
|
||||
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
|
||||
@@ -1,186 +0,0 @@
|
||||
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
|
||||
@@ -1,294 +0,0 @@
|
||||
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
|
||||
@@ -1,139 +0,0 @@
|
||||
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
|
||||
@@ -1,30 +0,0 @@
|
||||
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
|
||||
@@ -1,339 +0,0 @@
|
||||
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, ¬e_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
|
||||
@@ -1,4 +1,4 @@
|
||||
defmodule TribeOne.TribesPlugin.Aether.Application do
|
||||
defmodule MyPlugin.Application do
|
||||
@moduledoc """
|
||||
OTP Application for this plugin.
|
||||
|
||||
@@ -12,10 +12,10 @@ defmodule TribeOne.TribesPlugin.Aether.Application do
|
||||
def start(_type, _args) do
|
||||
children = [
|
||||
# Add your supervised processes here, e.g.:
|
||||
# {TribeOne.TribesPlugin.Aether.Worker, []}
|
||||
# {MyPlugin.Worker, []}
|
||||
]
|
||||
|
||||
opts = [strategy: :one_for_one, name: TribeOne.TribesPlugin.Aether.Supervisor]
|
||||
opts = [strategy: :one_for_one, name: MyPlugin.Supervisor]
|
||||
Supervisor.start_link(children, opts)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,33 @@
|
||||
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
|
||||
@@ -0,0 +1,36 @@
|
||||
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
|
||||
@@ -0,0 +1,5 @@
|
||||
defmodule Tribes.Plugins.MyPlugin.Plugin do
|
||||
@moduledoc false
|
||||
|
||||
defdelegate register(context), to: MyPlugin.Plugin
|
||||
end
|
||||
+10
-21
@@ -1,28 +1,17 @@
|
||||
{
|
||||
"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",
|
||||
"name": "my_plugin",
|
||||
"version": "0.1.0",
|
||||
"description": "TODO: Describe what this plugin does",
|
||||
"entry_module": "Tribes.Plugins.MyPlugin.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"
|
||||
],
|
||||
"otp_app": "my_plugin",
|
||||
"provides": [],
|
||||
"requires": ["ui@1"],
|
||||
"enhances_with": [],
|
||||
"assets": {
|
||||
"global_js": [
|
||||
"aether.js"
|
||||
],
|
||||
"global_css": [
|
||||
"aether.css"
|
||||
]
|
||||
"global_js": ["my_plugin.js"],
|
||||
"global_css": ["my_plugin.css"]
|
||||
},
|
||||
"migrations": true,
|
||||
"migrations": false,
|
||||
"children": false
|
||||
}
|
||||
|
||||
@@ -1,77 +0,0 @@
|
||||
;; 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))
|
||||
@@ -1,10 +1,10 @@
|
||||
defmodule TribeOne.TribesPlugin.Aether.MixProject do
|
||||
defmodule MyPlugin.MixProject do
|
||||
use Mix.Project
|
||||
|
||||
def project do
|
||||
[
|
||||
app: :tribe_one_aether,
|
||||
version: "0.2.0",
|
||||
app: :my_plugin,
|
||||
version: "0.1.0",
|
||||
elixir: "~> 1.18",
|
||||
elixirc_paths: elixirc_paths(Mix.env()),
|
||||
start_permanent: Mix.env() == :prod,
|
||||
@@ -24,7 +24,7 @@ defmodule TribeOne.TribesPlugin.Aether.MixProject do
|
||||
[
|
||||
extra_applications: [:logger]
|
||||
# Uncomment if your plugin needs its own supervision tree:
|
||||
# mod: {TribeOne.TribesPlugin.Aether.Application, []}
|
||||
# mod: {MyPlugin.Application, []}
|
||||
]
|
||||
end
|
||||
|
||||
@@ -40,12 +40,12 @@ defmodule TribeOne.TribesPlugin.Aether.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"}
|
||||
{:phoenix_live_view, "~> 1.1.0"},
|
||||
{:usage_rules, "~> 1.2", only: :dev}
|
||||
] ++ tribes_deps(Mix.env())
|
||||
end
|
||||
|
||||
|
||||
@@ -1,34 +1,34 @@
|
||||
%{
|
||||
"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": {: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_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.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": {: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_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.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"},
|
||||
"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"},
|
||||
"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.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"},
|
||||
"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"},
|
||||
"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.19", "6903cabdfd9d1af46454126e7c8385186659dd33ecfb74a885cae52221ad6109", [:mix], [], "hexpm", "3669e6cab13f54c2df26b3e6833745d647f35b6e30d8ddd5975df0d5c842ca98"},
|
||||
"castore": {:hex, :castore, "1.0.18", "5e43ef0ec7d31195dfa5a65a86e6131db999d074179d2ba5a8de11fe14570f55", [:mix], [], "hexpm", "f393e4fe6317829b158fb74d86eb681f737d2fe326aa61ccf6293c4104957e34"},
|
||||
"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.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": {: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_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.1", "318d385d55f657e9a5005838c4e426e13dcd724a691438384b6165a69687e531", [:make, :rebar3], [], "hexpm", "58f1e425a9e04176f1d30e20116f57c4e90ef0e187552e9741c465bdf4044f70"},
|
||||
"cowlib": {:hex, :cowlib, "2.16.0", "54592074ebbbb92ee4746c8a8846e5605052f29309d3a873468d76cdf932076f", [:make, :rebar3], [], "hexpm", "7f478d80d66b747344f0ea7708c187645cfcc08b11aa424632f78e25bf05db51"},
|
||||
"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.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"},
|
||||
"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"},
|
||||
"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.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": {: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_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.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"},
|
||||
"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"},
|
||||
"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.5", "2e3a008590b0b8d7388c20293e9dcc9cf3e5d642fd2a114e4cbbb52e595d940a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "b0c823996102bcd0239b3c2444eb00409b72f6a140c1950bc8b457d836b30684"},
|
||||
"jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"},
|
||||
"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.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"},
|
||||
"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"},
|
||||
"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.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"},
|
||||
"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"},
|
||||
"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.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"},
|
||||
"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"},
|
||||
"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.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_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_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.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": {: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_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.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"},
|
||||
"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"},
|
||||
"ranch": {:hex, :ranch, "2.2.0", "25528f82bc8d7c6152c57666ca99ec716510fe0925cb188172f41ce93117b1b0", [:make, :rebar3], [], "hexpm", "fa0b99a1780c80218a4197a59ea8d3bdae32fbff7e88527d7d8a4787eff4f8e7"},
|
||||
"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"},
|
||||
"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"},
|
||||
"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.0", "ff8effecc509a51245df2f864ec78d849248647c37a75886033e3b1a53ca9470", [:mix], [], "hexpm", "73cfd0892d7316d6f2c93e6e8784bd6e137b2aa38443de52fd0a25171d106d81"},
|
||||
"splode": {:hex, :splode, "0.3.1", "9843c54f84f71b7833fec3f0be06c3cfb5be6b35960ee195ea4fad84b1c25030", [:mix], [], "hexpm", "8f2309b6ec2ecbb01435656429ed1d9ed04ba28797a3280c3b0d1217018ecfbd"},
|
||||
"stream_data": {:hex, :stream_data, "1.3.0", "bde37905530aff386dea1ddd86ecbf00e6642dc074ceffc10b7d4e41dfd6aac9", [:mix], [], "hexpm", "3cc552e286e817dca43c98044c706eec9318083a1480c52ae2688b08e2936e3c"},
|
||||
"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"},
|
||||
"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"},
|
||||
"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"},
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
--- 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()
|
||||
@@ -1,49 +0,0 @@
|
||||
{
|
||||
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;
|
||||
};
|
||||
}
|
||||
@@ -1,143 +0,0 @@
|
||||
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
|
||||
@@ -1,134 +0,0 @@
|
||||
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
|
||||
@@ -1,66 +0,0 @@
|
||||
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
|
||||
@@ -1,231 +0,0 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
@@ -1,231 +0,0 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
@@ -1,209 +0,0 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"ash_functions_version": 5,
|
||||
"installed": [
|
||||
"ash-functions",
|
||||
"citext"
|
||||
]
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
#!/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"
|
||||
+1
-3
@@ -7,8 +7,6 @@ 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
|
||||
@@ -79,7 +77,7 @@ fi
|
||||
shift
|
||||
|
||||
case "$command_name" in
|
||||
validate | test | precommit | ecto.migration | ash.codegen | smoke | shell) ;;
|
||||
validate | test | precommit | smoke | shell) ;;
|
||||
-h | --help | help)
|
||||
usage
|
||||
exit 0
|
||||
|
||||
+7
-4
@@ -44,6 +44,9 @@ 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() {
|
||||
@@ -67,13 +70,13 @@ sed_in_place() {
|
||||
while IFS= read -r -d '' file; do
|
||||
sed_in_place "$file"
|
||||
done < <(
|
||||
find . -type f \
|
||||
\( -name '*.ex' -o -name '*.exs' -o -name '*.json' -o -name '*.js' -o -name '*.css' -o -name '*.md' -o -name '*.yml' -o -name '*.yaml' -o -name '.formatter.exs' \) \
|
||||
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' \) \
|
||||
-print0
|
||||
)
|
||||
|
||||
# Rename asset files
|
||||
for ext in js css; do
|
||||
for ext in js ts css; do
|
||||
if [ -f "assets/$ext/my_plugin.$ext" ]; then
|
||||
mv "assets/$ext/my_plugin.$ext" "assets/$ext/$SNAKE.$ext"
|
||||
fi
|
||||
@@ -84,4 +87,4 @@ done
|
||||
|
||||
echo "Done. Review the changes, then:"
|
||||
echo " 1. Edit manifest.json — set description, capabilities"
|
||||
echo " 2. Run: mix deps.get && mix test"
|
||||
echo " 2. Run: mix deps.get && scripts/plugin smoke && scripts/plugin test"
|
||||
|
||||
@@ -1,244 +0,0 @@
|
||||
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
|
||||
@@ -1,74 +0,0 @@
|
||||
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
|
||||
@@ -1,90 +0,0 @@
|
||||
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
|
||||
@@ -1,82 +0,0 @@
|
||||
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
|
||||
@@ -1,11 +0,0 @@
|
||||
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
|
||||
@@ -1,3 +0,0 @@
|
||||
defmodule TribeOne.TribesPlugin.Aether.PluginContractTest do
|
||||
use Tribes.PluginTest.ContractTest, plugin: TribeOne.TribesPlugin.Aether.Plugin
|
||||
end
|
||||
@@ -1,44 +0,0 @@
|
||||
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
|
||||
@@ -0,0 +1,17 @@
|
||||
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
|
||||
@@ -0,0 +1,3 @@
|
||||
defmodule MyPlugin.PluginContractTest do
|
||||
use Tribes.PluginTest.ContractTest, plugin: Tribes.Plugins.MyPlugin.Plugin
|
||||
end
|
||||
@@ -1,10 +1 @@
|
||||
ExUnit.start()
|
||||
|
||||
{:ok, _apps} = Application.ensure_all_started(:tribes)
|
||||
|
||||
Ecto.Migrator.run(
|
||||
Tribes.Repo,
|
||||
Path.expand("../priv/repo/migrations", __DIR__),
|
||||
:up,
|
||||
all: true
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user