You've already forked tribes-plugin-template
Compare commits
47 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 80101b7e78 | |||
| 1483d30d7e | |||
| 1cbcb5ea55 | |||
| 742359c2cb | |||
| 3bc16c7cb5 | |||
| d0ba98f382 | |||
| 603ad5d0ae | |||
| 34bec7225b | |||
| b4b8c83ddb | |||
| 44b9c6caba | |||
| c1f4339dde | |||
| 341dcb573f | |||
| 446fffcadc | |||
| 2884f43f9a | |||
| 8b1990ee25 | |||
| a55bd9612d | |||
| d257221dc8 | |||
| 421fa01076 | |||
| f386cd38f5 | |||
| 21dd359b2f | |||
| 763f1ffe14 | |||
| 8f8748b278 | |||
| a6fa08118f | |||
| 09fa00c92c | |||
| 0fa953420b | |||
| 20be0857a5 | |||
| ca4995aa2b | |||
| dbb5491020 | |||
| da003b0a4f | |||
| 24f314a6d5 | |||
| aecc7da70b | |||
| c0eab283a1 | |||
| 5d6ab457ef | |||
| c121bde9b8 | |||
| 2afde628ca | |||
| 5024e8857c | |||
| 1a0ba6a93c | |||
| 118d32241f | |||
| 6fc8be20bf | |||
| 8297334ea5 | |||
| c904de90d7 | |||
| 4b7fe1b9ed | |||
| a07942b644 | |||
| 63e10ad5bc | |||
| f833791991 | |||
| 6645ec48a0 | |||
| 78f6c11b30 |
+179
@@ -0,0 +1,179 @@
|
||||
# This file contains the configuration for Credo and you are probably reading
|
||||
# this after creating it with `mix credo.gen.config`.
|
||||
#
|
||||
# If you find anything wrong or unclear in this file, please report an
|
||||
# issue on GitHub: https://github.com/rrrene/credo/issues
|
||||
#
|
||||
%{
|
||||
#
|
||||
# You can have as many configs as you like in the `configs:` field.
|
||||
configs: [
|
||||
%{
|
||||
#
|
||||
# Run any config using `mix credo -C <name>`. If no config name is given
|
||||
# "default" is used.
|
||||
#
|
||||
name: "default",
|
||||
#
|
||||
# These are the files included in the analysis:
|
||||
files: %{
|
||||
#
|
||||
# You can give explicit globs or simply directories.
|
||||
# In the latter case `**/*.{ex,exs}` will be used.
|
||||
#
|
||||
included: [
|
||||
"lib/",
|
||||
"src/",
|
||||
"test/"
|
||||
],
|
||||
excluded: [~r"/_build/", ~r"/deps/", ~r"/node_modules/"]
|
||||
},
|
||||
#
|
||||
# Load and configure plugins here:
|
||||
#
|
||||
plugins: [],
|
||||
#
|
||||
# If you create your own checks, you must specify the source files for
|
||||
# them here, so they can be loaded by Credo before running the analysis.
|
||||
#
|
||||
requires: [],
|
||||
#
|
||||
# If you want to enforce a style guide and need a more traditional linting
|
||||
# experience, you can change `strict` to `true` below:
|
||||
#
|
||||
strict: false,
|
||||
#
|
||||
# To modify the timeout for parsing files, change this value:
|
||||
#
|
||||
parse_timeout: 5000,
|
||||
#
|
||||
# If you want to use uncolored output by default, you can change `color`
|
||||
# to `false` below:
|
||||
#
|
||||
color: true,
|
||||
#
|
||||
# You can customize the parameters of any check by adding a second element
|
||||
# to the tuple.
|
||||
#
|
||||
# To disable a check put `false` as second element:
|
||||
#
|
||||
# {Credo.Check.Design.DuplicatedCode, false}
|
||||
#
|
||||
checks: [
|
||||
#
|
||||
## Consistency Checks
|
||||
#
|
||||
{Credo.Check.Consistency.ExceptionNames, []},
|
||||
{Credo.Check.Consistency.LineEndings, []},
|
||||
{Credo.Check.Consistency.ParameterPatternMatching, []},
|
||||
{Credo.Check.Consistency.SpaceAroundOperators, []},
|
||||
{Credo.Check.Consistency.SpaceInParentheses, []},
|
||||
{Credo.Check.Consistency.TabsOrSpaces, []},
|
||||
|
||||
#
|
||||
## Design Checks
|
||||
#
|
||||
# You can customize the priority of any check
|
||||
# Priority values are: `low, normal, high, higher`
|
||||
#
|
||||
{Credo.Check.Design.AliasUsage, false},
|
||||
# You can also customize the exit_status of each check.
|
||||
# If you don't want TODO comments to cause `mix credo` to fail, just
|
||||
# set this value to 0 (zero).
|
||||
#
|
||||
{Credo.Check.Design.TagTODO, [exit_status: 2]},
|
||||
{Credo.Check.Design.TagFIXME, []},
|
||||
|
||||
#
|
||||
## Readability Checks
|
||||
#
|
||||
{Credo.Check.Readability.AliasOrder, []},
|
||||
{Credo.Check.Readability.FunctionNames, []},
|
||||
{Credo.Check.Readability.LargeNumbers, []},
|
||||
{Credo.Check.Readability.MaxLineLength, [priority: :low, max_length: 120]},
|
||||
{Credo.Check.Readability.ModuleAttributeNames, []},
|
||||
{Credo.Check.Readability.ModuleDoc, []},
|
||||
{Credo.Check.Readability.ModuleNames, []},
|
||||
{Credo.Check.Readability.ParenthesesInCondition, []},
|
||||
{Credo.Check.Readability.ParenthesesOnZeroArityDefs, []},
|
||||
{Credo.Check.Readability.PredicateFunctionNames, []},
|
||||
{Credo.Check.Readability.PreferImplicitTry, []},
|
||||
{Credo.Check.Readability.RedundantBlankLines, []},
|
||||
{Credo.Check.Readability.Semicolons, []},
|
||||
{Credo.Check.Readability.SpaceAfterCommas, []},
|
||||
{Credo.Check.Readability.StringSigils, []},
|
||||
{Credo.Check.Readability.TrailingBlankLine, []},
|
||||
{Credo.Check.Readability.TrailingWhiteSpace, []},
|
||||
{Credo.Check.Readability.UnnecessaryAliasExpansion, []},
|
||||
{Credo.Check.Readability.VariableNames, []},
|
||||
|
||||
#
|
||||
## Refactoring Opportunities
|
||||
#
|
||||
{Credo.Check.Refactor.CondStatements, []},
|
||||
{Credo.Check.Refactor.CyclomaticComplexity, false},
|
||||
{Credo.Check.Refactor.FunctionArity, [max_arity: 13]},
|
||||
{Credo.Check.Refactor.LongQuoteBlocks, false},
|
||||
{Credo.Check.Refactor.MapInto, false},
|
||||
{Credo.Check.Refactor.MatchInCondition, []},
|
||||
{Credo.Check.Refactor.NegatedConditionsInUnless, []},
|
||||
{Credo.Check.Refactor.NegatedConditionsWithElse, []},
|
||||
{Credo.Check.Refactor.Nesting, [max_nesting: 7]},
|
||||
{Credo.Check.Refactor.UnlessWithElse, []},
|
||||
{Credo.Check.Refactor.WithClauses, []},
|
||||
|
||||
#
|
||||
## Warnings
|
||||
#
|
||||
{Credo.Check.Warning.BoolOperationOnSameValues, []},
|
||||
{Credo.Check.Warning.ExpensiveEmptyEnumCheck, []},
|
||||
{Credo.Check.Warning.IExPry, []},
|
||||
{Credo.Check.Warning.IoInspect, []},
|
||||
{Credo.Check.Warning.LazyLogging, false},
|
||||
{Credo.Check.Warning.MixEnv, false},
|
||||
{Credo.Check.Warning.OperationOnSameValues, []},
|
||||
{Credo.Check.Warning.OperationWithConstantResult, []},
|
||||
{Credo.Check.Warning.RaiseInsideRescue, []},
|
||||
{Credo.Check.Warning.UnusedEnumOperation, []},
|
||||
{Credo.Check.Warning.UnusedFileOperation, []},
|
||||
{Credo.Check.Warning.UnusedKeywordOperation, []},
|
||||
{Credo.Check.Warning.UnusedListOperation, []},
|
||||
{Credo.Check.Warning.UnusedPathOperation, []},
|
||||
{Credo.Check.Warning.UnusedRegexOperation, []},
|
||||
{Credo.Check.Warning.UnusedStringOperation, []},
|
||||
{Credo.Check.Warning.UnusedTupleOperation, []},
|
||||
{Credo.Check.Warning.UnsafeExec, []},
|
||||
|
||||
#
|
||||
# Checks scheduled for next check update (opt-in for now, just replace `false` with `[]`)
|
||||
|
||||
#
|
||||
# Controversial and experimental checks (opt-in, just replace `false` with `[]`)
|
||||
#
|
||||
{Credo.Check.Readability.StrictModuleLayout, false},
|
||||
{Credo.Check.Consistency.MultiAliasImportRequireUse, false},
|
||||
{Credo.Check.Consistency.UnusedVariableNames, false},
|
||||
{Credo.Check.Design.DuplicatedCode, false},
|
||||
{Credo.Check.Readability.AliasAs, false},
|
||||
{Credo.Check.Readability.MultiAlias, false},
|
||||
{Credo.Check.Readability.Specs, false},
|
||||
{Credo.Check.Readability.SinglePipe, false},
|
||||
{Credo.Check.Readability.WithCustomTaggedTuple, false},
|
||||
{Credo.Check.Refactor.ABCSize, false},
|
||||
{Credo.Check.Refactor.AppendSingleItem, false},
|
||||
{Credo.Check.Refactor.DoubleBooleanNegation, false},
|
||||
{Credo.Check.Refactor.ModuleDependencies, false},
|
||||
{Credo.Check.Refactor.NegatedIsNil, false},
|
||||
{Credo.Check.Refactor.PipeChainStart, false},
|
||||
{Credo.Check.Refactor.VariableRebinding, false},
|
||||
{Credo.Check.Warning.LeakyEnvironment, false},
|
||||
{Credo.Check.Warning.MapGetUnsafePass, false},
|
||||
{Credo.Check.Warning.UnsafeToAtom, false}
|
||||
|
||||
#
|
||||
# Custom checks can be created using `mix credo.gen.check`.
|
||||
#
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
+21
@@ -2,10 +2,17 @@
|
||||
/deps/
|
||||
/_build/
|
||||
/dist/
|
||||
/result
|
||||
/result-*
|
||||
|
||||
# Node
|
||||
/node_modules/
|
||||
/assets/node_modules/
|
||||
/priv/static/*
|
||||
!/priv/static/.gitkeep
|
||||
|
||||
# Browser tooling
|
||||
.playwright-mcp/
|
||||
|
||||
# Generated on crash
|
||||
erl_crash.dump
|
||||
@@ -23,3 +30,17 @@ Thumbs.db
|
||||
# Env files
|
||||
.env
|
||||
.env.local
|
||||
|
||||
# To do list
|
||||
/TODO.md
|
||||
|
||||
# Devenv
|
||||
.devenv*
|
||||
.env
|
||||
.null-ls_*.nix
|
||||
devenv.local.nix
|
||||
.guix-dev/
|
||||
.dev-shell
|
||||
|
||||
# direnv
|
||||
.direnv
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
# Shared by Nix/devenv and Guix shells. Keep hook entries PATH-based.
|
||||
default_stages:
|
||||
- pre-commit
|
||||
|
||||
repos:
|
||||
- repo: local
|
||||
hooks:
|
||||
- id: check-added-large-files
|
||||
name: check added large files
|
||||
entry: scripts/hooks/check-added-large-files
|
||||
args: ["--maxkb=131072"]
|
||||
language: system
|
||||
types: [file]
|
||||
stages: [pre-commit, pre-push, manual]
|
||||
|
||||
- id: mix-format
|
||||
name: mix format
|
||||
entry: mix format
|
||||
language: system
|
||||
files: '\.(ex|exs|heex)$'
|
||||
types: [file]
|
||||
stages: [pre-commit]
|
||||
|
||||
- id: prettier
|
||||
name: prettier
|
||||
entry: prettier
|
||||
args:
|
||||
- --ignore-unknown
|
||||
- --list-different
|
||||
- --write
|
||||
language: system
|
||||
files: '\.(js|ts|tsx|css)$'
|
||||
types: [text]
|
||||
stages: [pre-commit]
|
||||
@@ -0,0 +1,669 @@
|
||||
# Tribes Aether Plugin Agent Guide
|
||||
|
||||
This repository is the Tribes Aether plugin. Keep changes scoped to the plugin
|
||||
contract, migrations, assets, and tests owned by this repo.
|
||||
|
||||
## Required Workflow
|
||||
|
||||
- Use `plugin` for plugin-aware commands inside the repo development shell: `plugin validate`,
|
||||
`plugin test`, `plugin precommit`, and `plugin smoke`. Outside the development
|
||||
shell, use the local `scripts/plugin` wrapper.
|
||||
- Prefer `devenv shell -- <command>` when running repo commands from outside the
|
||||
development shell. If `devenv` is unavailable, use `./guix/guix-dev -- <command>`
|
||||
rather than raw `guix shell` so pinned channels from `guix/channels.scm` are used.
|
||||
- Raw `mix test` is not the normal entrypoint for this repo; `plugin test`
|
||||
invokes `mix raw_test` with the host database/services and host
|
||||
plugin-manager paths.
|
||||
- Keep browser assets under the plugin asset pipeline and avoid host app
|
||||
internals unless the plugin API requires them.
|
||||
- For AshPostgres resource changes, generate migrations with
|
||||
`plugin ash.codegen <name>`. Use `plugin ecto.migration <name>` only for rare
|
||||
manual schema migrations that are not derived from Ash resources.
|
||||
- Do not edit an existing migration after it may have run; add a new migration
|
||||
instead.
|
||||
|
||||
## Plugin Contract
|
||||
|
||||
- `manifest.json` `entry_module` must point at `TribeOne.TribesPlugin.Aether.Plugin`.
|
||||
- Keep runtime spec fields aligned with `manifest.json`.
|
||||
- Treat host integrations as capability-based plugin contracts.
|
||||
|
||||
|
||||
<!-- usage-rules-start -->
|
||||
<!-- usage_rules-start -->
|
||||
## usage_rules usage
|
||||
_A config-driven dev tool for Elixir projects to manage AGENTS.md files and agent skills from dependencies_
|
||||
|
||||
## Using Usage Rules
|
||||
|
||||
Many packages have usage rules, which you should *thoroughly* consult before taking any
|
||||
action. These usage rules contain guidelines and rules *directly from the package authors*.
|
||||
They are your best source of knowledge for making decisions.
|
||||
|
||||
## Modules & functions in the current app and dependencies
|
||||
|
||||
When looking for docs for modules & functions that are dependencies of the current project,
|
||||
or for Elixir itself, use `mix usage_rules.docs`
|
||||
|
||||
```
|
||||
# Search a whole module
|
||||
mix usage_rules.docs Enum
|
||||
|
||||
# Search a specific function
|
||||
mix usage_rules.docs Enum.zip
|
||||
|
||||
# Search a specific function & arity
|
||||
mix usage_rules.docs Enum.zip/1
|
||||
```
|
||||
|
||||
|
||||
## Searching Documentation
|
||||
|
||||
You should also consult the documentation of any tools you are using, early and often. The best
|
||||
way to accomplish this is to use the `usage_rules.search_docs` mix task. Once you have
|
||||
found what you are looking for, use the links in the search results to get more detail. For example:
|
||||
|
||||
```
|
||||
# Search docs for all packages in the current application, including Elixir
|
||||
mix usage_rules.search_docs Enum.zip
|
||||
|
||||
# Search docs for specific packages
|
||||
mix usage_rules.search_docs Req.get -p req
|
||||
|
||||
# Search docs for multi-word queries
|
||||
mix usage_rules.search_docs "making requests" -p req
|
||||
|
||||
# Search only in titles (useful for finding specific functions/modules)
|
||||
mix usage_rules.search_docs "Enum.zip" --query-by title
|
||||
```
|
||||
|
||||
|
||||
<!-- usage_rules-end -->
|
||||
<!-- usage_rules:elixir-start -->
|
||||
## usage_rules:elixir usage
|
||||
# Elixir Core Usage Rules
|
||||
|
||||
## Pattern Matching
|
||||
- Use pattern matching over conditional logic when possible
|
||||
- Prefer to match on function heads instead of using `if`/`else` or `case` in function bodies
|
||||
- `%{}` matches ANY map, not just empty maps. Use `map_size(map) == 0` guard to check for truly empty maps
|
||||
|
||||
## Error Handling
|
||||
- Use `{:ok, result}` and `{:error, reason}` tuples for operations that can fail
|
||||
- Avoid raising exceptions for control flow
|
||||
- Use `with` for chaining operations that return `{:ok, _}` or `{:error, _}`
|
||||
|
||||
## Common Mistakes to Avoid
|
||||
- Elixir has no `return` statement, nor early returns. The last expression in a block is always returned.
|
||||
- Don't use `Enum` functions on large collections when `Stream` is more appropriate
|
||||
- Avoid nested `case` statements - refactor to a single `case`, `with` or separate functions
|
||||
- Don't use `String.to_atom/1` on user input (memory leak risk)
|
||||
- Lists and enumerables cannot be indexed with brackets. Use pattern matching or `Enum` functions
|
||||
- Prefer `Enum` functions like `Enum.reduce` over recursion
|
||||
- When recursion is necessary, prefer to use pattern matching in function heads for base case detection
|
||||
- Using the process dictionary is typically a sign of unidiomatic code
|
||||
- Only use macros if explicitly requested
|
||||
- There are many useful standard library functions, prefer to use them where possible
|
||||
|
||||
## Function Design
|
||||
- Use guard clauses: `when is_binary(name) and byte_size(name) > 0`
|
||||
- Prefer multiple function clauses over complex conditional logic
|
||||
- Name functions descriptively: `calculate_total_price/2` not `calc/2`
|
||||
- Predicate function names should not start with `is` and should end in a question mark.
|
||||
- Names like `is_thing` should be reserved for guards
|
||||
|
||||
## Data Structures
|
||||
- Use structs over maps when the shape is known: `defstruct [:name, :age]`
|
||||
- Prefer keyword lists for options: `[timeout: 5000, retries: 3]`
|
||||
- Use maps for dynamic key-value data
|
||||
- Prefer to prepend to lists `[new | list]` not `list ++ [new]`
|
||||
|
||||
## Mix Tasks
|
||||
|
||||
- Use `mix help` to list available mix tasks
|
||||
- Use `mix help task_name` to get docs for an individual task
|
||||
- Read the docs and options fully before using tasks
|
||||
|
||||
## Testing
|
||||
- Run tests in a specific file with `mix test test/my_test.exs` and a specific test with the line number `mix test path/to/test.exs:123`
|
||||
- Limit the number of failed tests with `mix test --max-failures n`
|
||||
- Use `@tag` to tag specific tests, and `mix test --only tag` to run only those tests
|
||||
- Use `assert_raise` for testing expected exceptions: `assert_raise ArgumentError, fn -> invalid_function() end`
|
||||
- Use `mix help test` to for full documentation on running tests
|
||||
|
||||
## Debugging
|
||||
|
||||
- Use `dbg/1` to print values while debugging. This will display the formatted value and other relevant information in the console.
|
||||
|
||||
<!-- usage_rules:elixir-end -->
|
||||
<!-- usage_rules:otp-start -->
|
||||
## usage_rules:otp usage
|
||||
# OTP Usage Rules
|
||||
|
||||
## GenServer Best Practices
|
||||
- Keep state simple and serializable
|
||||
- Handle all expected messages explicitly
|
||||
- Use `handle_continue/2` for post-init work
|
||||
- Implement proper cleanup in `terminate/2` when necessary
|
||||
|
||||
## Process Communication
|
||||
- Use `GenServer.call/3` for synchronous requests expecting replies
|
||||
- Use `GenServer.cast/2` for fire-and-forget messages.
|
||||
- When in doubt, use `call` over `cast`, to ensure back-pressure
|
||||
- Set appropriate timeouts for `call/3` operations
|
||||
|
||||
## Fault Tolerance
|
||||
- Set up processes such that they can handle crashing and being restarted by supervisors
|
||||
- Use `:max_restarts` and `:max_seconds` to prevent restart loops
|
||||
|
||||
## Task and Async
|
||||
- Use `Task.Supervisor` for better fault tolerance
|
||||
- Handle task failures with `Task.yield/2` or `Task.shutdown/2`
|
||||
- Set appropriate task timeouts
|
||||
- Use `Task.async_stream/3` for concurrent enumeration with back-pressure
|
||||
|
||||
<!-- usage_rules:otp-end -->
|
||||
<!-- phoenix:ecto-start -->
|
||||
## phoenix:ecto usage
|
||||
## Ecto Guidelines
|
||||
|
||||
- **Always** preload Ecto associations in queries when they'll be accessed in templates, ie a message that needs to reference the `message.user.email`
|
||||
- Remember `import Ecto.Query` and other supporting modules when you write `seeds.exs`
|
||||
- `Ecto.Schema` fields always use the `:string` type, even for `:text`, columns, ie: `field :name, :string`
|
||||
- `Ecto.Changeset.validate_number/2` **DOES NOT SUPPORT the `:allow_nil` option**. By default, Ecto validations only run if a change for the given field exists and the change value is not nil, so such as option is never needed
|
||||
- You **must** use `Ecto.Changeset.get_field(changeset, :field)` to access changeset fields
|
||||
- Fields which are set programmatically, such as `user_id`, must not be listed in `cast` calls or similar for security purposes. Instead they must be explicitly set when creating the struct
|
||||
- **Always** invoke `mix ecto.gen.migration migration_name_using_underscores` when generating migration files, so the correct timestamp and conventions are applied
|
||||
|
||||
<!-- phoenix:ecto-end -->
|
||||
<!-- phoenix:html-start -->
|
||||
## phoenix:html usage
|
||||
## Phoenix HTML guidelines
|
||||
|
||||
- Phoenix templates **always** use `~H` or .html.heex files (known as HEEx), **never** use `~E`
|
||||
- **Always** use the imported `Phoenix.Component.form/1` and `Phoenix.Component.inputs_for/1` function to build forms. **Never** use `Phoenix.HTML.form_for` or `Phoenix.HTML.inputs_for` as they are outdated
|
||||
- When building forms **always** use the already imported `Phoenix.Component.to_form/2` (`assign(socket, form: to_form(...))` and `<.form for={@form} id="msg-form">`), then access those forms in the template via `@form[:field]`
|
||||
- **Always** add unique DOM IDs to key elements (like forms, buttons, etc) when writing templates, these IDs can later be used in tests (`<.form for={@form} id="product-form">`)
|
||||
- For "app wide" template imports, you can import/alias into the `my_app_web.ex`'s `html_helpers` block, so they will be available to all LiveViews, LiveComponent's, and all modules that do `use MyAppWeb, :html` (replace "my_app" by the actual app name)
|
||||
|
||||
- Elixir supports `if/else` but **does NOT support `if/else if` or `if/elsif`**. **Never use `else if` or `elseif` in Elixir**, **always** use `cond` or `case` for multiple conditionals.
|
||||
|
||||
**Never do this (invalid)**:
|
||||
|
||||
<%= if condition do %>
|
||||
...
|
||||
<% else if other_condition %>
|
||||
...
|
||||
<% end %>
|
||||
|
||||
Instead **always** do this:
|
||||
|
||||
<%= cond do %>
|
||||
<% condition -> %>
|
||||
...
|
||||
<% condition2 -> %>
|
||||
...
|
||||
<% true -> %>
|
||||
...
|
||||
<% end %>
|
||||
|
||||
- HEEx require special tag annotation if you want to insert literal curly's like `{` or `}`. If you want to show a textual code snippet on the page in a `<pre>` or `<code>` block you *must* annotate the parent tag with `phx-no-curly-interpolation`:
|
||||
|
||||
<code phx-no-curly-interpolation>
|
||||
let obj = {key: "val"}
|
||||
</code>
|
||||
|
||||
Within `phx-no-curly-interpolation` annotated tags, you can use `{` and `}` without escaping them, and dynamic Elixir expressions can still be used with `<%= ... %>` syntax
|
||||
|
||||
- HEEx class attrs support lists, but you must **always** use list `[...]` syntax. You can use the class list syntax to conditionally add classes, **always do this for multiple class values**:
|
||||
|
||||
<a class={[
|
||||
"px-2 text-white",
|
||||
@some_flag && "py-5",
|
||||
if(@other_condition, do: "border-red-500", else: "border-blue-100"),
|
||||
...
|
||||
]}>Text</a>
|
||||
|
||||
and **always** wrap `if`'s inside `{...}` expressions with parens, like done above (`if(@other_condition, do: "...", else: "...")`)
|
||||
|
||||
and **never** do this, since it's invalid (note the missing `[` and `]`):
|
||||
|
||||
<a class={
|
||||
"px-2 text-white",
|
||||
@some_flag && "py-5"
|
||||
}> ...
|
||||
=> Raises compile syntax error on invalid HEEx attr syntax
|
||||
|
||||
- **Never** use `<% Enum.each %>` or non-for comprehensions for generating template content, instead **always** use `<%= for item <- @collection do %>`
|
||||
- HEEx HTML comments use `<%!-- comment --%>`. **Always** use the HEEx HTML comment syntax for template comments (`<%!-- comment --%>`)
|
||||
- HEEx allows interpolation via `{...}` and `<%= ... %>`, but the `<%= %>` **only** works within tag bodies. **Always** use the `{...}` syntax for interpolation within tag attributes, and for interpolation of values within tag bodies. **Always** interpolate block constructs (if, cond, case, for) within tag bodies using `<%= ... %>`.
|
||||
|
||||
**Always** do this:
|
||||
|
||||
<div id={@id}>
|
||||
{@my_assign}
|
||||
<%= if @some_block_condition do %>
|
||||
{@another_assign}
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
and **Never** do this – the program will terminate with a syntax error:
|
||||
|
||||
<%!-- THIS IS INVALID NEVER EVER DO THIS --%>
|
||||
<div id="<%= @invalid_interpolation %>">
|
||||
{if @invalid_block_construct do}
|
||||
{end}
|
||||
</div>
|
||||
|
||||
<!-- phoenix:html-end -->
|
||||
<!-- phoenix:liveview-start -->
|
||||
## phoenix:liveview usage
|
||||
## Phoenix LiveView guidelines
|
||||
|
||||
- **Never** use the deprecated `live_redirect` and `live_patch` functions, instead **always** use the `<.link navigate={href}>` and `<.link patch={href}>` in templates, and `push_navigate` and `push_patch` functions LiveViews
|
||||
- **Avoid LiveComponent's** unless you have a strong, specific need for them
|
||||
- LiveViews should be named like `AppWeb.WeatherLive`, with a `Live` suffix. When you go to add LiveView routes to the router, the default `:browser` scope is **already aliased** with the `AppWeb` module, so you can just do `live "/weather", WeatherLive`
|
||||
|
||||
### LiveView streams
|
||||
|
||||
- **Always** use LiveView streams for collections for assigning regular lists to avoid memory ballooning and runtime termination with the following operations:
|
||||
- basic append of N items - `stream(socket, :messages, [new_msg])`
|
||||
- resetting stream with new items - `stream(socket, :messages, [new_msg], reset: true)` (e.g. for filtering items)
|
||||
- prepend to stream - `stream(socket, :messages, [new_msg], at: -1)`
|
||||
- deleting items - `stream_delete(socket, :messages, msg)`
|
||||
|
||||
- When using the `stream/3` interfaces in the LiveView, the LiveView template must 1) always set `phx-update="stream"` on the parent element, with a DOM id on the parent element like `id="messages"` and 2) consume the `@streams.stream_name` collection and use the id as the DOM id for each child. For a call like `stream(socket, :messages, [new_msg])` in the LiveView, the template would be:
|
||||
|
||||
<div id="messages" phx-update="stream">
|
||||
<div :for={{id, msg} <- @streams.messages} id={id}>
|
||||
{msg.text}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
- LiveView streams are *not* enumerable, so you cannot use `Enum.filter/2` or `Enum.reject/2` on them. Instead, if you want to filter, prune, or refresh a list of items on the UI, you **must refetch the data and re-stream the entire stream collection, passing reset: true**:
|
||||
|
||||
def handle_event("filter", %{"filter" => filter}, socket) do
|
||||
# re-fetch the messages based on the filter
|
||||
messages = list_messages(filter)
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:messages_empty?, messages == [])
|
||||
# reset the stream with the new messages
|
||||
|> stream(:messages, messages, reset: true)}
|
||||
end
|
||||
|
||||
- LiveView streams *do not support counting or empty states*. If you need to display a count, you must track it using a separate assign. For empty states, you can use Tailwind classes:
|
||||
|
||||
<div id="tasks" phx-update="stream">
|
||||
<div class="hidden only:block">No tasks yet</div>
|
||||
<div :for={{id, task} <- @streams.tasks} id={id}>
|
||||
{task.name}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
The above only works if the empty state is the only HTML block alongside the stream for-comprehension.
|
||||
|
||||
- When updating an assign that should change content inside any streamed item(s), you MUST re-stream the items
|
||||
along with the updated assign:
|
||||
|
||||
def handle_event("edit_message", %{"message_id" => message_id}, socket) do
|
||||
message = Chat.get_message!(message_id)
|
||||
edit_form = to_form(Chat.change_message(message, %{content: message.content}))
|
||||
|
||||
# re-insert message so @editing_message_id toggle logic takes effect for that stream item
|
||||
{:noreply,
|
||||
socket
|
||||
|> stream_insert(:messages, message)
|
||||
|> assign(:editing_message_id, String.to_integer(message_id))
|
||||
|> assign(:edit_form, edit_form)}
|
||||
end
|
||||
|
||||
And in the template:
|
||||
|
||||
<div id="messages" phx-update="stream">
|
||||
<div :for={{id, message} <- @streams.messages} id={id} class="flex group">
|
||||
{message.username}
|
||||
<%= if @editing_message_id == message.id do %>
|
||||
<%!-- Edit mode --%>
|
||||
<.form for={@edit_form} id="edit-form-#{message.id}" phx-submit="save_edit">
|
||||
...
|
||||
</.form>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
- **Never** use the deprecated `phx-update="append"` or `phx-update="prepend"` for collections
|
||||
|
||||
### LiveView JavaScript interop
|
||||
|
||||
- Remember anytime you use `phx-hook="MyHook"` and that JS hook manages its own DOM, you **must** also set the `phx-update="ignore"` attribute
|
||||
- **Always** provide an unique DOM id alongside `phx-hook` otherwise a compiler error will be raised
|
||||
|
||||
LiveView hooks come in two flavors, 1) colocated js hooks for "inline" scripts defined inside HEEx,
|
||||
and 2) external `phx-hook` annotations where JavaScript object literals are defined and passed to the `LiveSocket` constructor.
|
||||
|
||||
#### Inline colocated js hooks
|
||||
|
||||
**Never** write raw embedded `<script>` tags in heex as they are incompatible with LiveView.
|
||||
Instead, **always use a colocated js hook script tag (`:type={Phoenix.LiveView.ColocatedHook}`)
|
||||
when writing scripts inside the template**:
|
||||
|
||||
<input type="text" name="user[phone_number]" id="user-phone-number" phx-hook=".PhoneNumber" />
|
||||
<script :type={Phoenix.LiveView.ColocatedHook} name=".PhoneNumber">
|
||||
export default {
|
||||
mounted() {
|
||||
this.el.addEventListener("input", e => {
|
||||
let match = this.el.value.replace(/\D/g, "").match(/^(\d{3})(\d{3})(\d{4})$/)
|
||||
if(match) {
|
||||
this.el.value = `${match[1]}-${match[2]}-${match[3]}`
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
</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 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:
|
||||
|
||||
window.TribesPluginHooks = window.TribesPluginHooks || {};
|
||||
window.TribesPluginHooks.MyHook = {
|
||||
mounted() { ... }
|
||||
};
|
||||
|
||||
#### Pushing events between client and server
|
||||
|
||||
Use LiveView's `push_event/3` when you need to push events/data to the client for a phx-hook to handle.
|
||||
**Always** return or rebind the socket on `push_event/3` when pushing events:
|
||||
|
||||
# re-bind socket so we maintain event state to be pushed
|
||||
socket = push_event(socket, "my_event", %{...})
|
||||
|
||||
# or return the modified socket directly:
|
||||
def handle_event("some_event", _, socket) do
|
||||
{:noreply, push_event(socket, "my_event", %{...})}
|
||||
end
|
||||
|
||||
Pushed events can then be picked up in a JS hook with `this.handleEvent`:
|
||||
|
||||
mounted() {
|
||||
this.handleEvent("my_event", data => console.log("from server:", data));
|
||||
}
|
||||
|
||||
Clients can also push an event to the server and receive a reply with `this.pushEvent`:
|
||||
|
||||
mounted() {
|
||||
this.el.addEventListener("click", e => {
|
||||
this.pushEvent("my_event", { one: 1 }, reply => console.log("got reply from server:", reply));
|
||||
})
|
||||
}
|
||||
|
||||
Where the server handled it via:
|
||||
|
||||
def handle_event("my_event", %{"one" => 1}, socket) do
|
||||
{:reply, %{two: 2}, socket}
|
||||
end
|
||||
|
||||
### LiveView tests
|
||||
|
||||
- `Phoenix.LiveViewTest` module and `LazyHTML` (included) for making your assertions
|
||||
- Form tests are driven by `Phoenix.LiveViewTest`'s `render_submit/2` and `render_change/2` functions
|
||||
- Come up with a step-by-step test plan that splits major test cases into small, isolated files. You may start with simpler tests that verify content exists, gradually add interaction tests
|
||||
- **Always reference the key element IDs you added in the LiveView templates in your tests** for `Phoenix.LiveViewTest` functions like `element/2`, `has_element/2`, selectors, etc
|
||||
- **Never** tests again raw HTML, **always** use `element/2`, `has_element/2`, and similar: `assert has_element?(view, "#my-form")`
|
||||
- Instead of relying on testing text content, which can change, favor testing for the presence of key elements
|
||||
- Focus on testing outcomes rather than implementation details
|
||||
- Be aware that `Phoenix.Component` functions like `<.form>` might produce different HTML than expected. Test against the output HTML structure, not your mental model of what you expect it to be
|
||||
- When facing test failures with element selectors, add debug statements to print the actual HTML, but use `LazyHTML` selectors to limit the output, ie:
|
||||
|
||||
html = render(view)
|
||||
document = LazyHTML.from_fragment(html)
|
||||
matches = LazyHTML.filter(document, "your-complex-selector")
|
||||
IO.inspect(matches, label: "Matches")
|
||||
|
||||
### Form handling
|
||||
|
||||
#### Creating a form from params
|
||||
|
||||
If you want to create a form based on `handle_event` params:
|
||||
|
||||
def handle_event("submitted", params, socket) do
|
||||
{:noreply, assign(socket, form: to_form(params))}
|
||||
end
|
||||
|
||||
When you pass a map to `to_form/1`, it assumes said map contains the form params, which are expected to have string keys.
|
||||
|
||||
You can also specify a name to nest the params:
|
||||
|
||||
def handle_event("submitted", %{"user" => user_params}, socket) do
|
||||
{:noreply, assign(socket, form: to_form(user_params, as: :user))}
|
||||
end
|
||||
|
||||
#### Creating a form from changesets
|
||||
|
||||
When using changesets, the underlying data, form params, and errors are retrieved from it. The `:as` option is automatically computed too. E.g. if you have a user schema:
|
||||
|
||||
defmodule MyApp.Users.User do
|
||||
use Ecto.Schema
|
||||
...
|
||||
end
|
||||
|
||||
And then you create a changeset that you pass to `to_form`:
|
||||
|
||||
%MyApp.Users.User{}
|
||||
|> Ecto.Changeset.change()
|
||||
|> to_form()
|
||||
|
||||
Once the form is submitted, the params will be available under `%{"user" => user_params}`.
|
||||
|
||||
In the template, the form form assign can be passed to the `<.form>` function component:
|
||||
|
||||
<.form for={@form} id="todo-form" phx-change="validate" phx-submit="save">
|
||||
<.input field={@form[:field]} type="text" />
|
||||
</.form>
|
||||
|
||||
Always give the form an explicit, unique DOM ID, like `id="todo-form"`.
|
||||
|
||||
#### Avoiding form errors
|
||||
|
||||
**Always** use a form assigned via `to_form/2` in the LiveView, and the `<.input>` component in the template. In the template **always access forms this**:
|
||||
|
||||
<%!-- ALWAYS do this (valid) --%>
|
||||
<.form for={@form} id="my-form">
|
||||
<.input field={@form[:field]} type="text" />
|
||||
</.form>
|
||||
|
||||
And **never** do this:
|
||||
|
||||
<%!-- NEVER do this (invalid) --%>
|
||||
<.form for={@changeset} id="my-form">
|
||||
<.input field={@changeset[:field]} type="text" />
|
||||
</.form>
|
||||
|
||||
- You are FORBIDDEN from accessing the changeset in the template as it will cause errors
|
||||
- **Never** use `<.form let={f} ...>` in the template, instead **always use `<.form for={@form} ...>`**, then drive all form references from the form assign as in `@form[:field]`. The UI should **always** be driven by a `to_form/2` assigned in the LiveView module that is derived from a changeset
|
||||
|
||||
<!-- phoenix:liveview-end -->
|
||||
<!-- phoenix:phoenix-start -->
|
||||
## phoenix:phoenix usage
|
||||
## Phoenix guidelines
|
||||
|
||||
- Remember Phoenix router `scope` blocks include an optional alias which is prefixed for all routes within the scope. **Always** be mindful of this when creating routes within a scope to avoid duplicate module prefixes.
|
||||
|
||||
- You **never** need to create your own `alias` for route definitions! The `scope` provides the alias, ie:
|
||||
|
||||
scope "/admin", AppWeb.Admin do
|
||||
pipe_through :browser
|
||||
|
||||
live "/users", UserLive, :index
|
||||
end
|
||||
|
||||
the UserLive route would point to the `AppWeb.Admin.UserLive` module
|
||||
|
||||
- `Phoenix.View` no longer is needed or included with Phoenix, don't use it
|
||||
|
||||
<!-- phoenix:phoenix-end -->
|
||||
<!-- ash-start -->
|
||||
## ash usage
|
||||
_A declarative, extensible framework for building Elixir applications._
|
||||
|
||||
# Rules for working with Ash
|
||||
|
||||
## Understanding Ash
|
||||
|
||||
Ash is an opinionated, composable framework for building applications in Elixir. It provides a declarative approach to modeling your domain with resources at the center. Read documentation *before* attempting to use its features. Do not assume that you have prior knowledge of the framework or its conventions.
|
||||
|
||||
|
||||
<!-- ash-end -->
|
||||
<!-- ash:actions-start -->
|
||||
## ash:actions usage
|
||||
[ash:actions usage rules](deps/ash/usage-rules/actions.md)
|
||||
<!-- ash:actions-end -->
|
||||
<!-- ash:migrations-start -->
|
||||
## ash:migrations usage
|
||||
[ash:migrations usage rules](deps/ash/usage-rules/migrations.md)
|
||||
<!-- ash:migrations-end -->
|
||||
<!-- ash:testing-start -->
|
||||
## ash:testing usage
|
||||
[ash:testing usage rules](deps/ash/usage-rules/testing.md)
|
||||
<!-- ash:testing-end -->
|
||||
<!-- tribes_plugin_api-start -->
|
||||
## tribes_plugin_api usage
|
||||
_tribes_plugin_api_
|
||||
|
||||
# Tribes Plugin API Usage Rules
|
||||
|
||||
These rules apply when implementing the public plugin contract from
|
||||
`tribes_plugin_api`. For deeper context from a plugin checkout, see
|
||||
[`../tribes/docs/plugins.md`](../tribes/docs/plugins.md); from this package
|
||||
source directory, the same document is at `../docs/plugins.md`.
|
||||
|
||||
## Entry Modules
|
||||
|
||||
- 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`.
|
||||
- `register/1` must return a `Tribes.Plugin.Spec` struct or a map/struct that
|
||||
validates into that spec.
|
||||
|
||||
## Spec Discipline
|
||||
|
||||
- Fields mirrored from `manifest.json` must match exactly after capability
|
||||
normalization: `name`, `version`, `provider_priority`, `provides`,
|
||||
`requires`, and `enhances_with`.
|
||||
- Use `%Tribes.Plugin.Spec.NavItem{}` and `%Tribes.Plugin.Spec.Page{}` or maps
|
||||
with only the documented keys. Unknown keys fail validation.
|
||||
- Use atom modules for `live_view`, plug modules, hook modules, `ui_components`,
|
||||
and `ash_domains`; do not pass module names as strings in the runtime spec.
|
||||
- Keep `children` as valid supervisor child specs and make plugin processes
|
||||
restartable under normal OTP supervision rules.
|
||||
- Run `scripts/plugin validate` or `mix tribes.plugin.validate` before relying
|
||||
on runtime registration behavior.
|
||||
|
||||
## Pages, Layouts, And Auth
|
||||
|
||||
- Use `Tribes.Plugin.Layouts.app` for pages that should render inside host
|
||||
chrome.
|
||||
- Use `Tribes.Plugin.LiveUserAuth` on plugin LiveViews when they need host user
|
||||
context.
|
||||
- Avoid `use TribesWeb, :live_view` in standalone release-facing plugin code;
|
||||
use `Phoenix.LiveView` and public plugin/UI APIs instead.
|
||||
|
||||
## Config Schema
|
||||
|
||||
- `config_schema` is for small admin-editable runtime defaults rendered by the
|
||||
host UI and stored through `Tribes.ConfigStore`.
|
||||
- Group IDs and setting keys must use the identifier format accepted by the
|
||||
validator: lowercase segments with letters, digits, underscores, and dots.
|
||||
- Supported setting types are `:string`, `:text`, `:boolean`, `:integer`,
|
||||
`:number`, `:enum`, `:list`, and `:object`.
|
||||
- Use `options` only with `:enum`, and provide stable stored values rather than
|
||||
display text as the value.
|
||||
|
||||
<!-- tribes_plugin_api-end -->
|
||||
<!-- tribes-start -->
|
||||
## tribes usage
|
||||
_tribes_
|
||||
|
||||
# Tribes Plugin Development Rules
|
||||
|
||||
These rules are for external Tribes plugin projects that depend on the host
|
||||
checkout during development. For the full contract, see
|
||||
[`../tribes/docs/plugins.md`](../tribes/docs/plugins.md) and
|
||||
[`../tribes/docs/ui.md`](../tribes/docs/ui.md).
|
||||
|
||||
## Plugin Boundaries
|
||||
|
||||
- Treat plugins as separate OTP applications. The host discovers them through
|
||||
`manifest.json` and a single `Tribes.Plugin` entry module, not by reaching
|
||||
into plugin internals.
|
||||
- Keep plugin contributions inside the supported runtime spec fields:
|
||||
`nav_items`, `pages`, `api_routes`, `plugs`, `children`, `global_js`,
|
||||
`global_css`, `migrations_path`, `ui_components`, `hooks`, `ash_domains`, and
|
||||
`config_schema`.
|
||||
- Do not mutate host routers, host Ash domains, endpoint config, or host
|
||||
supervision trees directly from plugin code.
|
||||
- Plugin-owned pages and API routes should live under plugin-owned paths. Avoid
|
||||
taking over unrelated host sections.
|
||||
|
||||
## Manifest And Runtime Spec
|
||||
|
||||
- `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.
|
||||
- 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
|
||||
plugin spec.
|
||||
|
||||
## 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
|
||||
`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.
|
||||
|
||||
## Runtime Config
|
||||
|
||||
- Use plugin OTP app env only for boot-time wiring that is genuinely static.
|
||||
- For mutable runtime settings, use `Tribes.ConfigStore` and expose small
|
||||
editable defaults through `config_schema` in the plugin spec.
|
||||
- `config_schema` is validated by the host and rendered by Tribes in the admin
|
||||
settings UI. It describes fields only; values are stored as ConfigStore
|
||||
overrides when an admin saves them.
|
||||
- If `config_schema.namespace` is omitted, the host uses
|
||||
`plugin.<plugin_name>`.
|
||||
- Plugins should read ConfigStore values with the same defaults declared in the
|
||||
schema, because saving a value equal to the default deletes the override.
|
||||
- Do not expose secrets, node-local paths, ports, database URLs, TLS material,
|
||||
package selection, or deployment state through `config_schema`.
|
||||
|
||||
## Synced Data
|
||||
|
||||
- If a plugin adds Ash resources that should replicate across the cluster, add
|
||||
them deliberately to the plugin spec `ash_domains` and document replication
|
||||
semantics in the plugin contract docs.
|
||||
- Use `AshNostrSync` only for resources whose persisted state is meant to be
|
||||
cluster-synced. Do not enable it as a default on every plugin resource.
|
||||
|
||||
<!-- tribes-end -->
|
||||
<!-- usage-rules-end -->
|
||||
@@ -1,103 +1,158 @@
|
||||
# Tribes Plugin Template
|
||||
# Aether
|
||||
|
||||
Template for creating [Tribes](https://github.com/your-org/tribes) plugins.
|
||||
Aether is an external Tribes plugin that adds local social posting and chat to a
|
||||
Tribes node.
|
||||
|
||||
## Getting Started
|
||||
It provides:
|
||||
|
||||
1. Click **"Use this template"** on GitHub to create your own repo
|
||||
2. Clone and rename:
|
||||
- `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.
|
||||
|
||||
```bash
|
||||
git clone https://github.com/you/your-plugin.git
|
||||
cd your-plugin
|
||||
./scripts/rename.sh your_plugin YourPlugin
|
||||
```
|
||||
## Features
|
||||
|
||||
3. Edit `manifest.json` — set description, capabilities, requirements
|
||||
4. Implement your plugin in `lib/your_plugin/plugin.ex`
|
||||
5. Run tests:
|
||||
### Local social feed
|
||||
|
||||
```bash
|
||||
mix deps.get
|
||||
mix test
|
||||
```
|
||||
- `/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.
|
||||
|
||||
## Development
|
||||
### Public and embeddable chat
|
||||
|
||||
For local development alongside a Tribes checkout:
|
||||
- `/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.
|
||||
|
||||
```bash
|
||||
# Symlink into the host plugins directory
|
||||
cd /path/to/tribes
|
||||
ln -s /path/to/your-plugin plugins/your_plugin
|
||||
### Direct messages
|
||||
|
||||
# Start Tribes dev server — your plugin loads automatically
|
||||
iex --sname dev -S mix phx.server
|
||||
```
|
||||
- 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.
|
||||
|
||||
Edit your plugin source. Phoenix code reloader picks up changes.
|
||||
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.
|
||||
|
||||
## Project Structure
|
||||
### Marmot scaffold
|
||||
|
||||
```
|
||||
your_plugin/
|
||||
├── manifest.json # Plugin metadata (Nix build + runtime)
|
||||
├── mix.exs # Dependencies
|
||||
├── lib/
|
||||
│ ├── your_plugin/
|
||||
│ │ ├── plugin.ex # Tribes.Plugin entry point
|
||||
│ │ └── application.ex # OTP supervision tree (optional)
|
||||
│ └── your_plugin_web/
|
||||
│ └── live/ # LiveView pages
|
||||
├── assets/ # JS/CSS (one bundle per plugin)
|
||||
├── priv/
|
||||
│ ├── static/ # Built assets for release
|
||||
│ └── repo/migrations/ # Ecto migrations
|
||||
└── test/
|
||||
```
|
||||
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`.
|
||||
|
||||
## Manifest
|
||||
## Plugin contract and API surface
|
||||
|
||||
`manifest.json` declares your plugin's identity and capabilities:
|
||||
`manifest.json` declares:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "your_plugin",
|
||||
"provides": ["some_capability@1"],
|
||||
"requires": ["ecto@1"],
|
||||
"enhances_with": ["inference@1"]
|
||||
"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
|
||||
}
|
||||
```
|
||||
|
||||
- **provides** — capabilities this plugin makes available
|
||||
- **requires** — hard dependencies (build fails without them)
|
||||
- **enhances_with** — optional dependencies (plugin degrades gracefully)
|
||||
Runtime contributions from `TribeOne.TribesPlugin.Aether.Plugin` include:
|
||||
|
||||
See the [Plugin System docs](https://github.com/your-org/tribes/blob/master/docs/PLUGINS.md) for the full specification.
|
||||
- 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.
|
||||
|
||||
## Testing
|
||||
The `org.tribe-one.caps.chat@1` provider surface is intentionally small and reusable:
|
||||
|
||||
Three test levels:
|
||||
- `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.
|
||||
|
||||
- **Unit tests** (`test/your_plugin/`) — plugin logic in isolation
|
||||
- **Manifest tests** (`test/your_plugin/manifest_test.exs`) — manifest schema validation
|
||||
- **Contract tests** (`test/contract_test.exs`) — runtime spec matches manifest
|
||||
Backend modules implement `TribeOne.TribesPlugin.Aether.Chat.Backend`:
|
||||
|
||||
Run all: `mix test`
|
||||
- `TribeOne.TribesPlugin.Aether.Chat.Backends.PublicSync`
|
||||
- `TribeOne.TribesPlugin.Aether.Chat.Backends.NostrNip17`
|
||||
- `TribeOne.TribesPlugin.Aether.Chat.Backends.NostrNip04ReadOnly`
|
||||
- `TribeOne.TribesPlugin.Aether.Chat.Backends.Marmot`
|
||||
|
||||
## Building for Release
|
||||
These are plugin-local today. If other plugins need the same primitives, the
|
||||
likely host/plugin API candidates are a stable Nostr event publish/query service
|
||||
and a stable session/external signer interface.
|
||||
|
||||
## Development
|
||||
|
||||
Use the plugin-aware commands from the repo devenv shell:
|
||||
|
||||
```bash
|
||||
MIX_ENV=prod mix compile
|
||||
|
||||
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/
|
||||
devenv shell -- plugin validate
|
||||
devenv shell -- plugin test
|
||||
devenv shell -- plugin precommit
|
||||
```
|
||||
|
||||
For Nix-based deployment, add your plugin to the host's `plugins.json`.
|
||||
Outside the devenv shell, use the local wrapper:
|
||||
|
||||
## Licence
|
||||
```bash
|
||||
scripts/plugin validate
|
||||
scripts/plugin test
|
||||
scripts/plugin precommit
|
||||
```
|
||||
|
||||
TODO: Choose a licence.
|
||||
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:
|
||||
|
||||
```bash
|
||||
cd /path/to/tribes
|
||||
ln -s /path/to/tribes-plugin-aether plugins/aether
|
||||
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.
|
||||
|
||||
## Assets and hooks
|
||||
|
||||
Plugin browser assets are declared in `manifest.json` and served by the host.
|
||||
Aether currently uses:
|
||||
|
||||
- `assets/js/aether.js` for LiveView hooks such as chat auto-scroll,
|
||||
- `assets/css/aether.css` for plugin-scoped styles.
|
||||
|
||||
External plugin hooks are registered through `window.TribesPluginHooks`; Phoenix
|
||||
colocated hooks from external plugin OTP apps are not auto-imported by the host.
|
||||
|
||||
## Runtime requirements
|
||||
|
||||
- A Tribes host checkout/runtime with `org.tribe-one.caps.ui@1`.
|
||||
- Parrhesia available through the host for Nostr event storage and streaming.
|
||||
- `nak` available in the runtime path for the current NIP-17/NIP-59/NIP-04
|
||||
protocol implementation.
|
||||
- Node dependencies under `assets/` for browser asset builds.
|
||||
|
||||
## Release packaging
|
||||
|
||||
Guix packaging assembles the plugin artifact from the compiled BEAM output,
|
||||
`priv/`, browser assets, and `manifest.json`. Enable the plugin from the
|
||||
`guix-tribes` channel-side node configuration.
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
min-release-age=7
|
||||
@@ -1,13 +1,17 @@
|
||||
/*
|
||||
* Plugin CSS entry point.
|
||||
*
|
||||
* Served at /plugins-assets/my_plugin/my_plugin.css
|
||||
* Served at /plugins-assets/aether/aether.css
|
||||
* and included in the page layout if declared in manifest.json assets.global_css.
|
||||
*
|
||||
* Prefix all selectors with your plugin name to avoid collisions
|
||||
* with host or other plugin styles.
|
||||
*/
|
||||
|
||||
.my-plugin {
|
||||
.aether {
|
||||
/* Plugin-scoped styles go here */
|
||||
}
|
||||
|
||||
.aether-chat-message {
|
||||
scroll-margin-block-end: 1rem;
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
// Plugin JavaScript entry point.
|
||||
//
|
||||
// This file is served by the host at /plugins-assets/aether/aether.js
|
||||
// and included in the page layout if declared in manifest.json assets.global_js.
|
||||
//
|
||||
// Register plugin LiveView hooks here. External plugin OTP apps are not
|
||||
// auto-imported by the host's phoenix-colocated bundle.
|
||||
//
|
||||
// window.TribesPluginHooks = window.TribesPluginHooks || {};
|
||||
// window.TribesPluginHooks["AetherHook"] = {
|
||||
// mounted() {
|
||||
// console.log("Aether hook mounted");
|
||||
// }
|
||||
// };
|
||||
|
||||
window.TribesPluginHooks = window.TribesPluginHooks || {};
|
||||
|
||||
window.TribesPluginHooks.AetherChatScroll = {
|
||||
mounted() {
|
||||
this.scrollToBottom();
|
||||
},
|
||||
updated() {
|
||||
this.scrollToBottom();
|
||||
},
|
||||
scrollToBottom() {
|
||||
this.el.scrollTop = this.el.scrollHeight;
|
||||
},
|
||||
};
|
||||
@@ -1,15 +0,0 @@
|
||||
// Plugin JavaScript entry point.
|
||||
//
|
||||
// This file is served by the host at /plugins-assets/my_plugin/my_plugin.js
|
||||
// and included in the page layout if declared in manifest.json assets.global_js.
|
||||
//
|
||||
// To register LiveView hooks:
|
||||
//
|
||||
// window.TribesPluginHooks = window.TribesPluginHooks || {};
|
||||
// window.TribesPluginHooks["MyPluginHook"] = {
|
||||
// mounted() {
|
||||
// console.log("MyPlugin hook mounted");
|
||||
// }
|
||||
// };
|
||||
|
||||
console.log("my_plugin loaded");
|
||||
Generated
+475
@@ -0,0 +1,475 @@
|
||||
{
|
||||
"name": "aether-assets",
|
||||
"version": "0.1.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "aether-assets",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@internet-privacy/marmot-ts": "0.5.1"
|
||||
}
|
||||
},
|
||||
"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==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"tslib": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ts-mls": {
|
||||
"version": "2.0.0-rc.10",
|
||||
"resolved": "https://registry.npmjs.org/ts-mls/-/ts-mls-2.0.0-rc.10.tgz",
|
||||
"integrity": "sha512-4FFbkysQkJlVaUv4fs7ZC4wuiwTOCgX/bEMo2fFGngy/QofC/lvWWgsScKuGxEIhEf4e2Q8APfJ7DknP0VRCoA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@hpke/core": "1.9.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@hpke/chacha20poly1305": "1.8.0",
|
||||
"@hpke/dhkem-x448": "1.8.0",
|
||||
"@hpke/hybridkem-x-wing": "0.7.0",
|
||||
"@hpke/ml-kem": "0.3.0",
|
||||
"@noble/ciphers": "2.1.1",
|
||||
"@noble/curves": "2.0.1",
|
||||
"@noble/post-quantum": "0.5.2"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@hpke/chacha20poly1305": {
|
||||
"optional": true
|
||||
},
|
||||
"@hpke/dhkem-x448": {
|
||||
"optional": true
|
||||
},
|
||||
"@hpke/hybridkem-x-wing": {
|
||||
"optional": true
|
||||
},
|
||||
"@hpke/ml-kem": {
|
||||
"optional": true
|
||||
},
|
||||
"@noble/curves": {
|
||||
"optional": true
|
||||
},
|
||||
"@noble/post-quantum": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/tslib": {
|
||||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||
"license": "0BSD"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"name": "aether-assets",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"scripts": {
|
||||
"build": "mkdir -p ../priv/static && cp -r css/. ../priv/static && cp -r js/. ../priv/static"
|
||||
},
|
||||
"dependencies": {
|
||||
"@internet-privacy/marmot-ts": "0.5.1"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import Config
|
||||
|
||||
config :tribe_one_aether, ash_domains: [TribeOne.TribesPlugin.Aether.Chat]
|
||||
|
||||
import_config "#{config_env()}.exs"
|
||||
@@ -0,0 +1,5 @@
|
||||
import Config
|
||||
|
||||
# The host is a compile-only dependency in dev. Suppress host Ash domain
|
||||
# config inclusion warnings while compiling host modules as a dependency.
|
||||
config :ash, :validate_domain_config_inclusion?, false
|
||||
@@ -0,0 +1 @@
|
||||
import Config
|
||||
@@ -0,0 +1,3 @@
|
||||
import Config
|
||||
|
||||
import_config "../../tribes/config/config.exs"
|
||||
+142
@@ -0,0 +1,142 @@
|
||||
{
|
||||
"nodes": {
|
||||
"devenv": {
|
||||
"locked": {
|
||||
"dir": "src/modules",
|
||||
"lastModified": 1779566536,
|
||||
"narHash": "sha256-KlMZMcgyU0BFUW2JCrCb9IHJMgCdG0eyhukpu9pwXLs=",
|
||||
"owner": "cachix",
|
||||
"repo": "devenv",
|
||||
"rev": "6715fab7a826d108278fbfd6ef35a61f49ab7ac9",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"dir": "src/modules",
|
||||
"owner": "cachix",
|
||||
"repo": "devenv",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"flake-compat": {
|
||||
"flake": false,
|
||||
"locked": {
|
||||
"lastModified": 1767039857,
|
||||
"narHash": "sha256-vNpUSpF5Nuw8xvDLj2KCwwksIbjua2LZCqhV1LNRDns=",
|
||||
"owner": "NixOS",
|
||||
"repo": "flake-compat",
|
||||
"rev": "5edf11c44bc78a0d334f6334cdaf7d60d732daab",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "NixOS",
|
||||
"repo": "flake-compat",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"git-hooks": {
|
||||
"inputs": {
|
||||
"flake-compat": "flake-compat",
|
||||
"gitignore": "gitignore",
|
||||
"nixpkgs": [
|
||||
"nixpkgs"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1778507602,
|
||||
"narHash": "sha256-kTwur1wV+01SdqskVMSo6JMEpg71ps3HpbFY2GsflKs=",
|
||||
"owner": "cachix",
|
||||
"repo": "git-hooks.nix",
|
||||
"rev": "61ab0e80d9c7ab14c256b5b453d8b3fb0189ba0a",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "cachix",
|
||||
"repo": "git-hooks.nix",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"gitignore": {
|
||||
"inputs": {
|
||||
"nixpkgs": [
|
||||
"git-hooks",
|
||||
"nixpkgs"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1709087332,
|
||||
"narHash": "sha256-HG2cCnktfHsKV0s4XW83gU3F57gaTljL9KNSuG6bnQs=",
|
||||
"owner": "hercules-ci",
|
||||
"repo": "gitignore.nix",
|
||||
"rev": "637db329424fd7e46cf4185293b9cc8c88c95394",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "hercules-ci",
|
||||
"repo": "gitignore.nix",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs": {
|
||||
"inputs": {
|
||||
"nixpkgs-src": "nixpkgs-src"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1778507786,
|
||||
"narHash": "sha256-HzSQCKMsMr8r55LwM1JuzIOB+8bzk0FEv6sItKvsfoY=",
|
||||
"owner": "cachix",
|
||||
"repo": "devenv-nixpkgs",
|
||||
"rev": "8f24a228a782e24576b155d1e39f0d914b380691",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "cachix",
|
||||
"ref": "rolling",
|
||||
"repo": "devenv-nixpkgs",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs-src": {
|
||||
"flake": false,
|
||||
"locked": {
|
||||
"lastModified": 1778274207,
|
||||
"narHash": "sha256-I4puXmX1iovcCHZlRmztO3vW0mAbbRvq4F8wgIMQ1MM=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "b3da656039dc7a6240f27b2ef8cc6a3ef3bccae7",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "NixOS",
|
||||
"ref": "nixpkgs-unstable",
|
||||
"repo": "nixpkgs",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
},
|
||||
"root": "root",
|
||||
"version": 7
|
||||
}
|
||||
+110
@@ -0,0 +1,110 @@
|
||||
{
|
||||
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";
|
||||
};
|
||||
|
||||
# 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 {})
|
||||
]
|
||||
++
|
||||
# Linux only
|
||||
lib.optionals pkgs.stdenv.isLinux [
|
||||
# for ExUnit notifier
|
||||
libnotify
|
||||
|
||||
# for package - file_system
|
||||
inotify-tools
|
||||
];
|
||||
|
||||
# https://devenv.sh/tests/
|
||||
# enterTest = ''
|
||||
# echo "Running tests"
|
||||
# git --version | grep "2.42.0"
|
||||
# '';
|
||||
|
||||
# https://devenv.sh/languages/
|
||||
languages = {
|
||||
elixir = {
|
||||
enable = true;
|
||||
package = pkgs.elixir_1_19;
|
||||
};
|
||||
|
||||
javascript = {
|
||||
enable = true;
|
||||
package = pkgs.nodejs_24;
|
||||
npm.enable = true;
|
||||
};
|
||||
};
|
||||
|
||||
# dotenv.enable = true;
|
||||
devenv.warnOnNewVersion = false;
|
||||
|
||||
# https://devenv.sh/pre-commit-hooks/
|
||||
git-hooks.hooks = {
|
||||
alejandra.enable = true;
|
||||
prettier.enable = true;
|
||||
prettier.files = "\\.(js|ts|tsx|css)$";
|
||||
check-added-large-files = {
|
||||
enable = true;
|
||||
args = ["--maxkb=131072"];
|
||||
};
|
||||
mix-format.enable = true;
|
||||
mix-format.files = "\\.(ex|exs|heex)$";
|
||||
};
|
||||
|
||||
# https://devenv.sh/scripts/
|
||||
enterShell = ''
|
||||
echo
|
||||
elixir --version
|
||||
echo -n "Node.js "
|
||||
node --version
|
||||
echo
|
||||
'';
|
||||
|
||||
scripts = {
|
||||
plugin.exec = ''bash "$DEVENV_ROOT/scripts/plugin" "$@"'';
|
||||
};
|
||||
|
||||
# See full reference at https://devenv.sh/reference/options/
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
# yaml-language-server: $schema=https://devenv.sh/devenv.schema.json
|
||||
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
|
||||
@@ -0,0 +1,144 @@
|
||||
# Feature Plan: Aether Chat
|
||||
|
||||
## Decision: manifest capability
|
||||
|
||||
Use `chat@1`, not `group_chat@1`.
|
||||
|
||||
Rationale: the first shipped feature is group chat, but the capability should be able to grow to DMs, stream-adjacent chats, support rooms, or other conversation types later. The `chat@1` contract should therefore mean "conversation provider and reusable chat UI". The provider can advertise supported conversation kinds; initially Aether supports public group-style channels and later Marmot-backed channels.
|
||||
|
||||
Backward-compatible additions, such as optional DM support, can stay under `chat@1` as long as existing consumers keep working. Breaking provider API changes should become `chat@2`.
|
||||
|
||||
## Goals
|
||||
|
||||
- Add a standalone Aether chat experience.
|
||||
- Provide an embeddable chat panel that Sender can mount next to a live video stream.
|
||||
- Support two backends:
|
||||
- `public_sync`: plaintext messages stored in plugin-owned synced Ash resources.
|
||||
- `marmot`: Marmot/Nostr-backed encrypted transport using `@internet-privacy/marmot-ts@0.5.1`, without claiming true E2EE while the current web UI/server trust model is in place.
|
||||
- Build a high-quality Telegram-like UI that can support media later.
|
||||
|
||||
## Non-goals for the first slice
|
||||
|
||||
- Full true E2EE claims.
|
||||
- Media upload/storage.
|
||||
- DMs.
|
||||
- Sender integration before Aether exposes a stable provider surface.
|
||||
|
||||
## Capability contract sketch
|
||||
|
||||
Aether should provide:
|
||||
|
||||
```json
|
||||
"provides": ["social@1", "chat@1"]
|
||||
```
|
||||
|
||||
Initial `chat@1` provider responsibilities:
|
||||
|
||||
- ensure or find a channel for a context, e.g. `{provider: "sender", type: "stream", id: stream_id}`
|
||||
- expose supported conversation kinds/backends
|
||||
- render standalone chat routes
|
||||
- provide an embeddable compact panel for consumers
|
||||
|
||||
Initial supported kinds:
|
||||
|
||||
- `:group`
|
||||
- `:context_group`, for stream/sidebar-style group channels
|
||||
|
||||
Future optional kinds:
|
||||
|
||||
- `:dm`
|
||||
- `:multi_dm`
|
||||
- `:support_room`
|
||||
|
||||
## Data model: public_sync backend
|
||||
|
||||
Plugin-owned Ash domain/resources:
|
||||
|
||||
- `TribeOne.TribesPlugin.Aether.Chat.Channel`
|
||||
- `id`
|
||||
- `slug`
|
||||
- `title`
|
||||
- `description`
|
||||
- `backend`: `:public_sync | :marmot`
|
||||
- `conversation_kind`: initially `:group | :context_group`
|
||||
- context fields: `context_provider`, `context_type`, `context_id`
|
||||
- `metadata`
|
||||
- timestamps
|
||||
- `TribeOne.TribesPlugin.Aether.Chat.Message`
|
||||
- `id`
|
||||
- `channel_id`
|
||||
- `author_id`
|
||||
- `author_pubkey`
|
||||
- `body`
|
||||
- `client_message_id`
|
||||
- `metadata`
|
||||
- timestamps
|
||||
|
||||
Both public resources should be added deliberately to Aether's `ash_domains` and `AshNostrSync` so public chat replicates across cluster nodes.
|
||||
|
||||
## UI plan
|
||||
|
||||
One reusable chat surface with modes:
|
||||
|
||||
- standalone page mode
|
||||
- compact embedded mode for Sender
|
||||
|
||||
MVP UI:
|
||||
|
||||
- message stream with stable DOM IDs
|
||||
- sticky composer
|
||||
- multiline textarea
|
||||
- send button
|
||||
- sender display label
|
||||
- timestamp display
|
||||
- empty state
|
||||
- signed-out state
|
||||
- compact mode support
|
||||
- auto-scroll hook later
|
||||
|
||||
Media-ready UI affordances can be added later without implementing uploads now.
|
||||
|
||||
## Marmot plan
|
||||
|
||||
Use `@internet-privacy/marmot-ts@0.5.1` if/when implementing the Marmot backend.
|
||||
|
||||
Marmot backend constraints:
|
||||
|
||||
- document as encrypted/Marmot-backed, not true E2EE yet
|
||||
- keep behind a feature flag or explicit backend choice until stable
|
||||
- use browser local storage for Marmot state if using `marmot-ts`
|
||||
- integrate with Parrhesia relay interfaces
|
||||
- decide signing bridge/key handling before productizing
|
||||
|
||||
## Phases
|
||||
|
||||
1. Add plan, capability, public Ash resources, migrations, and standalone public chat MVP.
|
||||
2. Extract/shape embeddable panel API and tests.
|
||||
3. Integrate Sender stream pages with Aether chat provider.
|
||||
4. Add Marmot spike using `marmot-ts@0.5.1`.
|
||||
5. Productize Marmot backend behind explicit configuration.
|
||||
6. Add media support later.
|
||||
|
||||
## Validation
|
||||
|
||||
For Aether work:
|
||||
|
||||
```sh
|
||||
scripts/plugin validate
|
||||
scripts/plugin test
|
||||
scripts/plugin precommit
|
||||
```
|
||||
|
||||
For host/plugin API changes:
|
||||
|
||||
```sh
|
||||
cd ../tribes
|
||||
devenv shell -- mix test
|
||||
```
|
||||
|
||||
For Sender integration:
|
||||
|
||||
```sh
|
||||
cd ../tribes-plugin-sender
|
||||
scripts/plugin test
|
||||
```
|
||||
@@ -0,0 +1,24 @@
|
||||
(list
|
||||
(channel
|
||||
(name 'guix)
|
||||
(url "https://git.teralink.net/tribes/guix-fork.git")
|
||||
(branch "refactor/substituter-trace-framing")
|
||||
;; guix-fork refactor/substituter-trace-framing
|
||||
(commit
|
||||
"83b0e7d44546968002fb0c0043004da4e9bedc0d")
|
||||
(introduction
|
||||
(make-channel-introduction
|
||||
"093f27dde01cdbda68f2ec4b81e5a34ae180aab9"
|
||||
(openpgp-fingerprint
|
||||
"6688 9153 C51C 4613 A493 A525 2F0D FD14 EF99 DAC3"))))
|
||||
(channel
|
||||
(name 'tribes)
|
||||
(url "https://git.teralink.net/tribes/guix-tribes.git")
|
||||
(branch "master")
|
||||
(commit
|
||||
"99789706834d678fb37f2c6a972b78803d2a2cf2")
|
||||
(introduction
|
||||
(make-channel-introduction
|
||||
"607c69a5c1662acca07ad72c3e18646c73500856"
|
||||
(openpgp-fingerprint
|
||||
"6688 9153 C51C 4613 A493 A525 2F0D FD14 EF99 DAC3")))))
|
||||
Executable
+26
@@ -0,0 +1,26 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
root="$(cd "$script_dir/.." && pwd)"
|
||||
|
||||
if [ ! -f "$root/manifest.scm" ]; then
|
||||
root="$(git rev-parse --show-toplevel 2>/dev/null || pwd)"
|
||||
fi
|
||||
|
||||
channels="$root/guix/channels.scm"
|
||||
manifest="$root/manifest.scm"
|
||||
|
||||
if [ ! -f "$channels" ] || [ ! -f "$manifest" ]; then
|
||||
echo "guix-dev: expected guix/channels.scm and manifest.scm in the project root" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ -f "$root/.env" ]; then
|
||||
set -a
|
||||
. "$root/.env"
|
||||
set +a
|
||||
fi
|
||||
|
||||
exec guix time-machine -C "$channels" -- \
|
||||
shell -m "$manifest" "$@"
|
||||
@@ -1,4 +1,4 @@
|
||||
defmodule MyPlugin.Application do
|
||||
defmodule TribeOne.TribesPlugin.Aether.Application do
|
||||
@moduledoc """
|
||||
OTP Application for this plugin.
|
||||
|
||||
@@ -12,10 +12,10 @@ defmodule MyPlugin.Application do
|
||||
def start(_type, _args) do
|
||||
children = [
|
||||
# Add your supervised processes here, e.g.:
|
||||
# {MyPlugin.Worker, []}
|
||||
# {TribeOne.TribesPlugin.Aether.Worker, []}
|
||||
]
|
||||
|
||||
opts = [strategy: :one_for_one, name: MyPlugin.Supervisor]
|
||||
opts = [strategy: :one_for_one, name: TribeOne.TribesPlugin.Aether.Supervisor]
|
||||
Supervisor.start_link(children, opts)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,352 @@
|
||||
defmodule TribeOne.TribesPlugin.Aether.Chat do
|
||||
@moduledoc """
|
||||
Chat domain and provider facade for TribeOne.TribesPlugin.Aether.
|
||||
"""
|
||||
|
||||
use Ash.Domain,
|
||||
otp_app: :tribe_one_aether
|
||||
|
||||
require Ash.Query
|
||||
|
||||
alias TribeOne.TribesPlugin.Aether.Chat.{Channel, Message, Participant}
|
||||
|
||||
@default_channel_slug "general"
|
||||
@default_channel_title "General Chat"
|
||||
@pubsub Tribes.PubSub
|
||||
|
||||
resources do
|
||||
resource Channel do
|
||||
define(:create_channel, action: :create)
|
||||
define(:get_channel, action: :by_id, args: [:id])
|
||||
define(:get_channel_by_slug, action: :by_slug, args: [:slug])
|
||||
define(:list_channels, action: :read)
|
||||
end
|
||||
|
||||
resource Message do
|
||||
define(:create_message, action: :create)
|
||||
define(:get_message, action: :by_id, args: [:id])
|
||||
define(:list_all_messages, action: :read)
|
||||
end
|
||||
|
||||
resource Participant do
|
||||
define(:create_participant, action: :create)
|
||||
define(:get_participant, action: :by_channel_pubkey, args: [:channel_id, :pubkey])
|
||||
define(:list_participants, action: :read)
|
||||
define(:list_participants_by_pubkey, action: :by_pubkey, args: [:pubkey])
|
||||
end
|
||||
end
|
||||
|
||||
def backend_module(:public_sync), do: TribeOne.TribesPlugin.Aether.Chat.Backends.PublicSync
|
||||
def backend_module(:nostr_nip17), do: TribeOne.TribesPlugin.Aether.Chat.Backends.NostrNip17
|
||||
|
||||
def backend_module(:nostr_nip04),
|
||||
do: TribeOne.TribesPlugin.Aether.Chat.Backends.NostrNip04ReadOnly
|
||||
|
||||
def backend_module(:marmot), do: TribeOne.TribesPlugin.Aether.Chat.Backends.Marmot
|
||||
def backend_module(_backend), do: TribeOne.TribesPlugin.Aether.Chat.Backends.PublicSync
|
||||
|
||||
def backend_capabilities(backend) do
|
||||
module = backend_module(backend)
|
||||
module.capabilities()
|
||||
end
|
||||
|
||||
def supported_conversation_kinds, do: [:group, :context_group, :dm, :legacy_dm, :marmot_group]
|
||||
|
||||
def supported_backends, do: [:public_sync, :nostr_nip17, :nostr_nip04, :marmot]
|
||||
|
||||
def chat_panel_component, do: TribeOne.TribesPlugin.AetherWeb.ChatPanelComponent
|
||||
|
||||
def recipient_picker_component, do: TribeOne.TribesPlugin.AetherWeb.ChatRecipientPickerComponent
|
||||
|
||||
def default_channel_slug, do: @default_channel_slug
|
||||
|
||||
def standalone_path(%Channel{slug: slug}), do: standalone_path(slug)
|
||||
def standalone_path(slug) when is_binary(slug), do: "/aether/chat/" <> slug
|
||||
|
||||
def embed_path(%Channel{slug: slug}), do: embed_path(slug)
|
||||
def embed_path(slug) when is_binary(slug), do: "/aether/chat/embed/" <> slug
|
||||
|
||||
def marmot_path(%Channel{slug: slug}), do: marmot_path(slug)
|
||||
def marmot_path(slug) when is_binary(slug), do: "/aether/chat/marmot/" <> slug
|
||||
|
||||
def ensure_context_channel(provider, type, id, attrs \\ %{}, opts \\ [])
|
||||
when is_binary(provider) and is_binary(type) and is_binary(id) and is_map(attrs) do
|
||||
attrs
|
||||
|> Map.merge(%{
|
||||
context_provider: provider,
|
||||
context_type: type,
|
||||
context_id: id
|
||||
})
|
||||
|> ensure_conversation(opts)
|
||||
end
|
||||
|
||||
def ensure_direct_conversation(sender, recipient, attrs \\ %{}, opts \\ [])
|
||||
|
||||
def ensure_direct_conversation(sender_pubkey, recipient_pubkey, attrs, opts)
|
||||
when is_binary(sender_pubkey) and is_binary(recipient_pubkey) and is_map(attrs) do
|
||||
participants = Enum.sort([sender_pubkey, recipient_pubkey])
|
||||
|
||||
attrs =
|
||||
attrs
|
||||
|> Map.update(:metadata, %{"participants" => participants}, fn metadata ->
|
||||
Map.put(metadata || %{}, "participants", participants)
|
||||
end)
|
||||
|> Map.put_new(:slug, direct_slug(sender_pubkey, recipient_pubkey))
|
||||
|> Map.put_new(:title, "Direct Message")
|
||||
|> Map.put_new(:conversation_kind, :dm)
|
||||
|> Map.put_new(:backend, :nostr_nip17)
|
||||
|
||||
with {:ok, %Channel{} = channel} <- ensure_conversation(attrs, opts),
|
||||
{:ok, _sender} <- ensure_participant(channel, sender_pubkey, %{role: :member}, opts),
|
||||
{:ok, _recipient} <-
|
||||
ensure_participant(channel, recipient_pubkey, %{role: :member}, opts) do
|
||||
{:ok, channel}
|
||||
end
|
||||
end
|
||||
|
||||
def direct_slug(pubkey_a, pubkey_b) when is_binary(pubkey_a) and is_binary(pubkey_b) do
|
||||
digest =
|
||||
[pubkey_a, pubkey_b]
|
||||
|> Enum.sort()
|
||||
|> Enum.join(":")
|
||||
|> then(&:crypto.hash(:sha256, &1))
|
||||
|> Base.encode16(case: :lower)
|
||||
|
||||
"dm-" <> digest
|
||||
end
|
||||
|
||||
def ash_opts(context \\ nil) do
|
||||
[
|
||||
authorize?: false,
|
||||
context: %{
|
||||
private: %{
|
||||
system?: true,
|
||||
system_purpose: :aether_chat,
|
||||
plugin_management_context: context
|
||||
}
|
||||
}
|
||||
]
|
||||
end
|
||||
|
||||
def ensure_conversation(attrs \\ %{}, opts \\ []) when is_map(attrs) do
|
||||
backend = attrs |> atomize_known_keys() |> Map.get(:backend, :public_sync)
|
||||
backend_module(backend).ensure_conversation(attrs, opts)
|
||||
end
|
||||
|
||||
def ensure_channel(attrs \\ %{}, opts \\ []) when is_map(attrs) do
|
||||
attrs = normalize_channel_attrs(attrs)
|
||||
ash_opts = Keyword.get(opts, :ash_opts, ash_opts())
|
||||
|
||||
case get_existing_channel(attrs.slug, ash_opts) do
|
||||
{:ok, nil} -> create_channel(attrs, ash_opts)
|
||||
{:ok, %Channel{} = channel} -> {:ok, channel}
|
||||
{:error, _reason} = error -> error
|
||||
end
|
||||
end
|
||||
|
||||
defp get_existing_channel(slug, ash_opts) do
|
||||
Channel
|
||||
|> Ash.Query.filter(slug == ^slug)
|
||||
|> Ash.Query.limit(1)
|
||||
|> Ash.read_one(ash_opts)
|
||||
end
|
||||
|
||||
defp get_existing_participant(channel_id, pubkey, ash_opts) do
|
||||
Participant
|
||||
|> Ash.Query.filter(channel_id == ^channel_id and pubkey == ^pubkey)
|
||||
|> Ash.Query.limit(1)
|
||||
|> Ash.read_one(ash_opts)
|
||||
end
|
||||
|
||||
def ensure_participant(%Channel{} = channel, pubkey, attrs \\ %{}, opts \\ [])
|
||||
when is_binary(pubkey) and is_map(attrs) do
|
||||
attrs = atomize_known_keys(attrs)
|
||||
ash_opts = Keyword.get(opts, :ash_opts, ash_opts())
|
||||
|
||||
case get_existing_participant(channel.id, pubkey, ash_opts) do
|
||||
{:ok, nil} ->
|
||||
attrs
|
||||
|> Map.merge(%{channel_id: channel.id, pubkey: pubkey})
|
||||
|> Map.put_new(:role, :member)
|
||||
|> Map.put_new(:metadata, %{})
|
||||
|> create_participant(ash_opts)
|
||||
|
||||
{:ok, %Participant{} = participant} ->
|
||||
{:ok, participant}
|
||||
|
||||
{:error, _reason} = error ->
|
||||
error
|
||||
end
|
||||
end
|
||||
|
||||
def list_conversation_messages(%Channel{backend: backend} = channel, opts \\ []) do
|
||||
backend_module(backend).list_messages(channel, opts)
|
||||
end
|
||||
|
||||
def list_messages(channel_or_id, opts \\ []) do
|
||||
channel_id = channel_id(channel_or_id)
|
||||
limit = Keyword.get(opts, :limit, 100)
|
||||
ash_opts = Keyword.get(opts, :ash_opts, ash_opts())
|
||||
|
||||
result =
|
||||
Message
|
||||
|> Ash.Query.filter(channel_id == ^channel_id)
|
||||
|> Ash.Query.sort(inserted_at: :desc, id: :desc)
|
||||
|> Ash.Query.limit(limit)
|
||||
|> Ash.read(ash_opts)
|
||||
|
||||
case result do
|
||||
{:ok, messages} -> {:ok, Enum.reverse(messages)}
|
||||
{:error, _reason} = error -> error
|
||||
end
|
||||
end
|
||||
|
||||
def send_message(%Channel{backend: backend} = channel, attrs, opts \\ []) when is_map(attrs) do
|
||||
backend_module(backend).send_message(channel, attrs, opts)
|
||||
end
|
||||
|
||||
def post_message(%Channel{} = channel, attrs, opts \\ []) when is_map(attrs) do
|
||||
ash_opts = Keyword.get(opts, :ash_opts, ash_opts())
|
||||
|
||||
attrs
|
||||
|> normalize_message_attrs(channel)
|
||||
|> create_message(ash_opts)
|
||||
|> maybe_broadcast_message()
|
||||
end
|
||||
|
||||
def subscribe_conversation(%Channel{backend: backend} = channel, pid \\ self(), opts \\ []) do
|
||||
backend_module(backend).subscribe(channel, pid, opts)
|
||||
end
|
||||
|
||||
def subscribe_channel(%Channel{} = channel), do: subscribe_channel(channel.id)
|
||||
|
||||
def subscribe_channel(channel_id) when is_binary(channel_id) do
|
||||
if pubsub_started?() do
|
||||
Phoenix.PubSub.subscribe(@pubsub, topic(channel_id))
|
||||
else
|
||||
{:error, :pubsub_not_started}
|
||||
end
|
||||
end
|
||||
|
||||
def topic(channel_id) when is_binary(channel_id), do: "aether:chat:" <> channel_id
|
||||
|
||||
defp maybe_broadcast_message({:ok, %Message{} = message}) do
|
||||
broadcast_message(message)
|
||||
{:ok, message}
|
||||
end
|
||||
|
||||
defp maybe_broadcast_message({:error, _reason} = error), do: error
|
||||
|
||||
defp broadcast_message(%Message{} = message) do
|
||||
if pubsub_started?() do
|
||||
Phoenix.PubSub.broadcast_from(
|
||||
@pubsub,
|
||||
self(),
|
||||
topic(message.channel_id),
|
||||
{:aether_chat, :message, message}
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
defp pubsub_started?, do: Process.whereis(@pubsub) != nil
|
||||
|
||||
defp normalize_channel_attrs(attrs) do
|
||||
attrs = atomize_known_keys(attrs)
|
||||
title = attrs[:title] || @default_channel_title
|
||||
slug = attrs[:slug] || context_slug(attrs) || slugify(title) || @default_channel_slug
|
||||
|
||||
%{
|
||||
slug: slug,
|
||||
title: title,
|
||||
description: attrs[:description],
|
||||
backend: attrs[:backend] || :public_sync,
|
||||
conversation_kind: attrs[:conversation_kind] || default_conversation_kind(attrs),
|
||||
context_provider: attrs[:context_provider],
|
||||
context_type: attrs[:context_type],
|
||||
context_id: attrs[:context_id],
|
||||
metadata: attrs[:metadata] || %{}
|
||||
}
|
||||
end
|
||||
|
||||
defp normalize_message_attrs(attrs, %Channel{} = channel) do
|
||||
attrs = atomize_known_keys(attrs)
|
||||
|
||||
%{
|
||||
channel_id: channel.id,
|
||||
author_id: attrs[:author_id],
|
||||
author_pubkey: attrs[:author_pubkey],
|
||||
body: String.trim(attrs[:body] || ""),
|
||||
client_message_id: attrs[:client_message_id],
|
||||
metadata: attrs[:metadata] || %{}
|
||||
}
|
||||
end
|
||||
|
||||
defp atomize_known_keys(attrs) do
|
||||
Enum.reduce(attrs, %{}, fn
|
||||
{key, value}, acc when is_atom(key) -> Map.put(acc, key, value)
|
||||
{key, value}, acc when is_binary(key) -> put_known_key(acc, key, value)
|
||||
end)
|
||||
end
|
||||
|
||||
defp put_known_key(acc, key, value) do
|
||||
case key do
|
||||
"slug" -> Map.put(acc, :slug, value)
|
||||
"title" -> Map.put(acc, :title, value)
|
||||
"description" -> Map.put(acc, :description, value)
|
||||
"backend" -> Map.put(acc, :backend, parse_atom(value))
|
||||
"conversation_kind" -> Map.put(acc, :conversation_kind, parse_atom(value))
|
||||
"context_provider" -> Map.put(acc, :context_provider, value)
|
||||
"context_type" -> Map.put(acc, :context_type, value)
|
||||
"context_id" -> Map.put(acc, :context_id, value)
|
||||
"metadata" -> Map.put(acc, :metadata, value)
|
||||
"pubkey" -> Map.put(acc, :pubkey, value)
|
||||
"user_id" -> Map.put(acc, :user_id, value)
|
||||
"display_name" -> Map.put(acc, :display_name, value)
|
||||
"role" -> Map.put(acc, :role, parse_atom(value))
|
||||
"author_id" -> Map.put(acc, :author_id, value)
|
||||
"author_pubkey" -> Map.put(acc, :author_pubkey, value)
|
||||
"body" -> Map.put(acc, :body, value)
|
||||
"client_message_id" -> Map.put(acc, :client_message_id, value)
|
||||
_other -> acc
|
||||
end
|
||||
end
|
||||
|
||||
defp parse_atom(value) when is_atom(value), do: value
|
||||
defp parse_atom("public_sync"), do: :public_sync
|
||||
defp parse_atom("nostr_nip17"), do: :nostr_nip17
|
||||
defp parse_atom("nostr_nip04"), do: :nostr_nip04
|
||||
defp parse_atom("marmot"), do: :marmot
|
||||
defp parse_atom("group"), do: :group
|
||||
defp parse_atom("context_group"), do: :context_group
|
||||
defp parse_atom("dm"), do: :dm
|
||||
defp parse_atom("legacy_dm"), do: :legacy_dm
|
||||
defp parse_atom("marmot_group"), do: :marmot_group
|
||||
defp parse_atom("owner"), do: :owner
|
||||
defp parse_atom("member"), do: :member
|
||||
defp parse_atom(_value), do: nil
|
||||
|
||||
defp context_slug(%{context_provider: provider, context_type: type, context_id: id})
|
||||
when is_binary(provider) and is_binary(type) and is_binary(id) do
|
||||
Enum.map_join([provider, type, id], "-", &slugify/1)
|
||||
end
|
||||
|
||||
defp context_slug(_attrs), do: nil
|
||||
|
||||
defp default_conversation_kind(%{context_provider: provider}) when is_binary(provider),
|
||||
do: :context_group
|
||||
|
||||
defp default_conversation_kind(_attrs), do: :group
|
||||
|
||||
defp channel_id(%Channel{id: id}), do: id
|
||||
defp channel_id(id) when is_binary(id), do: id
|
||||
|
||||
defp slugify(value) when is_binary(value) do
|
||||
slug =
|
||||
value
|
||||
|> String.downcase()
|
||||
|> String.replace(~r/[^a-z0-9]+/u, "-")
|
||||
|> String.trim("-")
|
||||
|
||||
if slug == "", do: nil, else: slug
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,25 @@
|
||||
defmodule TribeOne.TribesPlugin.Aether.Chat.Backend do
|
||||
@moduledoc """
|
||||
Behaviour for chat storage/transport backends.
|
||||
|
||||
Backends are plain Elixir modules. Ash resources store conversation projections,
|
||||
participants, and local message state, while protocol backends may use Parrhesia
|
||||
raw events as canonical storage.
|
||||
|
||||
`opts` may carry signing/client context for future non-custodial clients, e.g.
|
||||
browser-held Nostr signers, NIP-07 bridges, or server-session signers. Backends
|
||||
that need signatures should reject missing signer context explicitly instead of
|
||||
assuming server-side custody.
|
||||
"""
|
||||
|
||||
alias TribeOne.TribesPlugin.Aether.Chat.Channel
|
||||
|
||||
@type attrs :: map()
|
||||
@type opts :: keyword()
|
||||
|
||||
@callback capabilities() :: map()
|
||||
@callback ensure_conversation(attrs(), opts()) :: {:ok, Channel.t()} | {:error, term()}
|
||||
@callback list_messages(Channel.t(), opts()) :: {:ok, [struct()]} | {:error, term()}
|
||||
@callback send_message(Channel.t(), attrs(), opts()) :: {:ok, struct()} | {:error, term()}
|
||||
@callback subscribe(Channel.t(), pid(), opts()) :: :ok | {:error, term()}
|
||||
end
|
||||
@@ -0,0 +1,41 @@
|
||||
defmodule TribeOne.TribesPlugin.Aether.Chat.Backends.Marmot do
|
||||
@moduledoc """
|
||||
Marmot/MLS group backend scaffold.
|
||||
|
||||
Marmot events should be stored canonically as Parrhesia/Nostr events while the
|
||||
browser/client maintains MLS state. The current web UI does not claim true E2EE.
|
||||
"""
|
||||
|
||||
@behaviour TribeOne.TribesPlugin.Aether.Chat.Backend
|
||||
|
||||
alias TribeOne.TribesPlugin.Aether.Chat
|
||||
alias TribeOne.TribesPlugin.Aether.Chat.Channel
|
||||
|
||||
@impl true
|
||||
def capabilities do
|
||||
%{
|
||||
canonical_store: :parrhesia_events,
|
||||
nostr_compatible?: true,
|
||||
non_custodial_signing?: true,
|
||||
conversation_kinds: [:marmot_group],
|
||||
read_only?: false,
|
||||
required_protocols: [:marmot, :mls]
|
||||
}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def ensure_conversation(attrs, opts) when is_map(attrs) do
|
||||
attrs
|
||||
|> Map.put(:backend, :marmot)
|
||||
|> Chat.ensure_channel(opts)
|
||||
end
|
||||
|
||||
@impl true
|
||||
def list_messages(%Channel{}, _opts), do: {:ok, []}
|
||||
|
||||
@impl true
|
||||
def send_message(%Channel{}, _attrs, _opts), do: {:error, :not_implemented}
|
||||
|
||||
@impl true
|
||||
def subscribe(%Channel{} = channel, _pid, _opts), do: Chat.subscribe_channel(channel)
|
||||
end
|
||||
@@ -0,0 +1,169 @@
|
||||
defmodule TribeOne.TribesPlugin.Aether.Chat.Backends.NostrNip04ReadOnly do
|
||||
@moduledoc """
|
||||
Legacy NIP-04 DM read-only fallback.
|
||||
|
||||
This backend queries canonical Parrhesia kind-4 events and decrypts messages for
|
||||
local display when the viewer has an unlocked session private key. Outbound
|
||||
messages intentionally remain disabled; new DMs should use NIP-17.
|
||||
"""
|
||||
|
||||
@behaviour TribeOne.TribesPlugin.Aether.Chat.Backend
|
||||
|
||||
require Ash.Query
|
||||
|
||||
alias TribeOne.TribesPlugin.Aether.Chat
|
||||
alias TribeOne.TribesPlugin.Aether.Chat.{Channel, Message, Participant}
|
||||
alias TribeOne.TribesPlugin.Aether.Chat.Nostr.Nak
|
||||
alias Parrhesia.API.Events
|
||||
alias Parrhesia.API.RequestContext
|
||||
|
||||
@kind_nip04 4
|
||||
|
||||
@impl true
|
||||
def capabilities do
|
||||
%{
|
||||
canonical_store: :parrhesia_events,
|
||||
nostr_compatible?: true,
|
||||
non_custodial_signing?: true,
|
||||
conversation_kinds: [:legacy_dm],
|
||||
read_only?: true,
|
||||
required_protocols: [:nip04],
|
||||
signer_modes: [:session_privkey, :future_external_signer]
|
||||
}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def ensure_conversation(attrs, opts) when is_map(attrs) do
|
||||
attrs
|
||||
|> Map.put(:backend, :nostr_nip04)
|
||||
|> Map.put_new(:conversation_kind, :legacy_dm)
|
||||
|> Chat.ensure_channel(opts)
|
||||
end
|
||||
|
||||
@impl true
|
||||
def list_messages(%Channel{} = channel, opts) do
|
||||
with {:ok, privkey} <- fetch_privkey(opts),
|
||||
{:ok, local_pubkey} <- Nak.pubkey_from_private_key(privkey),
|
||||
{:ok, participant_pubkeys} <- participant_pubkeys(channel),
|
||||
true <- MapSet.member?(participant_pubkeys, local_pubkey),
|
||||
{:ok, events} <- query_legacy_dms(local_pubkey, opts) do
|
||||
messages =
|
||||
events
|
||||
|> Enum.flat_map(&decrypt_message(&1, privkey, channel, participant_pubkeys))
|
||||
|> Enum.sort_by(&message_sort_key/1)
|
||||
|> maybe_take_latest(Keyword.get(opts, :limit, 100))
|
||||
|
||||
{:ok, messages}
|
||||
else
|
||||
false -> {:error, :not_a_participant}
|
||||
{:error, _reason} = error -> error
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def send_message(%Channel{}, _attrs, _opts), do: {:error, :read_only_backend}
|
||||
|
||||
@impl true
|
||||
def subscribe(%Channel{}, _pid, _opts), do: :ok
|
||||
|
||||
defp fetch_privkey(opts) do
|
||||
case Keyword.get(opts, :session_privkey) || Keyword.get(opts, :privkey) do
|
||||
privkey when is_binary(privkey) -> {:ok, privkey}
|
||||
_other -> {:error, :missing_session_privkey}
|
||||
end
|
||||
end
|
||||
|
||||
defp participant_pubkeys(%Channel{id: channel_id}) do
|
||||
Participant
|
||||
|> Ash.Query.filter(channel_id == ^channel_id)
|
||||
|> Ash.read(Chat.ash_opts())
|
||||
|> case do
|
||||
{:ok, participants} ->
|
||||
pubkeys =
|
||||
participants
|
||||
|> Enum.map(& &1.pubkey)
|
||||
|> Enum.filter(&valid_pubkey?/1)
|
||||
|> MapSet.new()
|
||||
|
||||
{:ok, pubkeys}
|
||||
|
||||
{:error, _reason} = error ->
|
||||
error
|
||||
end
|
||||
end
|
||||
|
||||
defp query_legacy_dms(local_pubkey, opts) do
|
||||
filters = [
|
||||
%{
|
||||
"kinds" => [@kind_nip04],
|
||||
"#p" => [local_pubkey],
|
||||
"limit" => Keyword.get(opts, :event_limit, 200)
|
||||
}
|
||||
]
|
||||
|
||||
Events.query(filters, context: request_context([local_pubkey]))
|
||||
end
|
||||
|
||||
defp decrypt_message(event, privkey, channel, participant_pubkeys) do
|
||||
with %{"kind" => @kind_nip04, "pubkey" => sender_pubkey, "content" => ciphertext} <- event,
|
||||
true <- MapSet.member?(participant_pubkeys, sender_pubkey),
|
||||
true <- Enum.any?(p_tags(event), &MapSet.member?(participant_pubkeys, &1)),
|
||||
{:ok, body} <- Nak.nip04_decrypt(privkey, sender_pubkey, ciphertext) do
|
||||
[
|
||||
%Message{
|
||||
id: event["id"],
|
||||
channel_id: channel.id,
|
||||
author_pubkey: sender_pubkey,
|
||||
body: body,
|
||||
client_message_id: event["id"],
|
||||
metadata: %{"nostr_kind" => @kind_nip04, "nostr_event_id" => event["id"]},
|
||||
inserted_at: unix_to_datetime(event["created_at"])
|
||||
}
|
||||
]
|
||||
else
|
||||
_other -> []
|
||||
end
|
||||
end
|
||||
|
||||
defp p_tags(event) do
|
||||
event
|
||||
|> Map.get("tags", [])
|
||||
|> Enum.flat_map(fn
|
||||
["p", pubkey | _rest] when is_binary(pubkey) -> [pubkey]
|
||||
_tag -> []
|
||||
end)
|
||||
end
|
||||
|
||||
defp message_sort_key(%Message{inserted_at: %DateTime{} = inserted_at, id: id}) do
|
||||
{DateTime.to_unix(inserted_at, :microsecond), id || ""}
|
||||
end
|
||||
|
||||
defp maybe_take_latest(messages, limit) when is_integer(limit) and limit > 0 do
|
||||
if length(messages) > limit do
|
||||
messages |> Enum.take(-limit)
|
||||
else
|
||||
messages
|
||||
end
|
||||
end
|
||||
|
||||
defp maybe_take_latest(messages, _limit), do: messages
|
||||
|
||||
defp unix_to_datetime(timestamp) when is_integer(timestamp) do
|
||||
case DateTime.from_unix(timestamp, :second) do
|
||||
{:ok, datetime} -> datetime
|
||||
{:error, _reason} -> DateTime.utc_now()
|
||||
end
|
||||
end
|
||||
|
||||
defp unix_to_datetime(_timestamp), do: DateTime.utc_now()
|
||||
|
||||
defp request_context(pubkeys) do
|
||||
%RequestContext{
|
||||
caller: :local,
|
||||
authenticated_pubkeys: MapSet.new(Enum.filter(pubkeys, &valid_pubkey?/1))
|
||||
}
|
||||
end
|
||||
|
||||
defp valid_pubkey?(pubkey) when is_binary(pubkey), do: pubkey =~ ~r/\A[0-9a-fA-F]{64}\z/
|
||||
defp valid_pubkey?(_pubkey), do: false
|
||||
end
|
||||
@@ -0,0 +1,294 @@
|
||||
defmodule TribeOne.TribesPlugin.Aether.Chat.Backends.NostrNip17 do
|
||||
@moduledoc """
|
||||
NIP-17 private DM backend.
|
||||
|
||||
Gift-wrapped Nostr events in Parrhesia are the canonical message store. The
|
||||
Ash chat channel/participant records are local UI projections used to find the
|
||||
conversation participants; decrypted message structs returned to the UI are not
|
||||
persisted through the Ash synced message resource.
|
||||
"""
|
||||
|
||||
@behaviour TribeOne.TribesPlugin.Aether.Chat.Backend
|
||||
|
||||
require Ash.Query
|
||||
|
||||
alias TribeOne.TribesPlugin.Aether.Chat
|
||||
alias TribeOne.TribesPlugin.Aether.Chat.{Channel, Message, Participant}
|
||||
alias TribeOne.TribesPlugin.Aether.Chat.Nostr.Nak
|
||||
alias Parrhesia.API.Events
|
||||
alias Parrhesia.API.RequestContext
|
||||
alias Parrhesia.API.Stream
|
||||
|
||||
@kind_giftwrap 1059
|
||||
@kind_dm 14
|
||||
|
||||
@impl true
|
||||
def capabilities do
|
||||
%{
|
||||
canonical_store: :parrhesia_events,
|
||||
nostr_compatible?: true,
|
||||
non_custodial_signing?: true,
|
||||
conversation_kinds: [:dm],
|
||||
read_only?: false,
|
||||
required_protocols: [:nip17, :nip44, :nip59],
|
||||
signer_modes: [:session_privkey, :future_external_signer],
|
||||
plaintext_projection?: false
|
||||
}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def ensure_conversation(attrs, opts) when is_map(attrs) do
|
||||
attrs
|
||||
|> Map.put(:backend, :nostr_nip17)
|
||||
|> Map.put_new(:conversation_kind, :dm)
|
||||
|> Chat.ensure_channel(opts)
|
||||
end
|
||||
|
||||
@impl true
|
||||
def list_messages(%Channel{} = channel, opts) do
|
||||
with {:ok, privkey} <- fetch_privkey(opts),
|
||||
{:ok, local_pubkey} <- Nak.pubkey_from_private_key(privkey),
|
||||
{:ok, participant_pubkeys} <- participant_pubkeys(channel),
|
||||
true <- MapSet.member?(participant_pubkeys, local_pubkey),
|
||||
{:ok, events} <- query_giftwraps(local_pubkey, opts) do
|
||||
messages =
|
||||
events
|
||||
|> Enum.flat_map(&unwrap_message(&1, privkey, channel, participant_pubkeys))
|
||||
|> dedupe_messages()
|
||||
|> Enum.sort_by(&message_sort_key/1)
|
||||
|> maybe_take_latest(Keyword.get(opts, :limit, 100))
|
||||
|
||||
{:ok, messages}
|
||||
else
|
||||
false -> {:error, :not_a_participant}
|
||||
{:error, _reason} = error -> error
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def send_message(%Channel{} = channel, attrs, opts) when is_map(attrs) do
|
||||
body = attrs |> Map.get(:body) || Map.get(attrs, "body") || ""
|
||||
body = String.trim(body)
|
||||
|
||||
with false <- body == "",
|
||||
{:ok, privkey} <- fetch_privkey(opts),
|
||||
{:ok, sender_pubkey} <- Nak.pubkey_from_private_key(privkey),
|
||||
:ok <- validate_author(attrs, sender_pubkey),
|
||||
{:ok, participant_pubkeys} <- participant_pubkeys(channel),
|
||||
true <- MapSet.member?(participant_pubkeys, sender_pubkey),
|
||||
{:ok, recipient_pubkey} <- recipient_pubkey(participant_pubkeys, sender_pubkey),
|
||||
{:ok, rumor} <- Nak.nip17_rumor(privkey, recipient_pubkey, body),
|
||||
{:ok, giftwraps} <- publish_giftwraps(privkey, rumor, [recipient_pubkey, sender_pubkey]) do
|
||||
{:ok, message_from_rumor(rumor, channel, attrs, giftwraps)}
|
||||
else
|
||||
true -> {:error, :empty_message}
|
||||
false -> {:error, :not_a_participant}
|
||||
{:error, _reason} = error -> error
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def subscribe(%Channel{} = channel, pid, opts) when is_pid(pid) do
|
||||
with {:ok, privkey} <- fetch_privkey(opts),
|
||||
{:ok, local_pubkey} <- Nak.pubkey_from_private_key(privkey) do
|
||||
filters = [%{"kinds" => [@kind_giftwrap], "#p" => [local_pubkey]}]
|
||||
|
||||
Stream.subscribe(pid, "aether-chat-#{channel.id}", filters,
|
||||
context: request_context([local_pubkey])
|
||||
)
|
||||
else
|
||||
{:error, :missing_session_privkey} -> :ok
|
||||
{:error, _reason} = error -> error
|
||||
end
|
||||
end
|
||||
|
||||
def unwrap_event(%Channel{} = channel, giftwrap, opts) when is_map(giftwrap) do
|
||||
with {:ok, privkey} <- fetch_privkey(opts),
|
||||
{:ok, participant_pubkeys} <- participant_pubkeys(channel) do
|
||||
case unwrap_message(giftwrap, privkey, channel, participant_pubkeys) do
|
||||
[message] -> {:ok, message}
|
||||
[] -> {:error, :not_conversation_message}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
defp fetch_privkey(opts) do
|
||||
case Keyword.get(opts, :session_privkey) || Keyword.get(opts, :privkey) do
|
||||
privkey when is_binary(privkey) -> {:ok, privkey}
|
||||
_other -> {:error, :missing_session_privkey}
|
||||
end
|
||||
end
|
||||
|
||||
defp validate_author(attrs, sender_pubkey) do
|
||||
case Map.get(attrs, :author_pubkey) || Map.get(attrs, "author_pubkey") do
|
||||
nil -> :ok
|
||||
^sender_pubkey -> :ok
|
||||
_other -> {:error, :author_pubkey_mismatch}
|
||||
end
|
||||
end
|
||||
|
||||
defp participant_pubkeys(%Channel{id: channel_id}) do
|
||||
Participant
|
||||
|> Ash.Query.filter(channel_id == ^channel_id)
|
||||
|> Ash.read(Chat.ash_opts())
|
||||
|> case do
|
||||
{:ok, participants} ->
|
||||
pubkeys =
|
||||
participants
|
||||
|> Enum.map(& &1.pubkey)
|
||||
|> Enum.filter(&valid_pubkey?/1)
|
||||
|> MapSet.new()
|
||||
|
||||
{:ok, pubkeys}
|
||||
|
||||
{:error, _reason} = error ->
|
||||
error
|
||||
end
|
||||
end
|
||||
|
||||
defp recipient_pubkey(participant_pubkeys, sender_pubkey) do
|
||||
case participant_pubkeys |> MapSet.delete(sender_pubkey) |> MapSet.to_list() do
|
||||
[recipient_pubkey] -> {:ok, recipient_pubkey}
|
||||
[] -> {:error, :missing_recipient}
|
||||
_many -> {:error, :unsupported_multi_recipient_dm}
|
||||
end
|
||||
end
|
||||
|
||||
defp publish_giftwraps(privkey, rumor, delivery_pubkeys) do
|
||||
delivery_pubkeys
|
||||
|> Enum.uniq()
|
||||
|> Enum.reduce_while({:ok, []}, fn delivery_pubkey, {:ok, acc} ->
|
||||
with {:ok, giftwrap} <- Nak.nip17_wrap(privkey, delivery_pubkey, rumor),
|
||||
:ok <- publish_event(giftwrap) do
|
||||
{:cont, {:ok, [giftwrap | acc]}}
|
||||
else
|
||||
{:error, _reason} = error -> {:halt, error}
|
||||
end
|
||||
end)
|
||||
|> case do
|
||||
{:ok, giftwraps} -> {:ok, Enum.reverse(giftwraps)}
|
||||
{:error, _reason} = error -> error
|
||||
end
|
||||
end
|
||||
|
||||
defp publish_event(event) do
|
||||
with {:ok, result} <- Events.publish(event, context: request_context([event["pubkey"]])),
|
||||
true <- result.accepted do
|
||||
:ok
|
||||
else
|
||||
false -> {:error, :publish_rejected}
|
||||
{:ok, %{reason: reason}} when not is_nil(reason) -> {:error, reason}
|
||||
{:error, _reason} = error -> error
|
||||
other -> {:error, other}
|
||||
end
|
||||
end
|
||||
|
||||
defp query_giftwraps(local_pubkey, opts) do
|
||||
filters = [
|
||||
%{
|
||||
"kinds" => [@kind_giftwrap],
|
||||
"#p" => [local_pubkey],
|
||||
"limit" => Keyword.get(opts, :event_limit, 200)
|
||||
}
|
||||
]
|
||||
|
||||
Events.query(filters, context: request_context([local_pubkey]))
|
||||
end
|
||||
|
||||
defp unwrap_message(giftwrap, privkey, channel, participant_pubkeys) do
|
||||
with {:ok, rumor} <- Nak.nip17_unwrap(privkey, giftwrap),
|
||||
true <- conversation_rumor?(rumor, participant_pubkeys) do
|
||||
[message_from_rumor(rumor, channel, %{}, [giftwrap])]
|
||||
else
|
||||
_other -> []
|
||||
end
|
||||
end
|
||||
|
||||
defp conversation_rumor?(%{"kind" => @kind_dm, "pubkey" => sender_pubkey} = rumor, pubkeys) do
|
||||
recipient_pubkeys = p_tags(rumor)
|
||||
|
||||
MapSet.member?(pubkeys, sender_pubkey) and
|
||||
Enum.any?(recipient_pubkeys, &MapSet.member?(pubkeys, &1))
|
||||
end
|
||||
|
||||
defp conversation_rumor?(_rumor, _pubkeys), do: false
|
||||
|
||||
defp message_from_rumor(rumor, channel, attrs, giftwraps) do
|
||||
%Message{
|
||||
id: rumor["id"] || (List.first(giftwraps || []) && List.first(giftwraps)["id"]),
|
||||
channel_id: channel.id,
|
||||
author_id: Map.get(attrs, :author_id) || Map.get(attrs, "author_id"),
|
||||
author_pubkey: rumor["pubkey"],
|
||||
body: rumor["content"] || "",
|
||||
client_message_id: rumor["id"],
|
||||
metadata: message_metadata(rumor, attrs, giftwraps),
|
||||
inserted_at: unix_to_datetime(rumor["created_at"])
|
||||
}
|
||||
end
|
||||
|
||||
defp message_metadata(rumor, attrs, giftwraps) do
|
||||
attrs_metadata = Map.get(attrs, :metadata) || Map.get(attrs, "metadata") || %{}
|
||||
|
||||
attrs_metadata
|
||||
|> Map.put_new("author_name", get_in(attrs_metadata, ["author_name"]))
|
||||
|> Map.put("nostr_kind", @kind_dm)
|
||||
|> Map.put("nostr_rumor_id", rumor["id"])
|
||||
|> Map.put("nostr_giftwrap_ids", Enum.map(giftwraps || [], & &1["id"]))
|
||||
end
|
||||
|
||||
defp p_tags(event) do
|
||||
event
|
||||
|> Map.get("tags", [])
|
||||
|> Enum.flat_map(fn
|
||||
["p", pubkey | _rest] when is_binary(pubkey) -> [pubkey]
|
||||
_tag -> []
|
||||
end)
|
||||
end
|
||||
|
||||
defp dedupe_messages(messages) do
|
||||
messages
|
||||
|> Enum.reduce({MapSet.new(), []}, fn message, {seen, acc} ->
|
||||
key = message.client_message_id || message.id
|
||||
|
||||
if MapSet.member?(seen, key) do
|
||||
{seen, acc}
|
||||
else
|
||||
{MapSet.put(seen, key), [message | acc]}
|
||||
end
|
||||
end)
|
||||
|> elem(1)
|
||||
end
|
||||
|
||||
defp message_sort_key(%Message{inserted_at: %DateTime{} = inserted_at, id: id}) do
|
||||
{DateTime.to_unix(inserted_at, :microsecond), id || ""}
|
||||
end
|
||||
|
||||
defp maybe_take_latest(messages, limit) when is_integer(limit) and limit > 0 do
|
||||
if length(messages) > limit do
|
||||
messages |> Enum.take(-limit)
|
||||
else
|
||||
messages
|
||||
end
|
||||
end
|
||||
|
||||
defp maybe_take_latest(messages, _limit), do: messages
|
||||
|
||||
defp unix_to_datetime(timestamp) when is_integer(timestamp) do
|
||||
case DateTime.from_unix(timestamp, :second) do
|
||||
{:ok, datetime} -> datetime
|
||||
{:error, _reason} -> DateTime.utc_now()
|
||||
end
|
||||
end
|
||||
|
||||
defp unix_to_datetime(_timestamp), do: DateTime.utc_now()
|
||||
|
||||
defp request_context(pubkeys) do
|
||||
%RequestContext{
|
||||
caller: :local,
|
||||
authenticated_pubkeys: MapSet.new(Enum.filter(pubkeys, &valid_pubkey?/1))
|
||||
}
|
||||
end
|
||||
|
||||
defp valid_pubkey?(pubkey) when is_binary(pubkey), do: pubkey =~ ~r/\A[0-9a-fA-F]{64}\z/
|
||||
defp valid_pubkey?(_pubkey), do: false
|
||||
end
|
||||
@@ -0,0 +1,35 @@
|
||||
defmodule TribeOne.TribesPlugin.Aether.Chat.Backends.PublicSync do
|
||||
@moduledoc """
|
||||
Plaintext Ash-backed chat backend for public and local DM-style conversations.
|
||||
"""
|
||||
|
||||
@behaviour TribeOne.TribesPlugin.Aether.Chat.Backend
|
||||
|
||||
alias TribeOne.TribesPlugin.Aether.Chat
|
||||
alias TribeOne.TribesPlugin.Aether.Chat.Channel
|
||||
|
||||
@impl true
|
||||
def capabilities do
|
||||
%{
|
||||
canonical_store: :ash,
|
||||
nostr_compatible?: false,
|
||||
non_custodial_signing?: false,
|
||||
conversation_kinds: [:group, :context_group, :dm],
|
||||
read_only?: false
|
||||
}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def ensure_conversation(attrs, opts) when is_map(attrs), do: Chat.ensure_channel(attrs, opts)
|
||||
|
||||
@impl true
|
||||
def list_messages(%Channel{} = channel, opts), do: Chat.list_messages(channel, opts)
|
||||
|
||||
@impl true
|
||||
def send_message(%Channel{} = channel, attrs, opts) when is_map(attrs) do
|
||||
Chat.post_message(channel, attrs, opts)
|
||||
end
|
||||
|
||||
@impl true
|
||||
def subscribe(%Channel{} = channel, _pid, _opts), do: Chat.subscribe_channel(channel)
|
||||
end
|
||||
@@ -0,0 +1,180 @@
|
||||
defmodule TribeOne.TribesPlugin.Aether.Chat.Channel do
|
||||
@moduledoc """
|
||||
Public chat channel metadata.
|
||||
"""
|
||||
|
||||
use Ash.Resource,
|
||||
otp_app: :tribe_one_aether,
|
||||
domain: TribeOne.TribesPlugin.Aether.Chat,
|
||||
data_layer: AshPostgres.DataLayer,
|
||||
extensions: [AshNostrSync]
|
||||
|
||||
import Ash.Expr
|
||||
|
||||
@backends [:public_sync, :nostr_nip17, :nostr_nip04, :marmot]
|
||||
@conversation_kinds [:group, :context_group, :dm, :legacy_dm, :marmot_group]
|
||||
|
||||
postgres do
|
||||
table("aether_chat_channels")
|
||||
repo(Tribes.Repo)
|
||||
|
||||
custom_indexes do
|
||||
index([:slug], unique: true, where: "deleted_at IS NULL")
|
||||
index([:context_provider, :context_type, :context_id], where: "deleted_at IS NULL")
|
||||
end
|
||||
end
|
||||
|
||||
nostr_sync do
|
||||
namespace("plugins.aether.chat.channel")
|
||||
lane(:control)
|
||||
publish?(true)
|
||||
consume?(true)
|
||||
end
|
||||
|
||||
actions do
|
||||
defaults([:read])
|
||||
|
||||
read :by_id do
|
||||
get?(true)
|
||||
get_by(:id)
|
||||
end
|
||||
|
||||
read :by_slug do
|
||||
get?(true)
|
||||
|
||||
argument :slug, :string do
|
||||
allow_nil?(false)
|
||||
end
|
||||
|
||||
filter(expr(slug == ^arg(:slug)))
|
||||
end
|
||||
|
||||
create :create do
|
||||
accept([
|
||||
:id,
|
||||
:slug,
|
||||
:title,
|
||||
:description,
|
||||
:backend,
|
||||
:conversation_kind,
|
||||
:context_provider,
|
||||
:context_type,
|
||||
:context_id,
|
||||
:metadata
|
||||
])
|
||||
|
||||
change(AshNostrSync.PublishChange)
|
||||
end
|
||||
|
||||
create :sync_upsert do
|
||||
accept([
|
||||
:id,
|
||||
:slug,
|
||||
:title,
|
||||
:description,
|
||||
:backend,
|
||||
:conversation_kind,
|
||||
:context_provider,
|
||||
:context_type,
|
||||
:context_id,
|
||||
:metadata
|
||||
])
|
||||
|
||||
upsert?(true)
|
||||
end
|
||||
|
||||
update :update do
|
||||
require_atomic?(false)
|
||||
|
||||
accept([
|
||||
:title,
|
||||
:description,
|
||||
:backend,
|
||||
:conversation_kind,
|
||||
:context_provider,
|
||||
:context_type,
|
||||
:context_id,
|
||||
:metadata
|
||||
])
|
||||
|
||||
change(AshNostrSync.PublishChange)
|
||||
end
|
||||
|
||||
destroy :destroy do
|
||||
require_atomic?(false)
|
||||
soft?(true)
|
||||
soft_delete()
|
||||
change(AshNostrSync.PublishChange)
|
||||
end
|
||||
end
|
||||
|
||||
relationships do
|
||||
has_many :messages, TribeOne.TribesPlugin.Aether.Chat.Message do
|
||||
destination_attribute(:channel_id)
|
||||
public?(true)
|
||||
end
|
||||
|
||||
has_many :participants, TribeOne.TribesPlugin.Aether.Chat.Participant do
|
||||
destination_attribute(:channel_id)
|
||||
public?(true)
|
||||
end
|
||||
end
|
||||
|
||||
attributes do
|
||||
attribute :id, :uuid do
|
||||
allow_nil?(false)
|
||||
primary_key?(true)
|
||||
public?(true)
|
||||
writable?(true)
|
||||
default(&Ash.UUID.generate/0)
|
||||
end
|
||||
|
||||
attribute :slug, :string do
|
||||
allow_nil?(false)
|
||||
public?(true)
|
||||
end
|
||||
|
||||
attribute :title, :string do
|
||||
allow_nil?(false)
|
||||
public?(true)
|
||||
end
|
||||
|
||||
attribute :description, :string do
|
||||
public?(true)
|
||||
end
|
||||
|
||||
attribute :backend, :atom do
|
||||
constraints(one_of: @backends)
|
||||
allow_nil?(false)
|
||||
default(:public_sync)
|
||||
public?(true)
|
||||
end
|
||||
|
||||
attribute :conversation_kind, :atom do
|
||||
constraints(one_of: @conversation_kinds)
|
||||
allow_nil?(false)
|
||||
default(:group)
|
||||
public?(true)
|
||||
end
|
||||
|
||||
attribute :context_provider, :string do
|
||||
public?(true)
|
||||
end
|
||||
|
||||
attribute :context_type, :string do
|
||||
public?(true)
|
||||
end
|
||||
|
||||
attribute :context_id, :string do
|
||||
public?(true)
|
||||
end
|
||||
|
||||
attribute :metadata, :map do
|
||||
allow_nil?(false)
|
||||
default(%{})
|
||||
public?(true)
|
||||
end
|
||||
|
||||
timestamps(type: :utc_datetime)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,139 @@
|
||||
defmodule TribeOne.TribesPlugin.Aether.Chat.Message do
|
||||
@moduledoc """
|
||||
Public synced chat message.
|
||||
"""
|
||||
|
||||
use Ash.Resource,
|
||||
otp_app: :tribe_one_aether,
|
||||
domain: TribeOne.TribesPlugin.Aether.Chat,
|
||||
data_layer: AshPostgres.DataLayer,
|
||||
extensions: [AshNostrSync]
|
||||
|
||||
import Ash.Expr
|
||||
|
||||
postgres do
|
||||
table("aether_chat_messages")
|
||||
repo(Tribes.Repo)
|
||||
|
||||
custom_indexes do
|
||||
index([:channel_id, :inserted_at])
|
||||
index([:author_pubkey])
|
||||
|
||||
index([:client_message_id],
|
||||
unique: true,
|
||||
where: "client_message_id IS NOT NULL AND deleted_at IS NULL"
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
nostr_sync do
|
||||
namespace("plugins.aether.chat.message")
|
||||
lane(:bulk)
|
||||
publish?(true)
|
||||
consume?(true)
|
||||
end
|
||||
|
||||
actions do
|
||||
defaults([:read])
|
||||
|
||||
read :by_id do
|
||||
get?(true)
|
||||
get_by(:id)
|
||||
end
|
||||
|
||||
read :by_channel do
|
||||
argument :channel_id, :uuid do
|
||||
allow_nil?(false)
|
||||
end
|
||||
|
||||
prepare(
|
||||
build(filter: expr(channel_id == ^arg(:channel_id)), sort: [inserted_at: :asc, id: :asc])
|
||||
)
|
||||
end
|
||||
|
||||
create :create do
|
||||
accept([
|
||||
:id,
|
||||
:channel_id,
|
||||
:author_id,
|
||||
:author_pubkey,
|
||||
:body,
|
||||
:client_message_id,
|
||||
:metadata
|
||||
])
|
||||
|
||||
change(AshNostrSync.PublishChange)
|
||||
end
|
||||
|
||||
create :sync_upsert do
|
||||
accept([
|
||||
:id,
|
||||
:channel_id,
|
||||
:author_id,
|
||||
:author_pubkey,
|
||||
:body,
|
||||
:client_message_id,
|
||||
:metadata
|
||||
])
|
||||
|
||||
upsert?(true)
|
||||
end
|
||||
|
||||
update :update do
|
||||
require_atomic?(false)
|
||||
accept([:body, :metadata])
|
||||
change(AshNostrSync.PublishChange)
|
||||
end
|
||||
|
||||
destroy :destroy do
|
||||
require_atomic?(false)
|
||||
soft?(true)
|
||||
soft_delete()
|
||||
change(AshNostrSync.PublishChange)
|
||||
end
|
||||
end
|
||||
|
||||
relationships do
|
||||
belongs_to :channel, TribeOne.TribesPlugin.Aether.Chat.Channel do
|
||||
allow_nil?(false)
|
||||
attribute_type(:uuid)
|
||||
public?(true)
|
||||
end
|
||||
end
|
||||
|
||||
attributes do
|
||||
attribute :id, :uuid do
|
||||
allow_nil?(false)
|
||||
primary_key?(true)
|
||||
public?(true)
|
||||
writable?(true)
|
||||
default(&Ash.UUID.generate/0)
|
||||
end
|
||||
|
||||
attribute :author_id, :uuid do
|
||||
public?(true)
|
||||
end
|
||||
|
||||
attribute :author_pubkey, :string do
|
||||
public?(true)
|
||||
end
|
||||
|
||||
attribute :body, :string do
|
||||
allow_nil?(false)
|
||||
public?(true)
|
||||
constraints(allow_empty?: false, trim?: true)
|
||||
end
|
||||
|
||||
attribute :client_message_id, :string do
|
||||
public?(true)
|
||||
end
|
||||
|
||||
attribute :metadata, :map do
|
||||
allow_nil?(false)
|
||||
default(%{})
|
||||
public?(true)
|
||||
end
|
||||
|
||||
timestamps(type: :utc_datetime)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,157 @@
|
||||
defmodule TribeOne.TribesPlugin.Aether.Chat.Nostr.Nak do
|
||||
@moduledoc false
|
||||
|
||||
@timeout_ms 30_000
|
||||
|
||||
def available?, do: System.find_executable("nak") != nil
|
||||
|
||||
def pubkey_from_private_key(privkey) do
|
||||
with {:ok, privkey_hex} <- normalize_private_key(privkey) do
|
||||
run_script(
|
||||
~S'''
|
||||
set -euo pipefail
|
||||
nak key public "$1"
|
||||
''',
|
||||
[privkey_hex]
|
||||
)
|
||||
|> trim_result()
|
||||
end
|
||||
end
|
||||
|
||||
def nip17_rumor(sender_privkey, recipient_pubkey, body)
|
||||
when is_binary(recipient_pubkey) and is_binary(body) do
|
||||
with {:ok, sender_privkey_hex} <- normalize_private_key(sender_privkey),
|
||||
:ok <- validate_pubkey(recipient_pubkey) do
|
||||
run_script(
|
||||
~S'''
|
||||
set -euo pipefail
|
||||
nak event --sec "$1" -k 14 -p "$2" -c "$3" </dev/null 2>/dev/null
|
||||
''',
|
||||
[sender_privkey_hex, recipient_pubkey, body]
|
||||
)
|
||||
|> decode_json_result()
|
||||
end
|
||||
end
|
||||
|
||||
def nip17_wrap(sender_privkey, recipient_pubkey, rumor) when is_map(rumor) do
|
||||
with {:ok, sender_privkey_hex} <- normalize_private_key(sender_privkey),
|
||||
:ok <- validate_pubkey(recipient_pubkey),
|
||||
{:ok, rumor_json} <- encode_json(rumor) do
|
||||
run_script(
|
||||
~S'''
|
||||
set -euo pipefail
|
||||
printf '%s\n' "$3" | nak gift wrap --sec "$1" -p "$2" 2>/dev/null
|
||||
''',
|
||||
[sender_privkey_hex, recipient_pubkey, rumor_json]
|
||||
)
|
||||
|> decode_json_result()
|
||||
end
|
||||
end
|
||||
|
||||
def nip17_unwrap(recipient_privkey, giftwrap) when is_map(giftwrap) do
|
||||
with {:ok, recipient_privkey_hex} <- normalize_private_key(recipient_privkey),
|
||||
{:ok, giftwrap_json} <- encode_json(giftwrap) do
|
||||
run_script(
|
||||
~S'''
|
||||
set -euo pipefail
|
||||
printf '%s\n' "$2" | nak gift unwrap --sec "$1" 2>/dev/null
|
||||
''',
|
||||
[recipient_privkey_hex, giftwrap_json]
|
||||
)
|
||||
|> decode_json_result()
|
||||
end
|
||||
end
|
||||
|
||||
def nip04_encrypt(sender_privkey, recipient_pubkey, plaintext)
|
||||
when is_binary(recipient_pubkey) and is_binary(plaintext) do
|
||||
with {:ok, sender_privkey_hex} <- normalize_private_key(sender_privkey),
|
||||
:ok <- validate_pubkey(recipient_pubkey) do
|
||||
run_script(
|
||||
~S'''
|
||||
set -euo pipefail
|
||||
nak encrypt --nip04 --sec "$1" -p "$2" "$3" </dev/null 2>/dev/null
|
||||
''',
|
||||
[sender_privkey_hex, recipient_pubkey, plaintext]
|
||||
)
|
||||
|> trim_result()
|
||||
end
|
||||
end
|
||||
|
||||
def nip04_decrypt(recipient_privkey, sender_pubkey, ciphertext)
|
||||
when is_binary(sender_pubkey) and is_binary(ciphertext) do
|
||||
with {:ok, recipient_privkey_hex} <- normalize_private_key(recipient_privkey),
|
||||
:ok <- validate_pubkey(sender_pubkey) do
|
||||
run_script(
|
||||
~S'''
|
||||
set -euo pipefail
|
||||
nak decrypt --nip04 --sec "$1" -p "$2" "$3" 2>/dev/null
|
||||
''',
|
||||
[recipient_privkey_hex, sender_pubkey, ciphertext]
|
||||
)
|
||||
|> trim_result()
|
||||
end
|
||||
end
|
||||
|
||||
def normalize_private_key(<<_::binary-size(32)>> = privkey),
|
||||
do: {:ok, Base.encode16(privkey, case: :lower)}
|
||||
|
||||
def normalize_private_key(privkey) when is_binary(privkey) do
|
||||
if privkey =~ ~r/\A[0-9a-fA-F]{64}\z/ do
|
||||
{:ok, String.downcase(privkey)}
|
||||
else
|
||||
{:error, :invalid_private_key}
|
||||
end
|
||||
end
|
||||
|
||||
def normalize_private_key(_privkey), do: {:error, :invalid_private_key}
|
||||
|
||||
defp validate_pubkey(pubkey) when is_binary(pubkey) do
|
||||
if pubkey =~ ~r/\A[0-9a-fA-F]{64}\z/ do
|
||||
:ok
|
||||
else
|
||||
{:error, :invalid_pubkey}
|
||||
end
|
||||
end
|
||||
|
||||
defp validate_pubkey(_pubkey), do: {:error, :invalid_pubkey}
|
||||
|
||||
defp run_script(script, args) do
|
||||
if available?() do
|
||||
do_run_script(script, args)
|
||||
else
|
||||
{:error, :nak_not_found}
|
||||
end
|
||||
end
|
||||
|
||||
defp do_run_script(script, args) do
|
||||
task =
|
||||
Task.async(fn ->
|
||||
System.cmd("bash", ["-lc", script, "bash" | args], stderr_to_stdout: true)
|
||||
end)
|
||||
|
||||
case Task.yield(task, @timeout_ms) || Task.shutdown(task, :brutal_kill) do
|
||||
{:ok, {output, 0}} -> {:ok, output}
|
||||
{:ok, {output, status}} -> {:error, {:nak_failed, status, output}}
|
||||
nil -> {:error, :nak_timeout}
|
||||
end
|
||||
end
|
||||
|
||||
defp trim_result({:ok, output}), do: {:ok, String.trim(output)}
|
||||
defp trim_result({:error, _reason} = error), do: error
|
||||
|
||||
defp decode_json_result({:ok, output}) do
|
||||
output
|
||||
|> String.trim()
|
||||
|> JSON.decode()
|
||||
rescue
|
||||
_error -> {:error, {:invalid_json, output}}
|
||||
end
|
||||
|
||||
defp decode_json_result({:error, _reason} = error), do: error
|
||||
|
||||
defp encode_json(value) do
|
||||
{:ok, JSON.encode!(value)}
|
||||
rescue
|
||||
_error -> {:error, :invalid_json}
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,127 @@
|
||||
defmodule TribeOne.TribesPlugin.Aether.Chat.Participant do
|
||||
@moduledoc """
|
||||
Participant projection for Aether conversations.
|
||||
"""
|
||||
|
||||
use Ash.Resource,
|
||||
otp_app: :tribe_one_aether,
|
||||
domain: TribeOne.TribesPlugin.Aether.Chat,
|
||||
data_layer: AshPostgres.DataLayer,
|
||||
extensions: [AshNostrSync]
|
||||
|
||||
import Ash.Expr
|
||||
|
||||
@roles [:owner, :member]
|
||||
|
||||
postgres do
|
||||
table("aether_chat_participants")
|
||||
repo(Tribes.Repo)
|
||||
|
||||
custom_indexes do
|
||||
index([:channel_id, :pubkey], unique: true, where: "deleted_at IS NULL")
|
||||
index([:pubkey], where: "deleted_at IS NULL")
|
||||
end
|
||||
end
|
||||
|
||||
nostr_sync do
|
||||
namespace("plugins.aether.chat.participant")
|
||||
lane(:control)
|
||||
publish?(true)
|
||||
consume?(true)
|
||||
end
|
||||
|
||||
actions do
|
||||
defaults([:read])
|
||||
|
||||
read :by_channel_pubkey do
|
||||
get?(true)
|
||||
|
||||
argument :channel_id, :uuid do
|
||||
allow_nil?(false)
|
||||
end
|
||||
|
||||
argument :pubkey, :string do
|
||||
allow_nil?(false)
|
||||
end
|
||||
|
||||
filter(expr(channel_id == ^arg(:channel_id) and pubkey == ^arg(:pubkey)))
|
||||
end
|
||||
|
||||
read :by_pubkey do
|
||||
argument :pubkey, :string do
|
||||
allow_nil?(false)
|
||||
end
|
||||
|
||||
prepare(build(filter: expr(pubkey == ^arg(:pubkey)), sort: [inserted_at: :desc]))
|
||||
end
|
||||
|
||||
create :create do
|
||||
accept([:id, :channel_id, :pubkey, :user_id, :display_name, :role, :metadata])
|
||||
change(AshNostrSync.PublishChange)
|
||||
end
|
||||
|
||||
create :sync_upsert do
|
||||
accept([:id, :channel_id, :pubkey, :user_id, :display_name, :role, :metadata])
|
||||
upsert?(true)
|
||||
end
|
||||
|
||||
update :update do
|
||||
require_atomic?(false)
|
||||
accept([:user_id, :display_name, :role, :metadata])
|
||||
change(AshNostrSync.PublishChange)
|
||||
end
|
||||
|
||||
destroy :destroy do
|
||||
require_atomic?(false)
|
||||
soft?(true)
|
||||
soft_delete()
|
||||
change(AshNostrSync.PublishChange)
|
||||
end
|
||||
end
|
||||
|
||||
relationships do
|
||||
belongs_to :channel, TribeOne.TribesPlugin.Aether.Chat.Channel do
|
||||
allow_nil?(false)
|
||||
attribute_type(:uuid)
|
||||
public?(true)
|
||||
end
|
||||
end
|
||||
|
||||
attributes do
|
||||
attribute :id, :uuid do
|
||||
allow_nil?(false)
|
||||
primary_key?(true)
|
||||
public?(true)
|
||||
writable?(true)
|
||||
default(&Ash.UUID.generate/0)
|
||||
end
|
||||
|
||||
attribute :pubkey, :string do
|
||||
allow_nil?(false)
|
||||
public?(true)
|
||||
end
|
||||
|
||||
attribute :user_id, :uuid do
|
||||
public?(true)
|
||||
end
|
||||
|
||||
attribute :display_name, :string do
|
||||
public?(true)
|
||||
end
|
||||
|
||||
attribute :role, :atom do
|
||||
constraints(one_of: @roles)
|
||||
allow_nil?(false)
|
||||
default(:member)
|
||||
public?(true)
|
||||
end
|
||||
|
||||
attribute :metadata, :map do
|
||||
allow_nil?(false)
|
||||
default(%{})
|
||||
public?(true)
|
||||
end
|
||||
|
||||
timestamps(type: :utc_datetime)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,111 @@
|
||||
defmodule TribeOne.TribesPlugin.Aether.Plugin do
|
||||
@moduledoc """
|
||||
Tribes plugin entry point.
|
||||
"""
|
||||
|
||||
use Tribes.Plugin.Base, otp_app: :tribe_one_aether
|
||||
|
||||
@behaviour Tribes.Capabilities.Chat.V1
|
||||
|
||||
@impl true
|
||||
def register(context) do
|
||||
super(context)
|
||||
|> Map.merge(%{
|
||||
nav_items: [
|
||||
%{
|
||||
label: "Aether",
|
||||
path: "/aether",
|
||||
icon: nil,
|
||||
requires: [],
|
||||
order: 50
|
||||
},
|
||||
%{
|
||||
label: "Chat",
|
||||
path: "/aether/chat",
|
||||
icon: nil,
|
||||
requires: [],
|
||||
order: 51
|
||||
}
|
||||
],
|
||||
pages: [
|
||||
%{
|
||||
path: "/aether/chat",
|
||||
live_view: TribeOne.TribesPlugin.AetherWeb.ChatLive,
|
||||
layout: nil
|
||||
},
|
||||
%{
|
||||
path: "/aether",
|
||||
live_view: TribeOne.TribesPlugin.AetherWeb.TimelineLive,
|
||||
layout: nil
|
||||
}
|
||||
],
|
||||
ash_domains: [TribeOne.TribesPlugin.Aether.Chat],
|
||||
config_schema: %{
|
||||
title: "Aether",
|
||||
description: "Social feed and chat defaults.",
|
||||
groups: [
|
||||
%{
|
||||
id: "chat",
|
||||
label: "Chat",
|
||||
description: "Group chat backend defaults.",
|
||||
order: 10,
|
||||
settings: [
|
||||
%{
|
||||
key: "chat.default_backend",
|
||||
label: "Default chat backend",
|
||||
description:
|
||||
"New standalone channels use public synced chat until Marmot is explicitly selected.",
|
||||
type: :enum,
|
||||
default: "public_sync",
|
||||
options: [
|
||||
%{label: "Public synced", value: "public_sync"},
|
||||
%{label: "Marmot scaffold", value: "marmot"}
|
||||
],
|
||||
order: 10
|
||||
},
|
||||
%{
|
||||
key: "chat.marmot_enabled",
|
||||
label: "Enable Marmot chat UI",
|
||||
description:
|
||||
"Reserved for the Marmot browser transport once storage, relay, and signing adapters are complete.",
|
||||
type: :boolean,
|
||||
default: false,
|
||||
order: 20
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
end
|
||||
|
||||
@impl Tribes.Capabilities.Chat.V1
|
||||
def embedded_panel(slug, opts \\ []) when is_binary(slug) do
|
||||
assigns = %{
|
||||
slug: slug,
|
||||
backend: Keyword.get(opts, :backend, :public_sync),
|
||||
embedded?: true,
|
||||
current_user: Keyword.get(opts, :current_user),
|
||||
session_privkey: Keyword.get(opts, :session_privkey)
|
||||
}
|
||||
|
||||
{:ok,
|
||||
Tribes.Plugin.Surface.live_component(
|
||||
TribeOne.TribesPlugin.AetherWeb.ChatPanelComponent,
|
||||
Keyword.get(opts, :id, "aether-chat-panel-#{slug}"),
|
||||
assigns,
|
||||
capability: "org.tribe-one.caps.chat@1",
|
||||
provider: __MODULE__
|
||||
)}
|
||||
end
|
||||
|
||||
@impl Tribes.Capabilities.Chat.V1
|
||||
def standalone_path(slug), do: TribeOne.TribesPlugin.Aether.Chat.standalone_path(slug)
|
||||
|
||||
@impl Tribes.Capabilities.Chat.V1
|
||||
def handle_surface_info(_surface, {:aether_chat, :message, message}) do
|
||||
{:ok, %{incoming_message: message}}
|
||||
end
|
||||
|
||||
def handle_surface_info(_surface, _message), do: :ignore
|
||||
end
|
||||
@@ -0,0 +1,186 @@
|
||||
defmodule TribeOne.TribesPlugin.AetherWeb.ChatLive do
|
||||
use Phoenix.LiveView
|
||||
|
||||
on_mount({Tribes.Plugin.LiveUserAuth, :live_user_optional})
|
||||
|
||||
alias TribeOne.TribesPlugin.Aether.Chat
|
||||
alias TribeOne.TribesPlugin.AetherWeb.{ChatPanelComponent, ChatRecipientPickerComponent}
|
||||
alias Tribes.Plugin.Layouts
|
||||
|
||||
@component_id "aether-chat-panel-component"
|
||||
|
||||
@impl true
|
||||
def mount(_params, session, socket) do
|
||||
request = channel_request(session)
|
||||
|
||||
{:ok,
|
||||
socket
|
||||
|> assign(:page_title, channel_title(request.slug))
|
||||
|> assign(:chat_request, request)
|
||||
|> assign(:component_id, @component_id)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_info({:aether_chat, :message, message}, socket) do
|
||||
send_update(ChatPanelComponent, id: @component_id, incoming_message: message)
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
def handle_info({:aether_chat, :recipient_selected, recipient}, socket) do
|
||||
current_user = socket.assigns.current_user
|
||||
|
||||
if current_user && is_binary(recipient.pubkey_hex) do
|
||||
attrs = %{
|
||||
title: recipient_label(recipient),
|
||||
metadata: %{"recipient_username" => recipient.username}
|
||||
}
|
||||
|
||||
case Chat.ensure_direct_conversation(current_user.pubkey_hex, recipient.pubkey_hex, attrs,
|
||||
session_privkey: socket.assigns[:session_privkey]
|
||||
) do
|
||||
{:ok, channel} ->
|
||||
{:noreply, push_navigate(socket, to: Chat.standalone_path(channel))}
|
||||
|
||||
{:error, reason} ->
|
||||
{:noreply, put_flash(socket, :error, "Could not open conversation: #{inspect(reason)}")}
|
||||
end
|
||||
else
|
||||
{:noreply, put_flash(socket, :error, "Sign in to start a direct message")}
|
||||
end
|
||||
end
|
||||
|
||||
def handle_info({:parrhesia, :events, _ref, _subscription_id, events}, socket)
|
||||
when is_list(events) do
|
||||
Enum.each(events, &send_unwrapped_message(socket, &1))
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
def handle_info({:parrhesia, :event, _ref, _subscription_id, event}, socket) do
|
||||
send_unwrapped_message(socket, event)
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
def handle_info({:parrhesia, :eose, _ref, _subscription_id, _hints}, socket),
|
||||
do: {:noreply, socket}
|
||||
|
||||
def handle_info({:parrhesia, :eose, _ref, _subscription_id}, socket), do: {:noreply, socket}
|
||||
|
||||
@impl true
|
||||
def render(%{chat_request: %{mode: :embedded}} = assigns) do
|
||||
~H"""
|
||||
<div class="aether aether-chat h-full min-h-[28rem] p-2" id="aether-chat-embed">
|
||||
<.live_component
|
||||
module={ChatPanelComponent}
|
||||
id={@component_id}
|
||||
slug={@chat_request.slug}
|
||||
backend={@chat_request.backend}
|
||||
embedded?={true}
|
||||
current_user={@current_user}
|
||||
session_privkey={@session_privkey}
|
||||
/>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<Layouts.app flash={@flash} current_scope={@current_scope}>
|
||||
<div class="aether aether-chat flex min-h-[calc(100vh-8rem)] flex-col p-4 sm:p-6" id="aether-chat-page">
|
||||
<section class="mb-4 rounded-3xl border border-base-300 bg-base-100/95 p-5 shadow-sm">
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
|
||||
<div>
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.3em] text-base-content/50">
|
||||
Aether chat
|
||||
</p>
|
||||
<h1 class="mt-1 text-3xl font-semibold tracking-tight text-base-content">
|
||||
{channel_title(@chat_request.slug)}
|
||||
</h1>
|
||||
<p class="mt-2 max-w-2xl text-sm leading-6 text-base-content/70">
|
||||
Public synced rooms and NIP-17 direct messages share this chat surface.
|
||||
</p>
|
||||
</div>
|
||||
<.live_component
|
||||
:if={@current_user}
|
||||
module={ChatRecipientPickerComponent}
|
||||
id="aether-chat-recipient-picker"
|
||||
current_user={@current_user}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<.live_component
|
||||
module={ChatPanelComponent}
|
||||
id={@component_id}
|
||||
slug={@chat_request.slug}
|
||||
backend={@chat_request.backend}
|
||||
embedded?={false}
|
||||
current_user={@current_user}
|
||||
session_privkey={@session_privkey}
|
||||
/>
|
||||
</div>
|
||||
</Layouts.app>
|
||||
"""
|
||||
end
|
||||
|
||||
defp channel_request(%{"plugin_path" => path}) when is_binary(path) do
|
||||
case String.split(path, "/", trim: true) do
|
||||
["aether", "chat", "embed", slug | _rest] ->
|
||||
request(:embedded, slug, :public_sync)
|
||||
|
||||
["plugins", "aether", "chat", "embed", slug | _rest] ->
|
||||
request(:embedded, slug, :public_sync)
|
||||
|
||||
["aether", "chat", "marmot", slug | _rest] ->
|
||||
request(:standalone, slug, :marmot)
|
||||
|
||||
["plugins", "aether", "chat", "marmot", slug | _rest] ->
|
||||
request(:standalone, slug, :marmot)
|
||||
|
||||
["aether", "chat", slug | _rest] ->
|
||||
request(:standalone, slug, :public_sync)
|
||||
|
||||
["plugins", "aether", "chat", slug | _rest] ->
|
||||
request(:standalone, slug, :public_sync)
|
||||
|
||||
_other ->
|
||||
request(:standalone, Chat.default_channel_slug(), :public_sync)
|
||||
end
|
||||
end
|
||||
|
||||
defp channel_request(_session),
|
||||
do: request(:standalone, Chat.default_channel_slug(), :public_sync)
|
||||
|
||||
defp request(mode, slug, backend), do: %{mode: mode, slug: slug, backend: backend}
|
||||
|
||||
defp send_unwrapped_message(socket, event) do
|
||||
request = socket.assigns.chat_request
|
||||
|
||||
with {:ok, %TribeOne.TribesPlugin.Aether.Chat.Channel{} = channel} <-
|
||||
Chat.get_channel_by_slug(request.slug, Chat.ash_opts()),
|
||||
backend <- Chat.backend_module(channel.backend),
|
||||
true <- function_exported?(backend, :unwrap_event, 3),
|
||||
{:ok, message} <-
|
||||
backend.unwrap_event(channel, event, session_privkey: socket.assigns[:session_privkey]) do
|
||||
send_update(ChatPanelComponent, id: @component_id, incoming_message: message)
|
||||
end
|
||||
end
|
||||
|
||||
defp recipient_label(%{display_name: display_name})
|
||||
when is_binary(display_name) and display_name != "" do
|
||||
display_name
|
||||
end
|
||||
|
||||
defp recipient_label(%{username: username}) when is_binary(username) and username != "",
|
||||
do: username
|
||||
|
||||
defp recipient_label(%{pubkey_hex: pubkey}), do: String.slice(pubkey, 0, 8) <> "…"
|
||||
|
||||
defp channel_title("general"), do: "General Chat"
|
||||
|
||||
defp channel_title(slug) do
|
||||
slug
|
||||
|> String.replace(["-", "_"], " ")
|
||||
|> String.split(" ", trim: true)
|
||||
|> Enum.map_join(" ", &String.capitalize/1)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,294 @@
|
||||
defmodule TribeOne.TribesPlugin.AetherWeb.ChatPanelComponent do
|
||||
@moduledoc """
|
||||
Reusable chat panel component for standalone and embedded Aether chat surfaces.
|
||||
"""
|
||||
|
||||
use Phoenix.LiveComponent
|
||||
|
||||
alias TribeOne.TribesPlugin.Aether.Chat
|
||||
alias TribeOne.TribesPlugin.Aether.Chat.{Channel, Message}
|
||||
|
||||
@impl true
|
||||
def update(%{incoming_message: %Message{} = message}, socket) do
|
||||
{:ok, put_message(socket, message)}
|
||||
end
|
||||
|
||||
def update(assigns, socket) do
|
||||
slug = Map.fetch!(assigns, :slug)
|
||||
backend = Map.get(assigns, :backend, :public_sync)
|
||||
|
||||
socket =
|
||||
socket
|
||||
|> assign(assigns)
|
||||
|> assign_new(:channel, fn -> nil end)
|
||||
|> assign_new(:messages, fn -> [] end)
|
||||
|> assign_new(:message_ids, fn -> MapSet.new() end)
|
||||
|> assign_new(:message_count, fn -> 0 end)
|
||||
|> assign_new(:subscribed_channel_id, fn -> nil end)
|
||||
|
||||
socket =
|
||||
if reload_channel?(socket, slug, backend) do
|
||||
load_channel(socket, slug, backend)
|
||||
else
|
||||
socket
|
||||
end
|
||||
|
||||
{:ok, maybe_subscribe(socket)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("send_message", %{"message" => %{"body" => body}}, socket) do
|
||||
body = String.trim(body)
|
||||
|
||||
cond do
|
||||
socket.assigns.channel == nil ->
|
||||
{:noreply, put_flash(socket, :error, "Chat channel is unavailable")}
|
||||
|
||||
socket.assigns.current_user == nil ->
|
||||
{:noreply, put_flash(socket, :error, "Sign in to chat")}
|
||||
|
||||
body == "" ->
|
||||
{:noreply, put_flash(socket, :error, "Message cannot be empty")}
|
||||
|
||||
true ->
|
||||
post_message(socket, body)
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<div
|
||||
id="aether-chat-panel"
|
||||
class={[
|
||||
"flex min-h-0 flex-1 flex-col overflow-hidden border border-base-300 bg-base-100 shadow-sm",
|
||||
@embedded? && "h-full rounded-2xl",
|
||||
!@embedded? && "rounded-3xl"
|
||||
]}
|
||||
>
|
||||
<div
|
||||
:if={@embedded?}
|
||||
id="chat-embed-header"
|
||||
class="flex items-center justify-between gap-3 border-b border-base-300 px-4 py-3"
|
||||
>
|
||||
<div>
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.2em] text-base-content/50">Chat</p>
|
||||
<h2 class="text-base font-semibold text-base-content">{if @channel, do: @channel.title, else: "Chat"}</h2>
|
||||
</div>
|
||||
<span class="text-xs text-base-content/60">{@message_count}</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
:if={@backend == :marmot}
|
||||
id="chat-marmot-notice"
|
||||
class="border-b border-warning/30 bg-warning/10 px-4 py-3 text-sm text-warning-content"
|
||||
>
|
||||
Marmot backend scaffold is enabled for this channel. Browser transport, storage, and signing are not active yet.
|
||||
</div>
|
||||
|
||||
<div
|
||||
id="chat-messages"
|
||||
phx-hook="AetherChatScroll"
|
||||
class={[
|
||||
"flex-1 space-y-3 overflow-y-auto p-4",
|
||||
!@embedded? && "min-h-[24rem] sm:p-6",
|
||||
@embedded? && "min-h-0"
|
||||
]}
|
||||
>
|
||||
<div
|
||||
:if={@messages == []}
|
||||
id="chat-empty"
|
||||
class="rounded-box border border-dashed border-base-300 p-8 text-center text-sm text-base-content/60"
|
||||
>
|
||||
No messages yet. Start the conversation.
|
||||
</div>
|
||||
|
||||
<article :for={message <- @messages} id={"chat-message-#{message.id}"} class="aether-chat-message flex gap-3">
|
||||
<div class="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-primary/10 text-sm font-semibold text-primary">
|
||||
{author_initial(message)}
|
||||
</div>
|
||||
<div class="max-w-[min(42rem,85%)] rounded-2xl bg-base-200 px-4 py-3 shadow-sm">
|
||||
<div class="mb-1 flex items-center gap-2 text-xs text-base-content/60">
|
||||
<span class="font-semibold text-base-content/75">{author_label(message)}</span>
|
||||
<span
|
||||
phx-hook="DateTime"
|
||||
id={"chat-time-#{message.id}"}
|
||||
phx-update="ignore"
|
||||
data-timestamp={message_timestamp(message)}
|
||||
data-relative="true"
|
||||
data-format="LLL"
|
||||
>
|
||||
</span>
|
||||
</div>
|
||||
<p class="whitespace-pre-wrap break-words text-sm leading-6 text-base-content">{message.body}</p>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<%= cond do %>
|
||||
<% @backend == :marmot -> %>
|
||||
<div id="chat-marmot-disabled" class="border-t border-base-300 bg-base-100 p-5 text-center text-sm text-base-content/70">
|
||||
Marmot chat is not enabled in the web UI yet.
|
||||
</div>
|
||||
<% @current_user -> %>
|
||||
<form id="chat-message-form" phx-submit="send_message" phx-target={@myself} class="border-t border-base-300 bg-base-100 p-3 sm:p-4">
|
||||
<div class="flex items-end gap-2 rounded-2xl border border-base-300 bg-base-200/60 p-2">
|
||||
<label for="chat-message-body" class="sr-only">Message</label>
|
||||
<textarea
|
||||
id="chat-message-body"
|
||||
name="message[body]"
|
||||
rows="2"
|
||||
class="textarea textarea-ghost min-h-12 flex-1 resize-none bg-transparent focus:outline-none"
|
||||
placeholder={"Message #{if @channel, do: @channel.title, else: "chat"}"}
|
||||
></textarea>
|
||||
<button id="chat-send-button" type="submit" class="btn btn-primary rounded-full">
|
||||
Send
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
<% true -> %>
|
||||
<div id="chat-signed-out" class="border-t border-base-300 bg-base-100 p-5 text-center text-sm text-base-content/70">
|
||||
Sign in to join the chat.
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
defp reload_channel?(socket, slug, backend) do
|
||||
channel = socket.assigns.channel
|
||||
channel == nil || channel.slug != slug || channel.backend != backend
|
||||
end
|
||||
|
||||
defp load_channel(socket, slug, backend) do
|
||||
case Chat.ensure_conversation(%{slug: slug, title: channel_title(slug), backend: backend}) do
|
||||
{:ok, %Channel{} = channel} ->
|
||||
messages = load_messages(channel, socket.assigns)
|
||||
message_ids = messages |> Enum.map(& &1.id) |> MapSet.new()
|
||||
|
||||
socket
|
||||
|> assign(:channel, channel)
|
||||
|> assign(:backend, channel.backend)
|
||||
|> assign(:messages, messages)
|
||||
|> assign(:message_ids, message_ids)
|
||||
|> assign(:message_count, MapSet.size(message_ids))
|
||||
|
||||
{:error, reason} ->
|
||||
socket
|
||||
|> assign(:channel, nil)
|
||||
|> assign(:backend, backend)
|
||||
|> assign(:messages, [])
|
||||
|> assign(:message_ids, MapSet.new())
|
||||
|> assign(:message_count, 0)
|
||||
|> assign(:chat_error, inspect(reason))
|
||||
end
|
||||
end
|
||||
|
||||
defp maybe_subscribe(
|
||||
%{assigns: %{channel: %Channel{id: id}, subscribed_channel_id: id}} = socket
|
||||
) do
|
||||
socket
|
||||
end
|
||||
|
||||
defp maybe_subscribe(%{assigns: %{channel: %Channel{} = channel}} = socket) do
|
||||
if connected?(socket) do
|
||||
_ = Chat.subscribe_conversation(channel, self(), chat_opts(socket.assigns))
|
||||
assign(socket, :subscribed_channel_id, channel.id)
|
||||
else
|
||||
socket
|
||||
end
|
||||
end
|
||||
|
||||
defp maybe_subscribe(socket), do: socket
|
||||
|
||||
defp post_message(socket, body) do
|
||||
user = socket.assigns.current_user
|
||||
|
||||
attrs = %{
|
||||
body: body,
|
||||
author_id: user_attr(user, :id),
|
||||
author_pubkey: user_attr(user, :pubkey_hex) || user_attr(user, :pubkey),
|
||||
metadata: %{"author_name" => user_attr(user, :username)}
|
||||
}
|
||||
|
||||
case Chat.send_message(socket.assigns.channel, attrs, chat_opts(socket.assigns)) do
|
||||
{:ok, message} ->
|
||||
{:noreply, put_message(socket, message)}
|
||||
|
||||
{:error, reason} ->
|
||||
{:noreply, put_flash(socket, :error, "Failed to send message: #{inspect(reason)}")}
|
||||
end
|
||||
end
|
||||
|
||||
defp put_message(socket, %Message{} = message) do
|
||||
if socket.assigns.channel && message.channel_id == socket.assigns.channel.id do
|
||||
append_message(socket, message)
|
||||
else
|
||||
socket
|
||||
end
|
||||
end
|
||||
|
||||
defp append_message(socket, %Message{} = message) do
|
||||
if MapSet.member?(socket.assigns.message_ids, message.id) do
|
||||
socket
|
||||
else
|
||||
message_ids = MapSet.put(socket.assigns.message_ids, message.id)
|
||||
|
||||
socket
|
||||
|> assign(:messages, socket.assigns.messages ++ [message])
|
||||
|> assign(:message_ids, message_ids)
|
||||
|> assign(:message_count, MapSet.size(message_ids))
|
||||
end
|
||||
end
|
||||
|
||||
defp load_messages(%Channel{} = channel, assigns) do
|
||||
case Chat.list_conversation_messages(channel, chat_opts(assigns)) do
|
||||
{:ok, messages} -> messages
|
||||
{:error, _reason} -> []
|
||||
end
|
||||
end
|
||||
|
||||
defp chat_opts(assigns) do
|
||||
case Map.get(assigns, :session_privkey) do
|
||||
privkey when is_binary(privkey) -> [session_privkey: privkey]
|
||||
_other -> []
|
||||
end
|
||||
end
|
||||
|
||||
defp channel_title("general"), do: "General Chat"
|
||||
|
||||
defp channel_title(slug) do
|
||||
slug
|
||||
|> String.replace(["-", "_"], " ")
|
||||
|> String.split(" ", trim: true)
|
||||
|> Enum.map_join(" ", &String.capitalize/1)
|
||||
end
|
||||
|
||||
defp user_attr(nil, _key), do: nil
|
||||
defp user_attr(user, key), do: Map.get(user, key)
|
||||
|
||||
defp author_label(%Message{metadata: %{"author_name" => name}})
|
||||
when is_binary(name) and name != "" do
|
||||
name
|
||||
end
|
||||
|
||||
defp author_label(%Message{author_pubkey: pubkey}) when is_binary(pubkey) and pubkey != "" do
|
||||
String.slice(pubkey, 0, 8) <> "…"
|
||||
end
|
||||
|
||||
defp author_label(_message), do: "Unknown"
|
||||
|
||||
defp author_initial(message) do
|
||||
message
|
||||
|> author_label()
|
||||
|> String.first()
|
||||
|> Kernel.||("?")
|
||||
|> String.upcase()
|
||||
end
|
||||
|
||||
defp message_timestamp(%Message{inserted_at: %DateTime{} = inserted_at}) do
|
||||
DateTime.to_unix(inserted_at, :millisecond)
|
||||
end
|
||||
|
||||
defp message_timestamp(_message), do: DateTime.utc_now() |> DateTime.to_unix(:millisecond)
|
||||
end
|
||||
@@ -0,0 +1,139 @@
|
||||
defmodule TribeOne.TribesPlugin.AetherWeb.ChatRecipientPickerComponent do
|
||||
@moduledoc """
|
||||
Recipient picker exposed as part of Aether's `org.tribe-one.caps.chat@1` UI contract.
|
||||
"""
|
||||
|
||||
use Phoenix.LiveComponent
|
||||
|
||||
alias Tribes.Plugin.Services.Alliance
|
||||
alias Tribes.Plugin.User
|
||||
alias Tribes.Scope
|
||||
|
||||
@impl true
|
||||
def update(assigns, socket) do
|
||||
recipients = Map.get(assigns, :recipients) || load_recipients(Map.get(assigns, :current_user))
|
||||
|
||||
{:ok,
|
||||
socket
|
||||
|> assign(assigns)
|
||||
|> assign_new(:query, fn -> "" end)
|
||||
|> assign(:recipients, recipients)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("search", %{"recipient" => %{"query" => query}}, socket) do
|
||||
{:noreply, assign(socket, :query, String.trim(query || ""))}
|
||||
end
|
||||
|
||||
def handle_event("select", %{"pubkey" => pubkey}, socket) do
|
||||
case Enum.find(socket.assigns.recipients, &(&1.pubkey_hex == pubkey)) do
|
||||
%User{} = user -> send(self(), {:aether_chat, :recipient_selected, user})
|
||||
_other -> :ok
|
||||
end
|
||||
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<details id="chat-recipient-picker" class="dropdown dropdown-end">
|
||||
<summary id="chat-recipient-picker-button" class="btn btn-primary btn-sm">
|
||||
New message
|
||||
</summary>
|
||||
<div class="dropdown-content z-30 mt-2 w-80 rounded-box border border-base-300 bg-base-100 p-3 shadow-xl">
|
||||
<form id="chat-recipient-search-form" phx-change="search" phx-target={@myself}>
|
||||
<label class="input input-sm input-bordered flex items-center gap-2">
|
||||
<span class="text-xs text-base-content/50">To</span>
|
||||
<input
|
||||
id="chat-recipient-search"
|
||||
name="recipient[query]"
|
||||
value={@query}
|
||||
type="search"
|
||||
class="grow"
|
||||
placeholder="Search tribe users"
|
||||
/>
|
||||
</label>
|
||||
</form>
|
||||
|
||||
<div id="chat-recipient-options" class="mt-3 max-h-72 space-y-1 overflow-y-auto">
|
||||
<button
|
||||
:for={recipient <- filtered_recipients(@recipients, @query, @current_user)}
|
||||
id={"chat-recipient-#{recipient.id}"}
|
||||
type="button"
|
||||
class="flex w-full items-center gap-3 rounded-box px-3 py-2 text-left hover:bg-base-200"
|
||||
phx-click="select"
|
||||
phx-target={@myself}
|
||||
phx-value-pubkey={recipient.pubkey_hex}
|
||||
>
|
||||
<span class="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-primary/10 text-sm font-semibold text-primary">
|
||||
{recipient_initial(recipient)}
|
||||
</span>
|
||||
<span class="min-w-0">
|
||||
<span class="block truncate text-sm font-medium text-base-content">{recipient_label(recipient)}</span>
|
||||
<span class="block truncate text-xs text-base-content/50">{recipient_pubkey(recipient)}</span>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<p
|
||||
:if={filtered_recipients(@recipients, @query, @current_user) == []}
|
||||
id="chat-recipient-empty"
|
||||
class="rounded-box border border-dashed border-base-300 p-4 text-center text-sm text-base-content/60"
|
||||
>
|
||||
No matching users.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
"""
|
||||
end
|
||||
|
||||
defp load_recipients(_current_user) do
|
||||
scope = Scope.new()
|
||||
|
||||
with {:ok, tribe} when is_map(tribe) <- Alliance.local_tribe(scope),
|
||||
{:ok, users} <- Alliance.list_tribe_users(scope, tribe.id) do
|
||||
Enum.filter(users, &is_binary(&1.pubkey_hex))
|
||||
else
|
||||
_other -> []
|
||||
end
|
||||
end
|
||||
|
||||
defp filtered_recipients(recipients, query, current_user) do
|
||||
current_pubkey = current_user && current_user.pubkey_hex
|
||||
query = String.downcase(query || "")
|
||||
|
||||
recipients
|
||||
|> Enum.reject(&(&1.pubkey_hex == current_pubkey))
|
||||
|> Enum.filter(&matches_query?(&1, query))
|
||||
end
|
||||
|
||||
defp matches_query?(_recipient, ""), do: true
|
||||
|
||||
defp matches_query?(recipient, query) do
|
||||
[recipient.username, recipient.display_name, recipient.npub, recipient.pubkey_hex]
|
||||
|> Enum.filter(&is_binary/1)
|
||||
|> Enum.any?(&String.contains?(String.downcase(&1), query))
|
||||
end
|
||||
|
||||
defp recipient_label(%User{display_name: display_name})
|
||||
when is_binary(display_name) and display_name != "" do
|
||||
display_name
|
||||
end
|
||||
|
||||
defp recipient_label(%User{username: username}) when is_binary(username) and username != "",
|
||||
do: username
|
||||
|
||||
defp recipient_label(%User{pubkey_hex: pubkey}), do: String.slice(pubkey, 0, 8) <> "…"
|
||||
|
||||
defp recipient_pubkey(%User{npub: npub}) when is_binary(npub) and npub != "", do: npub
|
||||
defp recipient_pubkey(%User{pubkey_hex: pubkey}), do: pubkey
|
||||
|
||||
defp recipient_initial(recipient) do
|
||||
recipient
|
||||
|> recipient_label()
|
||||
|> String.first()
|
||||
|> Kernel.||("?")
|
||||
|> String.upcase()
|
||||
end
|
||||
end
|
||||
@@ -1,9 +1,9 @@
|
||||
defmodule MyPluginWeb.HomeLive do
|
||||
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/my_plugin.
|
||||
at /plugins/aether.
|
||||
"""
|
||||
|
||||
# In dev mode (plugin loaded as path dep), you can use host macros:
|
||||
@@ -22,7 +22,7 @@ defmodule MyPluginWeb.HomeLive do
|
||||
<h1 class="text-2xl font-bold">My Plugin</h1>
|
||||
<p class="mt-2 text-base-content/70">
|
||||
This is a Tribes plugin. Edit this page in
|
||||
<code>lib/my_plugin_web/live/home_live.ex</code>.
|
||||
<code>lib/aether_web/live/home_live.ex</code>.
|
||||
</p>
|
||||
</div>
|
||||
"""
|
||||
@@ -0,0 +1,339 @@
|
||||
defmodule TribeOne.TribesPlugin.AetherWeb.TimelineLive do
|
||||
use Phoenix.LiveView
|
||||
|
||||
on_mount({Tribes.Plugin.LiveUserAuth, :live_user_optional})
|
||||
|
||||
alias Tribes.Plugin.Layouts
|
||||
alias Tribes.Plugin.Nostr.Profile
|
||||
alias Tribes.Plugin.Services.Alliance
|
||||
alias Tribes.Plugin.Services.Nostr
|
||||
alias Tribes.Plugin.Services.Routes
|
||||
alias Tribes.Scope
|
||||
|
||||
import Tribes.Plugin.Gettext
|
||||
|
||||
@impl true
|
||||
def mount(_params, _session, socket) do
|
||||
feed = load_feed()
|
||||
notes = load_notes(feed.member_pubkeys)
|
||||
profile_cache = load_profile_cache(notes)
|
||||
note_ids = notes |> Enum.map(& &1.id) |> MapSet.new()
|
||||
|
||||
socket =
|
||||
socket
|
||||
|> assign(:page_title, "Aether")
|
||||
|> assign(:tribe, feed.tribe)
|
||||
|> assign(:tribe_member_pubkeys, feed.member_pubkeys)
|
||||
|> assign(:profile_cache, profile_cache)
|
||||
|> assign(:note_ids, note_ids)
|
||||
|> assign(:note_count, MapSet.size(note_ids))
|
||||
|> assign(:subscription_ref, nil)
|
||||
|> stream_configure(:notes, dom_id: &("note-" <> &1.id))
|
||||
|> stream(:notes, notes)
|
||||
|
||||
if connected?(socket) do
|
||||
{:ok, ref} = subscribe_notes(feed.member_pubkeys)
|
||||
{:ok, assign(socket, :subscription_ref, ref)}
|
||||
else
|
||||
{:ok, socket}
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("post", %{"note" => %{"content" => content}}, socket) do
|
||||
content = String.trim(content)
|
||||
|
||||
cond do
|
||||
socket.assigns.current_user == nil ->
|
||||
{:noreply, put_flash(socket, :error, "Sign in to post")}
|
||||
|
||||
content == "" ->
|
||||
{:noreply, put_flash(socket, :error, "Post cannot be empty")}
|
||||
|
||||
true ->
|
||||
case socket.assigns[:session_privkey] do
|
||||
privkey when is_binary(privkey) ->
|
||||
case Nostr.publish_note(
|
||||
content,
|
||||
socket.assigns.current_user.pubkey_hex,
|
||||
privkey
|
||||
) do
|
||||
{:ok, event} ->
|
||||
handle_published_note(socket, event)
|
||||
|
||||
{:error, reason} ->
|
||||
{:noreply, put_flash(socket, :error, "Failed to publish: #{inspect(reason)}")}
|
||||
end
|
||||
|
||||
_other ->
|
||||
{:noreply, put_flash(socket, :error, "Signing key unavailable in session")}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_info({:parrhesia, :event, _ref, _sub_id, %{"kind" => 1} = raw_event}, socket) do
|
||||
{:noreply, handle_note_event(socket, raw_event)}
|
||||
end
|
||||
|
||||
def handle_info({:parrhesia, :events, _ref, _sub_id, events}, socket) when is_list(events) do
|
||||
socket =
|
||||
Enum.reduce(events, socket, fn
|
||||
%{"kind" => 1} = raw_event, socket -> handle_note_event(socket, raw_event)
|
||||
_raw_event, socket -> socket
|
||||
end)
|
||||
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
def handle_info({:parrhesia, :event, _ref, _sub_id, _event}, socket), do: {:noreply, socket}
|
||||
def handle_info({:parrhesia, :events, _ref, _sub_id, _events}, socket), do: {:noreply, socket}
|
||||
def handle_info({:parrhesia, :eose, _ref, _sub_id, _hints}, socket), do: {:noreply, socket}
|
||||
def handle_info({:parrhesia, :eose, _ref, _sub_id}, socket), do: {:noreply, socket}
|
||||
def handle_info({:parrhesia, :closed, _ref, _sub_id, _reason}, socket), do: {:noreply, socket}
|
||||
|
||||
@impl true
|
||||
def terminate(_reason, socket) do
|
||||
if socket.assigns[:subscription_ref] do
|
||||
Nostr.unsubscribe(socket.assigns.subscription_ref)
|
||||
end
|
||||
|
||||
:ok
|
||||
end
|
||||
|
||||
defp handle_note_event(socket, raw_event) do
|
||||
case Nostr.to_event(raw_event) do
|
||||
{:ok, event} ->
|
||||
if note_in_scope?(event, socket.assigns.tribe_member_pubkeys) do
|
||||
profile_cache = maybe_put_profile(socket.assigns.profile_cache, event.pubkey)
|
||||
|
||||
if MapSet.member?(socket.assigns.note_ids, event.id) do
|
||||
assign(socket, :profile_cache, profile_cache)
|
||||
else
|
||||
note_ids = MapSet.put(socket.assigns.note_ids, event.id)
|
||||
|
||||
socket
|
||||
|> assign(:profile_cache, profile_cache)
|
||||
|> assign(:note_ids, note_ids)
|
||||
|> assign(:note_count, MapSet.size(note_ids))
|
||||
|> stream_insert(:notes, event, at: 0)
|
||||
end
|
||||
else
|
||||
socket
|
||||
end
|
||||
|
||||
{:error, _reason} ->
|
||||
socket
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<Layouts.app
|
||||
flash={@flash}
|
||||
current_scope={@current_scope}
|
||||
>
|
||||
<div class="space-y-6 p-6" id="aether-page">
|
||||
<section class="rounded-3xl border border-base-300 bg-base-100/95 p-6 shadow-sm">
|
||||
<div class="flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
|
||||
<div class="space-y-2">
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.3em] text-base-content/50">
|
||||
Local feed
|
||||
</p>
|
||||
<h1 class="text-3xl font-semibold tracking-tight text-base-content">
|
||||
{timeline_title(@tribe)}
|
||||
</h1>
|
||||
<p class="max-w-2xl text-sm leading-6 text-base-content/70">
|
||||
{timeline_description(@tribe)}
|
||||
</p>
|
||||
</div>
|
||||
<div class="rounded-full border border-base-300 px-4 py-2 text-sm font-medium text-base-content/70">
|
||||
{@note_count} notes
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<%= if @current_user do %>
|
||||
<form id="note-form" phx-submit="post" class="card bg-base-100 shadow-sm">
|
||||
<div class="card-body">
|
||||
<label class="fieldset mb-2" for="note-content">
|
||||
<span class="label mb-1">Post</span>
|
||||
<textarea
|
||||
id="note-content"
|
||||
name="note[content]"
|
||||
rows="3"
|
||||
class="textarea w-full"
|
||||
placeholder="Share something with your local network"
|
||||
></textarea>
|
||||
</label>
|
||||
<div class="card-actions justify-end">
|
||||
<button id="post-note-button" type="submit" class="btn btn-primary btn-sm">
|
||||
Post note
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
<% else %>
|
||||
<div class="rounded-box border border-dashed border-base-300 bg-base-100 p-6 text-center text-sm text-base-content/70">
|
||||
{tgettext("Sign in")} to publish notes from your local identity.
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<div id="notes" phx-update="stream" class="space-y-3">
|
||||
<div
|
||||
id="notes-empty"
|
||||
class="hidden only:block rounded-box border border-dashed border-base-300 bg-base-100 p-8 text-center text-sm text-base-content/60"
|
||||
>
|
||||
No local notes yet.
|
||||
</div>
|
||||
|
||||
<article :for={{id, note} <- @streams.notes} id={id} class="card bg-base-100 shadow-sm">
|
||||
<div class="card-body gap-2">
|
||||
<div class="flex items-center justify-between gap-4 text-xs text-base-content/60">
|
||||
<a href={Routes.profile_path(note.pubkey)} class="link link-hover font-medium">
|
||||
{author_label(@profile_cache, note.pubkey)}
|
||||
</a>
|
||||
<span
|
||||
phx-hook="DateTime"
|
||||
id={"time-#{note.id}"}
|
||||
phx-update="ignore"
|
||||
data-timestamp={note.created_at * 1000}
|
||||
data-relative="true"
|
||||
data-format="LLL"
|
||||
>
|
||||
</span>
|
||||
</div>
|
||||
<p class="whitespace-pre-wrap text-sm leading-6">{note.content}</p>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
</Layouts.app>
|
||||
"""
|
||||
end
|
||||
|
||||
defp handle_published_note(socket, event) do
|
||||
if note_in_scope?(event, socket.assigns.tribe_member_pubkeys) do
|
||||
note_ids = MapSet.put(socket.assigns.note_ids, event.id)
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:note_ids, note_ids)
|
||||
|> assign(:note_count, MapSet.size(note_ids))
|
||||
|> stream_insert(:notes, event, at: 0)}
|
||||
else
|
||||
{:noreply, put_flash(socket, :info, "Posted outside the local tribe feed")}
|
||||
end
|
||||
end
|
||||
|
||||
defp load_feed do
|
||||
scope = Scope.new()
|
||||
|
||||
with {:ok, tribe} when is_map(tribe) <- Alliance.local_tribe(scope),
|
||||
{:ok, users} <- Alliance.list_tribe_users(scope, tribe.id) do
|
||||
member_pubkeys =
|
||||
users
|
||||
|> Enum.map(& &1.pubkey_hex)
|
||||
|> Enum.filter(&is_binary/1)
|
||||
|> MapSet.new()
|
||||
|
||||
%{tribe: tribe, member_pubkeys: member_pubkeys}
|
||||
else
|
||||
_ -> %{tribe: nil, member_pubkeys: MapSet.new()}
|
||||
end
|
||||
end
|
||||
|
||||
defp load_notes(member_pubkeys) do
|
||||
query_opts =
|
||||
[limit: 100]
|
||||
|> maybe_put_authors(member_pubkeys)
|
||||
|
||||
case Nostr.list_notes(query_opts) do
|
||||
{:ok, notes} ->
|
||||
notes
|
||||
|> filter_notes(member_pubkeys)
|
||||
|> sort_notes()
|
||||
|
||||
{:error, _reason} ->
|
||||
[]
|
||||
end
|
||||
end
|
||||
|
||||
defp subscribe_notes(member_pubkeys) do
|
||||
query_opts =
|
||||
[limit: 100]
|
||||
|> maybe_put_authors(member_pubkeys)
|
||||
|
||||
case Nostr.subscribe_notes(self(), "aether", query_opts) do
|
||||
{:ok, ref} -> {:ok, ref}
|
||||
{:error, _reason} -> {:ok, nil}
|
||||
end
|
||||
end
|
||||
|
||||
defp note_in_scope?(event, member_pubkeys) do
|
||||
MapSet.member?(member_pubkeys, event.pubkey)
|
||||
end
|
||||
|
||||
defp filter_notes(notes, member_pubkeys) do
|
||||
Enum.filter(notes, ¬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,81 +0,0 @@
|
||||
defmodule MyPlugin.Plugin do
|
||||
@moduledoc """
|
||||
Tribes plugin entry point.
|
||||
|
||||
Implements the `Tribes.Plugin` behaviour. This module is referenced by
|
||||
`entry_module` in manifest.json and is called by the host's PluginManager
|
||||
during startup.
|
||||
"""
|
||||
|
||||
# Once Tribes.Plugin.Base is available, replace the manual implementation
|
||||
# with:
|
||||
#
|
||||
# use Tribes.Plugin.Base, otp_app: :my_plugin
|
||||
#
|
||||
# and override only register/1.
|
||||
|
||||
# @behaviour Tribes.Plugin
|
||||
|
||||
def register(_context) do
|
||||
manifest = read_manifest()
|
||||
|
||||
%{
|
||||
name: manifest["name"],
|
||||
version: manifest["version"],
|
||||
provides: manifest["provides"] || [],
|
||||
requires: manifest["requires"] || [],
|
||||
enhances_with: manifest["enhances_with"] || [],
|
||||
nav_items: [
|
||||
%{
|
||||
label: "My Plugin",
|
||||
path: "/plugins/my_plugin",
|
||||
icon: nil,
|
||||
requires: [],
|
||||
order: 50
|
||||
}
|
||||
],
|
||||
pages: [
|
||||
%{
|
||||
path: "/plugins/my_plugin",
|
||||
live_view: MyPluginWeb.HomeLive,
|
||||
layout: nil
|
||||
}
|
||||
],
|
||||
api_routes: [],
|
||||
plugs: [],
|
||||
children: [],
|
||||
global_js: get_in(manifest, ["assets", "global_js"]) || [],
|
||||
global_css: get_in(manifest, ["assets", "global_css"]) || [],
|
||||
migrations_path: migrations_path(manifest),
|
||||
hooks: %{}
|
||||
}
|
||||
end
|
||||
|
||||
defp read_manifest do
|
||||
manifest_path()
|
||||
|> File.read!()
|
||||
|> Jason.decode!()
|
||||
end
|
||||
|
||||
defp manifest_path do
|
||||
# In a release, manifest.json sits alongside ebin/ in the plugin directory.
|
||||
# In dev mode (path dep), it's at the project root.
|
||||
case :code.priv_dir(:my_plugin) do
|
||||
{:error, :bad_name} ->
|
||||
# Dev mode fallback: relative to project root
|
||||
Path.join(__DIR__, "../../../manifest.json") |> Path.expand()
|
||||
|
||||
priv_dir ->
|
||||
priv_dir |> to_string() |> Path.join("../manifest.json") |> Path.expand()
|
||||
end
|
||||
end
|
||||
|
||||
defp migrations_path(manifest) do
|
||||
if manifest["migrations"] do
|
||||
case :code.priv_dir(:my_plugin) do
|
||||
{:error, :bad_name} -> nil
|
||||
priv_dir -> priv_dir |> to_string() |> Path.join("repo/migrations")
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
+21
-9
@@ -1,16 +1,28 @@
|
||||
{
|
||||
"name": "my_plugin",
|
||||
"version": "0.1.0",
|
||||
"description": "TODO: Describe what this plugin does",
|
||||
"entry_module": "MyPlugin.Plugin",
|
||||
"id": "org.tribe-one.plugins.aether",
|
||||
"slug": "aether",
|
||||
"display_name": "Aether",
|
||||
"version": "0.2.0",
|
||||
"description": "Local social feed and chat for Tribes, including NIP-17 direct messages.",
|
||||
"entry_module": "TribeOne.TribesPlugin.Aether.Plugin",
|
||||
"host_api": "1",
|
||||
"provides": [],
|
||||
"requires": ["ecto@1"],
|
||||
"otp_app": "tribe_one_aether",
|
||||
"provides": [
|
||||
"org.tribe-one.caps.social@1",
|
||||
"org.tribe-one.caps.chat@1"
|
||||
],
|
||||
"requires": [
|
||||
"org.tribe-one.caps.ui@1"
|
||||
],
|
||||
"enhances_with": [],
|
||||
"assets": {
|
||||
"global_js": ["my_plugin.js"],
|
||||
"global_css": ["my_plugin.css"]
|
||||
"global_js": [
|
||||
"aether.js"
|
||||
],
|
||||
"global_css": [
|
||||
"aether.css"
|
||||
]
|
||||
},
|
||||
"migrations": false,
|
||||
"migrations": true,
|
||||
"children": false
|
||||
}
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
;; Guix development manifest for the Aether plugin.
|
||||
;;
|
||||
;; Usage:
|
||||
;; ./guix/guix-dev
|
||||
;;
|
||||
;; This intentionally keeps the repo-specific development package list here,
|
||||
;; similar to devenv.nix. Shared custom packages such as node-24, prek, and
|
||||
;; prettier come from the pinned guix-tribes channel.
|
||||
|
||||
(use-modules (gnu packages bash)
|
||||
(gnu packages base)
|
||||
(gnu packages elixir)
|
||||
(gnu packages erlang)
|
||||
(gnu packages gnome)
|
||||
(gnu packages linux)
|
||||
(gnu packages nss)
|
||||
(gnu packages package-management)
|
||||
(gnu packages version-control)
|
||||
(guix build-system trivial)
|
||||
(guix gexp)
|
||||
((guix licenses) #:prefix license:)
|
||||
(guix packages)
|
||||
(guix profiles)
|
||||
(tribes packages devtools)
|
||||
((tribes packages node) #:select (node-24)))
|
||||
|
||||
(define guix-dev-script
|
||||
(local-file "guix/guix-dev" "guix-dev-script"))
|
||||
|
||||
(define guix-dev-command-package
|
||||
(package
|
||||
(name "guix-dev-command")
|
||||
(version "0.1")
|
||||
(source #f)
|
||||
(build-system trivial-build-system)
|
||||
(arguments
|
||||
(list
|
||||
#:modules '((guix build utils))
|
||||
#:builder
|
||||
#~(begin
|
||||
(use-modules (guix build utils))
|
||||
(let* ((bin (string-append #$output "/bin"))
|
||||
(target (string-append bin "/guix-dev")))
|
||||
(mkdir-p bin)
|
||||
(copy-file #$guix-dev-script target)
|
||||
(substitute* target
|
||||
(("^#!/usr/bin/env bash")
|
||||
(string-append "#!" #$(file-append bash-minimal "/bin/bash"))))
|
||||
(chmod target #o555)))))
|
||||
(inputs (list bash-minimal))
|
||||
(home-page "https://git.teralink.net/tribes/tribes-plugin-aether.git")
|
||||
(synopsis "Pinned Guix development shell helper")
|
||||
(description
|
||||
"guix-dev re-enters this checkout's Guix development shell through its
|
||||
repo-local guix/channels.scm time-machine pin and manifest.scm.")
|
||||
(license license:asl2.0)))
|
||||
|
||||
(packages->manifest
|
||||
(list bash
|
||||
coreutils
|
||||
findutils
|
||||
grep
|
||||
sed
|
||||
nss-certs
|
||||
git
|
||||
guix
|
||||
guix-dev-command-package
|
||||
pre-commit
|
||||
prek
|
||||
prettier
|
||||
erlang
|
||||
elixir
|
||||
elixir-hex
|
||||
rebar3
|
||||
node-24
|
||||
libnotify
|
||||
inotify-tools))
|
||||
@@ -1,15 +1,22 @@
|
||||
defmodule MyPlugin.MixProject do
|
||||
defmodule TribeOne.TribesPlugin.Aether.MixProject do
|
||||
use Mix.Project
|
||||
|
||||
def project do
|
||||
[
|
||||
app: :my_plugin,
|
||||
version: "0.1.0",
|
||||
app: :tribe_one_aether,
|
||||
version: "0.2.0",
|
||||
elixir: "~> 1.18",
|
||||
elixirc_paths: elixirc_paths(Mix.env()),
|
||||
start_permanent: Mix.env() == :prod,
|
||||
deps: deps(),
|
||||
aliases: aliases()
|
||||
aliases: aliases(),
|
||||
usage_rules: usage_rules()
|
||||
]
|
||||
end
|
||||
|
||||
def cli do
|
||||
[
|
||||
preferred_envs: [precommit: :test, raw_precommit: :test, raw_test: :test]
|
||||
]
|
||||
end
|
||||
|
||||
@@ -17,7 +24,7 @@ defmodule MyPlugin.MixProject do
|
||||
[
|
||||
extra_applications: [:logger]
|
||||
# Uncomment if your plugin needs its own supervision tree:
|
||||
# mod: {MyPlugin.Application, []}
|
||||
# mod: {TribeOne.TribesPlugin.Aether.Application, []}
|
||||
]
|
||||
end
|
||||
|
||||
@@ -26,21 +33,80 @@ defmodule MyPlugin.MixProject do
|
||||
|
||||
defp deps do
|
||||
[
|
||||
# Host dependency — provides Tribes.Plugin behaviour, types, and test helpers.
|
||||
# Plugin API dependency for local development alongside a tribes checkout.
|
||||
#
|
||||
# For local development alongside a tribes checkout:
|
||||
{:tribes, path: "../tribes", only: [:dev, :test]},
|
||||
#
|
||||
# For CI or standalone development (when not co-located with tribes):
|
||||
# {:tribes, github: "your-org/tribes", branch: "master", only: [:dev, :test]},
|
||||
# For CI or standalone development, this can be replaced with a published
|
||||
# package once tribes_plugin_api is released.
|
||||
{: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"}
|
||||
] ++ tribes_deps(Mix.env())
|
||||
end
|
||||
|
||||
{:jason, "~> 1.2"}
|
||||
defp tribes_deps(:dev), do: [{:tribes, path: "../tribes", only: :dev, runtime: false}]
|
||||
defp tribes_deps(:test), do: [{:tribes, path: "../tribes", only: :test}]
|
||||
defp tribes_deps(_), do: []
|
||||
|
||||
defp usage_rules do
|
||||
[
|
||||
file: "AGENTS.md",
|
||||
usage_rules: [
|
||||
{:usage_rules, sub_rules: []},
|
||||
{"usage_rules:elixir", main: false},
|
||||
{"usage_rules:otp", main: false},
|
||||
"phoenix:ecto",
|
||||
"phoenix:html",
|
||||
"phoenix:liveview",
|
||||
"phoenix:phoenix",
|
||||
{:ash, sub_rules: []},
|
||||
{"ash:actions", link: :markdown, main: false},
|
||||
{"ash:migrations", link: :markdown, main: false},
|
||||
{"ash:testing", link: :markdown, main: false},
|
||||
{:tribes_plugin_api, sub_rules: []},
|
||||
{:tribes, sub_rules: []}
|
||||
]
|
||||
]
|
||||
end
|
||||
|
||||
defp aliases do
|
||||
[
|
||||
test: ["test"]
|
||||
test: &plugin_test_message/1,
|
||||
raw_test: &raw_test/1,
|
||||
lint: ["format --check-formatted", "credo"],
|
||||
precommit: &plugin_precommit_message/1,
|
||||
raw_precommit: &raw_precommit/1
|
||||
]
|
||||
end
|
||||
|
||||
defp plugin_test_message(_args) do
|
||||
Mix.raise("""
|
||||
This plugin test suite is host-backed. Use `plugin test` or `scripts/plugin test`.
|
||||
|
||||
For low-level debugging only, set up the host environment yourself and run `mix raw_test`.
|
||||
""")
|
||||
end
|
||||
|
||||
defp plugin_precommit_message(_args) do
|
||||
Mix.raise("""
|
||||
This plugin precommit is host-backed. Use `plugin precommit` or `scripts/plugin precommit`.
|
||||
|
||||
For low-level debugging only, set up the host environment yourself and run `mix raw_precommit`.
|
||||
""")
|
||||
end
|
||||
|
||||
defp raw_precommit(args) do
|
||||
Mix.Task.run("format")
|
||||
Mix.Task.run("compile", ["--warnings-as-errors"])
|
||||
Mix.Task.run("credo", ["--strict", "--all"])
|
||||
Mix.Task.run("deps.unlock", ["--unused"])
|
||||
raw_test(args)
|
||||
end
|
||||
|
||||
defp raw_test(args), do: Mix.Tasks.Test.run(args)
|
||||
end
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
%{
|
||||
"absinthe": {:hex, :absinthe, "1.9.1", "19fe8614d5cdabefaf127ee224cb89eceea48314de4d709737451b43b5bdedd5", [:mix], [{:dataloader, "~> 1.0.0 or ~> 2.0", [hex: :dataloader, repo: "hexpm", optional: true]}, {:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}, {:nimble_parsec, "~> 1.2.2 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}, {:opentelemetry_process_propagator, "~> 0.2.1 or ~> 0.3", [hex: :opentelemetry_process_propagator, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "d93e1aa61d68b974f48d5660104cb911ae045ee3a5d69954d251f91f3dbe2077"},
|
||||
"absinthe_phoenix": {:hex, :absinthe_phoenix, "2.0.4", "f36999412fbd6a2339abb5b7e24a4cc9492bbc7909d5806deeef83b06f55c508", [:mix], [{:absinthe, "~> 1.5", [hex: :absinthe, repo: "hexpm", optional: false]}, {:absinthe_plug, "~> 1.5", [hex: :absinthe_plug, repo: "hexpm", optional: false]}, {:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.5", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.13 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.0", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}], "hexpm", "66617ee63b725256ca16264364148b10b19e2ecb177488cd6353584f2e6c1cf3"},
|
||||
"absinthe_plug": {:hex, :absinthe_plug, "1.5.9", "4f66fd46aecf969b349dd94853e6132db6d832ae6a4b951312b6926ad4ee7ca3", [:mix], [{:absinthe, "~> 1.7", [hex: :absinthe, repo: "hexpm", optional: false]}, {:plug, "~> 1.4", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "dcdc84334b0e9e2cd439bd2653678a822623f212c71088edf0a4a7d03f1fa225"},
|
||||
"argon2_elixir": {:hex, :argon2_elixir, "4.1.3", "4f28318286f89453364d7fbb53e03d4563fd7ed2438a60237eba5e426e97785f", [:make, :mix], [{:comeonin, "~> 5.3", [hex: :comeonin, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "7c295b8d8e0eaf6f43641698f962526cdf87c6feb7d14bd21e599271b510608c"},
|
||||
"ash": {:hex, :ash, "3.21.3", "4cb8b05655664fe65daf6862285f0a6f91991f1a73ff6554b4609a484c1ceb8b", [:mix], [{:crux, ">= 0.1.2 and < 1.0.0-0", [hex: :crux, repo: "hexpm", optional: false]}, {:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:ecto, "~> 3.7", [hex: :ecto, repo: "hexpm", optional: false]}, {:ets, "~> 0.8", [hex: :ets, repo: "hexpm", optional: false]}, {:igniter, ">= 0.6.29 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, ">= 1.0.0", [hex: :jason, repo: "hexpm", optional: false]}, {:picosat_elixir, "~> 0.2", [hex: :picosat_elixir, repo: "hexpm", optional: true]}, {:plug, ">= 0.0.0", [hex: :plug, repo: "hexpm", optional: true]}, {:reactor, "~> 1.0", [hex: :reactor, repo: "hexpm", optional: false]}, {:simple_sat, ">= 0.1.1 and < 1.0.0-0", [hex: :simple_sat, repo: "hexpm", optional: true]}, {:spark, ">= 2.6.0", [hex: :spark, repo: "hexpm", optional: false]}, {:splode, "~> 0.3", [hex: :splode, repo: "hexpm", optional: false]}, {:stream_data, "~> 1.0", [hex: :stream_data, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.1", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "deec4223f11c7cbf3b35cb14b3c8df50b58b06a5c9f64de539a93469fa249b04"},
|
||||
"ash_authentication": {:hex, :ash_authentication, "4.13.7", "421b5ddb516026f6794435980a632109ec116af2afa68a45e15fb48b41c92cfa", [:mix], [{:argon2_elixir, "~> 4.0", [hex: :argon2_elixir, repo: "hexpm", optional: true]}, {:ash, "~> 3.7", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_postgres, ">= 2.6.8 and < 3.0.0-0", [hex: :ash_postgres, repo: "hexpm", optional: true]}, {:assent, "> 0.2.0 and < 0.3.0", [hex: :assent, repo: "hexpm", optional: false]}, {:bcrypt_elixir, "~> 3.0", [hex: :bcrypt_elixir, repo: "hexpm", optional: false]}, {:castore, "~> 1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:finch, "~> 0.19", [hex: :finch, repo: "hexpm", optional: false]}, {:igniter, "~> 0.4", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:joken, "~> 2.5", [hex: :joken, repo: "hexpm", optional: false]}, {:plug, "~> 1.13", [hex: :plug, repo: "hexpm", optional: false]}, {:spark, "~> 2.0", [hex: :spark, repo: "hexpm", optional: false]}, {:splode, "~> 0.2", [hex: :splode, repo: "hexpm", optional: false]}], "hexpm", "0d45ac3fdcca6902dabbe161ce63e9cea8f90583863c2e14261c9309e5837121"},
|
||||
"ash_authentication_phoenix": {:hex, :ash_authentication_phoenix, "2.15.0", "89e71e96a3d954aed7ed0c1f511d42cbfd19009b813f580b12749b01bbea5148", [:mix], [{:ash, "~> 3.0", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_authentication, "~> 4.10", [hex: :ash_authentication, repo: "hexpm", optional: false]}, {:ash_phoenix, ">= 2.3.11 and < 3.0.0-0", [hex: :ash_phoenix, repo: "hexpm", optional: false]}, {:bcrypt_elixir, "~> 3.0", [hex: :bcrypt_elixir, repo: "hexpm", optional: false]}, {:gettext, "~> 0.26 or ~> 1.0", [hex: :gettext, repo: "hexpm", optional: true]}, {:igniter, ">= 0.5.25 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.6", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_html_helpers, "~> 1.0", [hex: :phoenix_html_helpers, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 1.1", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: false]}, {:slugify, "~> 1.3", [hex: :slugify, repo: "hexpm", optional: false]}], "hexpm", "d2da66dcf62bc1054ce8f5d9c2829b1dff1dbc3f1d03f9ef0cbe89123d7df107"},
|
||||
"ash_graphql": {:hex, :ash_graphql, "1.9.3", "02d5a8e34a9fb22267513dab6e91380d5aaaffb9d8403d78a26cf1eba655f743", [:mix], [{:absinthe, "~> 1.7", [hex: :absinthe, repo: "hexpm", optional: false]}, {:absinthe_phoenix, "~> 2.0", [hex: :absinthe_phoenix, repo: "hexpm", optional: true]}, {:absinthe_plug, "~> 1.4", [hex: :absinthe_plug, repo: "hexpm", optional: false]}, {:ash, ">= 3.5.13 and < 4.0.0-0", [hex: :ash, repo: "hexpm", optional: false]}, {:igniter, ">= 0.5.28 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.2", [hex: :jason, repo: "hexpm", optional: false]}, {:spark, ">= 2.2.10", [hex: :spark, repo: "hexpm", optional: false]}], "hexpm", "dc3a8dbab4858a655c3f58813f1e1d8f815886d8eb0a9536601c52ceddbab521"},
|
||||
"ash_phoenix": {:hex, :ash_phoenix, "2.3.20", "022682396892046f48dc35a137bbea9c1e4c6a6d58e71d795defd2f071c3b138", [:mix], [{:ash, ">= 3.5.13 and < 4.0.0-0", [hex: :ash, repo: "hexpm", optional: false]}, {:igniter, "~> 0.6", [hex: :igniter, repo: "hexpm", optional: true]}, {:inertia, "~> 2.3", [hex: :inertia, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.5.6 or ~> 1.6", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.20.3 or ~> 1.0-rc.1", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:spark, ">= 2.2.29 and < 3.0.0-0", [hex: :spark, repo: "hexpm", optional: false]}], "hexpm", "0655a90b042a5e8873b32ba2f0b52c7c9b8da0fd415518bef41ac03a7b07e02e"},
|
||||
"ash_postgres": {:hex, :ash_postgres, "2.8.0", "bf1a30c57b15ae3622f1cea34959b3a11c33f0f4319830dd28235a3c0c79f647", [:mix], [{:ash, "~> 3.19", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_sql, ">= 0.4.3 and < 1.0.0-0", [hex: :ash_sql, repo: "hexpm", optional: false]}, {:ecto, "~> 3.13", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.13", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:igniter, ">= 0.6.29 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:postgrex, ">= 0.0.0", [hex: :postgrex, repo: "hexpm", optional: false]}, {:spark, ">= 2.3.4 and < 3.0.0-0", [hex: :spark, repo: "hexpm", optional: false]}], "hexpm", "06cd4278ccc838104fdbc92e0f9508cf75c4f3376a922ec7e6d85bc7192d616d"},
|
||||
"ash_sql": {:hex, :ash_sql, "0.5.1", "f4fcaa29308bdf417fe85373e57fb6d868b7db1e7ccc2fae5cc7f8f92016d622", [:mix], [{:ash, "~> 3.7", [hex: :ash, repo: "hexpm", optional: false]}, {:ecto, ">= 3.13.4 and < 4.0.0-0", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.9", [hex: :ecto_sql, repo: "hexpm", optional: false]}], "hexpm", "33febc8a84443683c3685a2d65f674391d8f21c073555d7c22d6c484f72faa65"},
|
||||
"assent": {:hex, :assent, "0.2.13", "11226365d2d8661d23e9a2cf94d3255e81054ff9d88ac877f28bfdf38fa4ef31", [:mix], [{:certifi, ">= 0.0.0", [hex: :certifi, repo: "hexpm", optional: true]}, {:finch, "~> 0.15", [hex: :finch, repo: "hexpm", optional: true]}, {:jose, "~> 1.8", [hex: :jose, repo: "hexpm", optional: true]}, {:mint, "~> 1.0", [hex: :mint, repo: "hexpm", optional: true]}, {:req, "~> 0.4", [hex: :req, repo: "hexpm", optional: true]}, {:ssl_verify_fun, ">= 0.0.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: true]}], "hexpm", "bf9f351b01dd6bceea1d1f157f05438f6765ce606e6eb8d29296003d29bf6eab"},
|
||||
"bandit": {:hex, :bandit, "1.11.1", "1eb33123cc3c17ae0c3447874eb83399ee530f960c39711ed240342fbd4865fa", [:mix], [{:hpax, "~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.18", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.0", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "d4401016df9abbc6dcd325c0b78b2b193e7c7c96bb68f31e576112be025d84a5"},
|
||||
"bcrypt_elixir": {:hex, :bcrypt_elixir, "3.3.2", "d50091e3c9492d73e17fc1e1619a9b09d6a5ef99160eb4d736926fd475a16ca3", [:make, :mix], [{:comeonin, "~> 5.3", [hex: :comeonin, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "471be5151874ae7931911057d1467d908955f93554f7a6cd1b7d804cac8cef53"},
|
||||
"bech32": {:hex, :bech32, "1.0.0", "85a3bb58c408d735b5becb39e8a23536660ec0df1ef0afee72377c130939de1b", [:mix], [], "hexpm", "f781b8524c30a524922613d97c1858c27bd9f639b4e6b350f4a4843ee97607d3"},
|
||||
"bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"},
|
||||
"castore": {:hex, :castore, "1.0.19", "6903cabdfd9d1af46454126e7c8385186659dd33ecfb74a885cae52221ad6109", [:mix], [], "hexpm", "3669e6cab13f54c2df26b3e6833745d647f35b6e30d8ddd5975df0d5c842ca98"},
|
||||
"cc_precompiler": {:hex, :cc_precompiler, "0.1.11", "8c844d0b9fb98a3edea067f94f616b3f6b29b959b6b3bf25fee94ffe34364768", [:mix], [{:elixir_make, "~> 0.7", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "3427232caf0835f94680e5bcf082408a70b48ad68a5f5c0b02a3bea9f3a075b9"},
|
||||
"cldr_utils": {:hex, :cldr_utils, "2.29.5", "f43161e04acb4016f5841b2320d69120d51827f5346babb2227893a2c5916dc8", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:certifi, "~> 2.5", [hex: :certifi, repo: "hexpm", optional: true]}, {:decimal, "~> 1.9 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}], "hexpm", "962d3a2028b232ee0a5373941dc411028a9442f53444a4d5d2c354f687db1835"},
|
||||
"comeonin": {:hex, :comeonin, "5.5.1", "5113e5f3800799787de08a6e0db307133850e635d34e9fab23c70b6501669510", [:mix], [], "hexpm", "65aac8f19938145377cee73973f192c5645873dcf550a8a6b18187d17c13ccdb"},
|
||||
"cowboy": {:hex, :cowboy, "2.15.0", "9cfe86ed7117bf045e10adbedb0170af7be57f2a3637e7be143433d8dd267396", [:make, :rebar3], [{:cowlib, ">= 2.16.0 and < 3.0.0", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, ">= 1.8.0 and < 3.0.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "179fb65140fb440a17b767ad53b755081506f9596c4db5c49c0396d8c8643668"},
|
||||
"cowboy_telemetry": {:hex, :cowboy_telemetry, "0.4.0", "f239f68b588efa7707abce16a84d0d2acf3a0f50571f8bb7f56a15865aae820c", [:rebar3], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7d98bac1ee4565d31b62d59f8823dfd8356a169e7fcbb83831b8a5397404c9de"},
|
||||
"cowlib": {:hex, :cowlib, "2.16.1", "318d385d55f657e9a5005838c4e426e13dcd724a691438384b6165a69687e531", [:make, :rebar3], [], "hexpm", "58f1e425a9e04176f1d30e20116f57c4e90ef0e187552e9741c465bdf4044f70"},
|
||||
"credo": {:hex, :credo, "1.7.18", "5c5596bf7aedf9c8c227f13272ac499fe8eae6237bd326f2f07dfc173786f042", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "a189d164685fd945809e862fe76a7420c4398fa288d76257662aecb909d6b3e5"},
|
||||
"crux": {:hex, :crux, "0.1.2", "4441c9e3a34f1e340954ce96b9ad5a2de13ceb4f97b3f910211227bb92e2ca90", [:mix], [{:picosat_elixir, "~> 0.2", [hex: :picosat_elixir, repo: "hexpm", optional: true]}, {:simple_sat, ">= 0.1.1 and < 1.0.0-0", [hex: :simple_sat, repo: "hexpm", optional: true]}, {:stream_data, "~> 1.0", [hex: :stream_data, repo: "hexpm", optional: true]}], "hexpm", "563ea3748ebfba9cc078e6d198a1d6a06015a8fae503f0b721363139f0ddb350"},
|
||||
"db_connection": {:hex, :db_connection, "2.10.1", "d5465f6bcc125c1b8981c1dbf23c193ca16f446ec0b25832dc174f74f18be510", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "18ed94c6e627b4bf452dbd4df61b69a35a1e768525140bc1917b7a685026a6a3"},
|
||||
"decimal": {:hex, :decimal, "2.4.1", "6c0fbede12fb122ba685e9ab41c6a40c129e322b3aa192f9e072e61f3a6ffaf2", [:mix], [], "hexpm", "7e618897933a8455f19a727d7c5e50a2c071a544b700e5e724298ecb4340187f"},
|
||||
"digital_token": {:hex, :digital_token, "1.0.0", "454a4444061943f7349a51ef74b7fb1ebd19e6a94f43ef711f7dae88c09347df", [:mix], [{:cldr_utils, "~> 2.17", [hex: :cldr_utils, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm", "8ed6f5a8c2fa7b07147b9963db506a1b4c7475d9afca6492136535b064c9e9e6"},
|
||||
"dns_cluster": {:hex, :dns_cluster, "0.2.0", "aa8eb46e3bd0326bd67b84790c561733b25c5ba2fe3c7e36f28e88f384ebcb33", [:mix], [], "hexpm", "ba6f1893411c69c01b9e8e8f772062535a4cf70f3f35bcc964a324078d8c8240"},
|
||||
"ecto": {:hex, :ecto, "3.13.6", "352135b474f91d1ab99a1b502171d207e9db60421c9e3d0ecab4c7ab96b24d14", [:mix], [{:decimal, "~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "8afa059bc16cd2c94739ec0a11e3e5df69d828125119109bef35f20a21a76af2"},
|
||||
"ecto_sql": {:hex, :ecto_sql, "3.13.5", "2f8282b2ad97bf0f0d3217ea0a6fff320ead9e2f8770f810141189d182dc304e", [:mix], [{:db_connection, "~> 2.4.1 or ~> 2.5", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.13.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.7", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.19 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "aa36751f4e6a2b56ae79efb0e088042e010ff4935fc8684e74c23b1f49e25fdc"},
|
||||
"elixir_make": {:hex, :elixir_make, "0.9.0", "6484b3cd8c0cee58f09f05ecaf1a140a8c97670671a6a0e7ab4dc326c3109726", [:mix], [], "hexpm", "db23d4fd8b757462ad02f8aa73431a426fe6671c80b200d9710caf3d1dd0ffdb"},
|
||||
"esbuild": {:hex, :esbuild, "0.10.0", "b0aa3388a1c23e727c5a3e7427c932d89ee791746b0081bbe56103e9ef3d291f", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "468489cda427b974a7cc9f03ace55368a83e1a7be12fba7e30969af78e5f8c70"},
|
||||
"ets": {:hex, :ets, "0.9.0", "79c6a6c205436780486f72d84230c6cba2f8a9920456750ddd1e47389107d5fd", [:mix], [], "hexpm", "2861fdfb04bcaeff370f1a5904eec864f0a56dcfebe5921ea9aadf2a481c822b"},
|
||||
"ex_cldr": {:hex, :ex_cldr, "2.47.2", "c866f4b45523abd25eea3e5252eb91364296dd15bddf970db1c78cd38f25df9a", [:mix], [{:cldr_utils, "~> 2.29", [hex: :cldr_utils, repo: "hexpm", optional: false]}, {:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:gettext, "~> 0.19 or ~> 1.0", [hex: :gettext, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:nimble_parsec, "~> 0.5 or ~> 1.0", [hex: :nimble_parsec, repo: "hexpm", optional: true]}], "hexpm", "4a7cef380a1c2546166b45d6ee5e8e2f707ea695b12ae6dadd250201588b4f16"},
|
||||
"ex_cldr_calendars": {:hex, :ex_cldr_calendars, "2.4.2", "31d5d7a65add622dc4bf16fc1bfe5ef378d71cfdaf02fb33c7d0742c585f6117", [:mix], [{:calendar_interval, "~> 0.2", [hex: :calendar_interval, repo: "hexpm", optional: true]}, {:ex_cldr_lists, "~> 2.10", [hex: :ex_cldr_lists, repo: "hexpm", optional: true]}, {:ex_cldr_numbers, "~> 2.36", [hex: :ex_cldr_numbers, repo: "hexpm", optional: false]}, {:ex_cldr_units, "~> 3.20", [hex: :ex_cldr_units, repo: "hexpm", optional: true]}, {:ex_doc, "~> 0.21", [hex: :ex_doc, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "ab69fd04bc1ae18baf9d2e57335d4754c5ac263076ea397eb112621702251fe5"},
|
||||
"ex_cldr_currencies": {:hex, :ex_cldr_currencies, "2.17.1", "89947c7102ff1b46fc46095624239a1c3d72499b19ed650597630771d9e4a662", [:mix], [{:ex_cldr, "~> 2.38", [hex: :ex_cldr, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm", "e266a0a61f4c7d83608154d49b59e4d7485b2aaa7ba1d0e17b3c55910595de51"},
|
||||
"ex_cldr_dates_times": {:hex, :ex_cldr_dates_times, "2.25.6", "6db974ab2b430b5733994c2bfbe98a69e25eeb076b876a929791ff521f8fdd96", [:mix], [{:calendar_interval, "~> 0.2", [hex: :calendar_interval, repo: "hexpm", optional: true]}, {:ex_cldr_calendars, "~> 2.4", [hex: :ex_cldr_calendars, repo: "hexpm", optional: false]}, {:ex_cldr_units, "~> 3.20", [hex: :ex_cldr_units, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:tz, "~> 0.26", [hex: :tz, repo: "hexpm", optional: true]}], "hexpm", "926ff5662b849f86088832ee66b61a96aab0fa5a54d5e14240e08ad3030663e2"},
|
||||
"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"},
|
||||
"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"},
|
||||
"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"},
|
||||
"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"},
|
||||
"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"},
|
||||
"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_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_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"},
|
||||
"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"},
|
||||
"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"},
|
||||
"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"},
|
||||
"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"},
|
||||
"telemetry_poller": {:hex, :telemetry_poller, "1.3.0", "d5c46420126b5ac2d72bc6580fb4f537d35e851cc0f8dbd571acf6d6e10f5ec7", [:rebar3], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "51f18bed7128544a50f75897db9974436ea9bfba560420b646af27a9a9b35211"},
|
||||
"text_diff": {:hex, :text_diff, "0.1.0", "1caf3175e11a53a9a139bc9339bd607c47b9e376b073d4571c031913317fecaa", [:mix], [], "hexpm", "d1ffaaecab338e49357b6daa82e435f877e0649041ace7755583a0ea3362dbd7"},
|
||||
"thousand_island": {:hex, :thousand_island, "1.4.3", "2158209580f633be38d43ec4e3ce0a01079592b9657afff9080d5d8ca149a3af", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "6e4ce09b0fd761a58594d02814d40f77daff460c48a7354a15ab353bb998ea0b"},
|
||||
"unicode_util_compat": {:hex, :unicode_util_compat, "0.7.1", "a48703a25c170eedadca83b11e88985af08d35f37c6f664d6dcfb106a97782fc", [:rebar3], [], "hexpm", "b3a917854ce3ae233619744ad1e0102e05673136776fb2fa76234f3e03b23642"},
|
||||
"usage_rules": {:hex, :usage_rules, "1.2.6", "a7b3f8d6e5d265701139d5714749c37c54bb82230a4c51ec54a12a1e4769b9d1", [:mix], [{:igniter, ">= 0.6.6 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}], "hexpm", "608411b9876a16a9d62a427dbaf42faf458e4cd0a508b3bd7e5ee71502073582"},
|
||||
"websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"},
|
||||
"websock_adapter": {:hex, :websock_adapter, "0.5.9", "43dc3ba6d89ef5dec5b1d0a39698436a1e856d000d84bf31a3149862b01a287f", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "5534d5c9adad3c18a0f58a9371220d75a803bf0b9a3d87e6fe072faaeed76a08"},
|
||||
"websockex": {:hex, :websockex, "0.5.1", "9de28d37bbe34f371eb46e29b79c94c94fff79f93c960d842fbf447253558eb4", [:mix], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "8ef39576ed56bc3804c9cd8626f8b5d6b5721848d2726c0ccd4f05385a3c9f14"},
|
||||
"yamerl": {:hex, :yamerl, "0.10.0", "4ff81fee2f1f6a46f1700c0d880b24d193ddb74bd14ef42cb0bcf46e81ef2f8e", [:rebar3], [], "hexpm", "346adb2963f1051dc837a2364e4acf6eb7d80097c0f53cbdc3046ec8ec4b4e6e"},
|
||||
"yaml_elixir": {:hex, :yaml_elixir, "2.12.1", "d74f2d82294651b58dac849c45a82aaea639766797359baff834b64439f6b3f4", [:mix], [{:yamerl, "~> 0.10", [hex: :yamerl, repo: "hexpm", optional: false]}], "hexpm", "d9ac16563c737d55f9bfeed7627489156b91268a3a21cd55c54eb2e335207fed"},
|
||||
"ymlr": {:hex, :ymlr, "5.1.5", "0b9207c7940be3f2bc29b77cd55109d5aa2f4dcde6575942017335769e6f5628", [:mix], [], "hexpm", "7030cb240c46850caeb3b01be745307632be319b15f03083136f6251f49b516d"},
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
--- a/command.ts
|
||||
+++ b/command.ts
|
||||
@@ -3,8 +3,6 @@ import { toText as streamToText } from "@std/streams";
|
||||
import type { ArgumentValue } from "@cliffy/command";
|
||||
import { Command, ValidationError } from "@cliffy/command";
|
||||
import { CompletionsCommand } from "@cliffy/command/completions";
|
||||
-import { UpgradeCommand } from "@cliffy/command/upgrade";
|
||||
-import { JsrProvider } from "@cliffy/command/upgrade/provider/jsr";
|
||||
|
||||
import type { AllEventsIterOptions, FetchFilter, FetchTimeRangeFilter } from "nostr-fetch";
|
||||
import * as nip19 from "nostr-tools/nip19";
|
||||
@@ -29,18 +27,6 @@ export const nosdumpCommand = new Command()
|
||||
.description("A tool to dump events stored in Nostr relays")
|
||||
.usage("[options...] <relays...>")
|
||||
.command("completions", new CompletionsCommand())
|
||||
- .command(
|
||||
- "upgrade",
|
||||
- new UpgradeCommand({
|
||||
- provider: [
|
||||
- new JsrProvider({
|
||||
- scope: "jiftechnify",
|
||||
- package: "@jiftechnify/nosdump",
|
||||
- }),
|
||||
- ],
|
||||
- args: ["--allow-all"], // grant all permissions while upgrading
|
||||
- }),
|
||||
- )
|
||||
.command("relay-alias", relayAliasCommand).alias("alias")
|
||||
.command("relay-set", relaySetCommand).alias("rset")
|
||||
.reset()
|
||||
@@ -0,0 +1,49 @@
|
||||
{
|
||||
lib,
|
||||
stdenv,
|
||||
fetchFromGitHub,
|
||||
deno,
|
||||
makeWrapper,
|
||||
}: let
|
||||
pname = "nosdump";
|
||||
version = "0.7.1";
|
||||
|
||||
src = fetchFromGitHub {
|
||||
owner = "jiftechnify";
|
||||
repo = "nosdump";
|
||||
rev = version;
|
||||
hash = "sha256-sn6KUZWA2Y65Y/f1J718pT/a2BvX1Vr1LMe5RL4KtrM=";
|
||||
};
|
||||
in
|
||||
stdenv.mkDerivation {
|
||||
inherit pname version src;
|
||||
|
||||
nativeBuildInputs = [makeWrapper];
|
||||
|
||||
patches = [./nosdump-no-upgrade.patch];
|
||||
|
||||
installPhase = ''
|
||||
runHook preInstall
|
||||
mkdir -p "$out/share/${pname}" "$out/bin"
|
||||
cp -R . "$out/share/${pname}/"
|
||||
makeWrapper ${deno}/bin/deno "$out/bin/nosdump" \
|
||||
--add-flags "run" \
|
||||
--add-flags "--frozen" \
|
||||
--add-flags "--no-check" \
|
||||
--add-flags "--allow-env" \
|
||||
--add-flags "--allow-net" \
|
||||
--add-flags "--allow-read" \
|
||||
--add-flags "--allow-write" \
|
||||
--add-flags "--allow-sys" \
|
||||
--add-flags "$out/share/${pname}/mod.ts"
|
||||
runHook postInstall
|
||||
'';
|
||||
|
||||
meta = with lib; {
|
||||
description = "Dump events from Nostr relays";
|
||||
homepage = "https://github.com/jiftechnify/nosdump";
|
||||
license = licenses.mit;
|
||||
mainProgram = "nosdump";
|
||||
platforms = platforms.linux ++ platforms.darwin;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
defmodule Tribes.Repo.Migrations.AddChatExtensions1 do
|
||||
@moduledoc """
|
||||
Installs any extensions that are mentioned in the repo's `installed_extensions/0` callback
|
||||
|
||||
This file was autogenerated with `mix ash_postgres.generate_migrations`
|
||||
"""
|
||||
|
||||
use Ecto.Migration
|
||||
|
||||
def up do
|
||||
execute("""
|
||||
CREATE OR REPLACE FUNCTION ash_elixir_or(left BOOLEAN, in right ANYCOMPATIBLE, out f1 ANYCOMPATIBLE)
|
||||
AS $$ SELECT COALESCE(NULLIF($1, FALSE), $2) $$
|
||||
LANGUAGE SQL
|
||||
SET search_path = ''
|
||||
IMMUTABLE;
|
||||
""")
|
||||
|
||||
execute("""
|
||||
CREATE OR REPLACE FUNCTION ash_elixir_or(left ANYCOMPATIBLE, in right ANYCOMPATIBLE, out f1 ANYCOMPATIBLE)
|
||||
AS $$ SELECT COALESCE($1, $2) $$
|
||||
LANGUAGE SQL
|
||||
SET search_path = ''
|
||||
IMMUTABLE;
|
||||
""")
|
||||
|
||||
execute("""
|
||||
CREATE OR REPLACE FUNCTION ash_elixir_and(left BOOLEAN, in right ANYCOMPATIBLE, out f1 ANYCOMPATIBLE) AS $$
|
||||
SELECT CASE
|
||||
WHEN $1 IS TRUE THEN $2
|
||||
ELSE $1
|
||||
END $$
|
||||
LANGUAGE SQL
|
||||
SET search_path = ''
|
||||
IMMUTABLE;
|
||||
""")
|
||||
|
||||
execute("""
|
||||
CREATE OR REPLACE FUNCTION ash_elixir_and(left ANYCOMPATIBLE, in right ANYCOMPATIBLE, out f1 ANYCOMPATIBLE) AS $$
|
||||
SELECT CASE
|
||||
WHEN $1 IS NOT NULL THEN $2
|
||||
ELSE $1
|
||||
END $$
|
||||
LANGUAGE SQL
|
||||
SET search_path = ''
|
||||
IMMUTABLE;
|
||||
""")
|
||||
|
||||
execute("""
|
||||
CREATE OR REPLACE FUNCTION ash_trim_whitespace(arr text[])
|
||||
RETURNS text[] AS $$
|
||||
DECLARE
|
||||
start_index INT = 1;
|
||||
end_index INT = array_length(arr, 1);
|
||||
BEGIN
|
||||
WHILE start_index <= end_index AND arr[start_index] = '' LOOP
|
||||
start_index := start_index + 1;
|
||||
END LOOP;
|
||||
|
||||
WHILE end_index >= start_index AND arr[end_index] = '' LOOP
|
||||
end_index := end_index - 1;
|
||||
END LOOP;
|
||||
|
||||
IF start_index > end_index THEN
|
||||
RETURN ARRAY[]::text[];
|
||||
ELSE
|
||||
RETURN arr[start_index : end_index];
|
||||
END IF;
|
||||
END; $$
|
||||
LANGUAGE plpgsql
|
||||
SET search_path = ''
|
||||
IMMUTABLE;
|
||||
""")
|
||||
|
||||
execute("""
|
||||
CREATE OR REPLACE FUNCTION ash_raise_error(json_data jsonb)
|
||||
RETURNS BOOLEAN AS $$
|
||||
BEGIN
|
||||
-- Raise an error with the provided JSON data.
|
||||
-- The JSON object is converted to text for inclusion in the error message.
|
||||
RAISE EXCEPTION 'ash_error: %', json_data::text;
|
||||
RETURN NULL;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql
|
||||
STABLE
|
||||
SET search_path = '';
|
||||
""")
|
||||
|
||||
execute("""
|
||||
CREATE OR REPLACE FUNCTION ash_raise_error(json_data jsonb, type_signal ANYCOMPATIBLE)
|
||||
RETURNS ANYCOMPATIBLE AS $$
|
||||
BEGIN
|
||||
-- Raise an error with the provided JSON data.
|
||||
-- The JSON object is converted to text for inclusion in the error message.
|
||||
RAISE EXCEPTION 'ash_error: %', json_data::text;
|
||||
RETURN NULL;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql
|
||||
STABLE
|
||||
SET search_path = '';
|
||||
""")
|
||||
|
||||
execute("""
|
||||
CREATE OR REPLACE FUNCTION uuid_generate_v7()
|
||||
RETURNS UUID
|
||||
AS $$
|
||||
DECLARE
|
||||
timestamp TIMESTAMPTZ;
|
||||
microseconds INT;
|
||||
BEGIN
|
||||
timestamp = clock_timestamp();
|
||||
microseconds = (cast(extract(microseconds FROM timestamp)::INT - (floor(extract(milliseconds FROM timestamp))::INT * 1000) AS DOUBLE PRECISION) * 4.096)::INT;
|
||||
|
||||
RETURN encode(
|
||||
set_byte(
|
||||
set_byte(
|
||||
overlay(uuid_send(gen_random_uuid()) placing substring(int8send(floor(extract(epoch FROM timestamp) * 1000)::BIGINT) FROM 3) FROM 1 FOR 6
|
||||
),
|
||||
6, (b'0111' || (microseconds >> 8)::bit(4))::bit(8)::int
|
||||
),
|
||||
7, microseconds::bit(8)::int
|
||||
),
|
||||
'hex')::UUID;
|
||||
END
|
||||
$$
|
||||
LANGUAGE PLPGSQL
|
||||
SET search_path = ''
|
||||
VOLATILE;
|
||||
""")
|
||||
|
||||
execute("CREATE EXTENSION IF NOT EXISTS \"citext\"")
|
||||
end
|
||||
|
||||
def down do
|
||||
# Uncomment this if you actually want to uninstall the extensions
|
||||
# when this migration is rolled back:
|
||||
execute(
|
||||
"DROP FUNCTION IF EXISTS uuid_generate_v7(), timestamp_from_uuid_v7(uuid), ash_raise_error(jsonb), ash_raise_error(jsonb, ANYCOMPATIBLE), ash_elixir_and(BOOLEAN, ANYCOMPATIBLE), ash_elixir_and(ANYCOMPATIBLE, ANYCOMPATIBLE), ash_elixir_or(ANYCOMPATIBLE, ANYCOMPATIBLE), ash_elixir_or(BOOLEAN, ANYCOMPATIBLE), ash_trim_whitespace(text[])"
|
||||
)
|
||||
|
||||
# execute("DROP EXTENSION IF EXISTS \"citext\"")
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,134 @@
|
||||
defmodule Tribes.Repo.Migrations.AddChat do
|
||||
@moduledoc """
|
||||
Updates resources based on their most recent snapshots.
|
||||
|
||||
This file was autogenerated with `mix ash_postgres.generate_migrations`
|
||||
"""
|
||||
|
||||
use Ecto.Migration
|
||||
|
||||
def up do
|
||||
create table(:aether_chat_messages, primary_key: false) do
|
||||
add(:id, :uuid, null: false, default: fragment("gen_random_uuid()"), primary_key: true)
|
||||
add(:author_id, :uuid)
|
||||
add(:author_pubkey, :text)
|
||||
add(:body, :text, null: false)
|
||||
add(:client_message_id, :text)
|
||||
add(:metadata, :map, null: false, default: %{})
|
||||
|
||||
add(:inserted_at, :utc_datetime,
|
||||
null: false,
|
||||
default: fragment("(now() AT TIME ZONE 'utc')")
|
||||
)
|
||||
|
||||
add(:updated_at, :utc_datetime,
|
||||
null: false,
|
||||
default: fragment("(now() AT TIME ZONE 'utc')")
|
||||
)
|
||||
|
||||
add(:channel_id, :uuid, null: false)
|
||||
add(:deleted_at, :utc_datetime)
|
||||
end
|
||||
|
||||
create(
|
||||
index(:aether_chat_messages, [:client_message_id],
|
||||
unique: true,
|
||||
where: "deleted_at IS NULL AND client_message_id IS NOT NULL AND deleted_at IS NULL"
|
||||
)
|
||||
)
|
||||
|
||||
create(index(:aether_chat_messages, [:author_pubkey], where: "deleted_at IS NULL"))
|
||||
|
||||
create(index(:aether_chat_messages, [:channel_id, :inserted_at], where: "deleted_at IS NULL"))
|
||||
|
||||
create table(:aether_chat_channels, primary_key: false) do
|
||||
add(:id, :uuid, null: false, default: fragment("gen_random_uuid()"), primary_key: true)
|
||||
end
|
||||
|
||||
alter table(:aether_chat_messages) do
|
||||
modify(
|
||||
:channel_id,
|
||||
references(:aether_chat_channels,
|
||||
column: :id,
|
||||
name: "aether_chat_messages_channel_id_fkey",
|
||||
type: :uuid,
|
||||
prefix: "public"
|
||||
)
|
||||
)
|
||||
end
|
||||
|
||||
alter table(:aether_chat_channels) do
|
||||
add(:slug, :text, null: false)
|
||||
add(:title, :text, null: false)
|
||||
add(:description, :text)
|
||||
add(:backend, :text, null: false, default: "public_sync")
|
||||
add(:conversation_kind, :text, null: false, default: "group")
|
||||
add(:context_provider, :text)
|
||||
add(:context_type, :text)
|
||||
add(:context_id, :text)
|
||||
add(:metadata, :map, null: false, default: %{})
|
||||
|
||||
add(:inserted_at, :utc_datetime,
|
||||
null: false,
|
||||
default: fragment("(now() AT TIME ZONE 'utc')")
|
||||
)
|
||||
|
||||
add(:updated_at, :utc_datetime,
|
||||
null: false,
|
||||
default: fragment("(now() AT TIME ZONE 'utc')")
|
||||
)
|
||||
|
||||
add(:deleted_at, :utc_datetime)
|
||||
end
|
||||
|
||||
create(
|
||||
index(:aether_chat_channels, [:context_provider, :context_type, :context_id],
|
||||
where: "deleted_at IS NULL AND deleted_at IS NULL"
|
||||
)
|
||||
)
|
||||
|
||||
create(
|
||||
index(:aether_chat_channels, [:slug],
|
||||
unique: true,
|
||||
where: "deleted_at IS NULL AND deleted_at IS NULL"
|
||||
)
|
||||
)
|
||||
end
|
||||
|
||||
def down do
|
||||
drop_if_exists(index(:aether_chat_channels, [:slug]))
|
||||
|
||||
drop_if_exists(index(:aether_chat_channels, [:context_provider, :context_type, :context_id]))
|
||||
|
||||
alter table(:aether_chat_channels) do
|
||||
remove(:deleted_at)
|
||||
remove(:updated_at)
|
||||
remove(:inserted_at)
|
||||
remove(:metadata)
|
||||
remove(:context_id)
|
||||
remove(:context_type)
|
||||
remove(:context_provider)
|
||||
remove(:conversation_kind)
|
||||
remove(:backend)
|
||||
remove(:description)
|
||||
remove(:title)
|
||||
remove(:slug)
|
||||
end
|
||||
|
||||
drop(constraint(:aether_chat_messages, "aether_chat_messages_channel_id_fkey"))
|
||||
|
||||
alter table(:aether_chat_messages) do
|
||||
modify(:channel_id, :uuid)
|
||||
end
|
||||
|
||||
drop(table(:aether_chat_channels))
|
||||
|
||||
drop_if_exists(index(:aether_chat_messages, [:channel_id, :inserted_at]))
|
||||
|
||||
drop_if_exists(index(:aether_chat_messages, [:author_pubkey]))
|
||||
|
||||
drop_if_exists(index(:aether_chat_messages, [:client_message_id]))
|
||||
|
||||
drop(table(:aether_chat_messages))
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,66 @@
|
||||
defmodule Tribes.Repo.Migrations.AddChatParticipants do
|
||||
@moduledoc """
|
||||
Updates resources based on their most recent snapshots.
|
||||
|
||||
This file was autogenerated with `mix ash_postgres.generate_migrations`
|
||||
"""
|
||||
|
||||
use Ecto.Migration
|
||||
|
||||
def up do
|
||||
create table(:aether_chat_participants, primary_key: false) do
|
||||
add(:id, :uuid, null: false, default: fragment("gen_random_uuid()"), primary_key: true)
|
||||
add(:pubkey, :text, null: false)
|
||||
add(:user_id, :uuid)
|
||||
add(:display_name, :text)
|
||||
add(:role, :text, null: false, default: "member")
|
||||
add(:metadata, :map, null: false, default: %{})
|
||||
|
||||
add(:inserted_at, :utc_datetime,
|
||||
null: false,
|
||||
default: fragment("(now() AT TIME ZONE 'utc')")
|
||||
)
|
||||
|
||||
add(:updated_at, :utc_datetime,
|
||||
null: false,
|
||||
default: fragment("(now() AT TIME ZONE 'utc')")
|
||||
)
|
||||
|
||||
add(
|
||||
:channel_id,
|
||||
references(:aether_chat_channels,
|
||||
column: :id,
|
||||
name: "aether_chat_participants_channel_id_fkey",
|
||||
type: :uuid,
|
||||
prefix: "public"
|
||||
),
|
||||
null: false
|
||||
)
|
||||
|
||||
add(:deleted_at, :utc_datetime)
|
||||
end
|
||||
|
||||
create(
|
||||
index(:aether_chat_participants, [:pubkey],
|
||||
where: "deleted_at IS NULL AND deleted_at IS NULL"
|
||||
)
|
||||
)
|
||||
|
||||
create(
|
||||
index(:aether_chat_participants, [:channel_id, :pubkey],
|
||||
unique: true,
|
||||
where: "deleted_at IS NULL AND deleted_at IS NULL"
|
||||
)
|
||||
)
|
||||
end
|
||||
|
||||
def down do
|
||||
drop(constraint(:aether_chat_participants, "aether_chat_participants_channel_id_fkey"))
|
||||
|
||||
drop_if_exists(index(:aether_chat_participants, [:channel_id, :pubkey]))
|
||||
|
||||
drop_if_exists(index(:aether_chat_participants, [:pubkey]))
|
||||
|
||||
drop(table(:aether_chat_participants))
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,231 @@
|
||||
{
|
||||
"attributes": [
|
||||
{
|
||||
"allow_nil?": false,
|
||||
"default": "fragment(\"gen_random_uuid()\")",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": true,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "id",
|
||||
"type": "uuid"
|
||||
},
|
||||
{
|
||||
"allow_nil?": false,
|
||||
"default": "nil",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "slug",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"allow_nil?": false,
|
||||
"default": "nil",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "title",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"allow_nil?": true,
|
||||
"default": "nil",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "description",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"allow_nil?": false,
|
||||
"default": "\"public_sync\"",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "backend",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"allow_nil?": false,
|
||||
"default": "\"group\"",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "conversation_kind",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"allow_nil?": true,
|
||||
"default": "nil",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "context_provider",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"allow_nil?": true,
|
||||
"default": "nil",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "context_type",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"allow_nil?": true,
|
||||
"default": "nil",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "context_id",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"allow_nil?": false,
|
||||
"default": "%{}",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "metadata",
|
||||
"type": "map"
|
||||
},
|
||||
{
|
||||
"allow_nil?": false,
|
||||
"default": "fragment(\"(now() AT TIME ZONE 'utc')\")",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "inserted_at",
|
||||
"type": "utc_datetime"
|
||||
},
|
||||
{
|
||||
"allow_nil?": false,
|
||||
"default": "fragment(\"(now() AT TIME ZONE 'utc')\")",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "updated_at",
|
||||
"type": "utc_datetime"
|
||||
},
|
||||
{
|
||||
"allow_nil?": true,
|
||||
"default": "nil",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "deleted_at",
|
||||
"type": "utc_datetime"
|
||||
}
|
||||
],
|
||||
"base_filter": "deleted_at IS NULL",
|
||||
"check_constraints": [],
|
||||
"create_table_options": null,
|
||||
"custom_indexes": [
|
||||
{
|
||||
"all_tenants?": false,
|
||||
"concurrently": false,
|
||||
"error_fields": [
|
||||
"slug"
|
||||
],
|
||||
"fields": [
|
||||
{
|
||||
"type": "atom",
|
||||
"value": "slug"
|
||||
}
|
||||
],
|
||||
"include": null,
|
||||
"message": null,
|
||||
"name": null,
|
||||
"nulls_distinct": true,
|
||||
"prefix": null,
|
||||
"table": null,
|
||||
"unique": true,
|
||||
"using": null,
|
||||
"where": "deleted_at IS NULL"
|
||||
},
|
||||
{
|
||||
"all_tenants?": false,
|
||||
"concurrently": false,
|
||||
"error_fields": [
|
||||
"context_provider",
|
||||
"context_type",
|
||||
"context_id"
|
||||
],
|
||||
"fields": [
|
||||
{
|
||||
"type": "atom",
|
||||
"value": "context_provider"
|
||||
},
|
||||
{
|
||||
"type": "atom",
|
||||
"value": "context_type"
|
||||
},
|
||||
{
|
||||
"type": "atom",
|
||||
"value": "context_id"
|
||||
}
|
||||
],
|
||||
"include": null,
|
||||
"message": null,
|
||||
"name": null,
|
||||
"nulls_distinct": true,
|
||||
"prefix": null,
|
||||
"table": null,
|
||||
"unique": false,
|
||||
"using": null,
|
||||
"where": "deleted_at IS NULL"
|
||||
}
|
||||
],
|
||||
"custom_statements": [],
|
||||
"has_create_action": true,
|
||||
"hash": "274584387DB5ECF41145DE06404A06BF41F74821287238F3424C60E1B3FCEF21",
|
||||
"identities": [],
|
||||
"multitenancy": {
|
||||
"attribute": null,
|
||||
"global": null,
|
||||
"strategy": null
|
||||
},
|
||||
"repo": "Elixir.Tribes.Repo",
|
||||
"schema": null,
|
||||
"table": "aether_chat_channels"
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
{
|
||||
"attributes": [
|
||||
{
|
||||
"allow_nil?": false,
|
||||
"default": "fragment(\"gen_random_uuid()\")",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": true,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "id",
|
||||
"type": "uuid"
|
||||
},
|
||||
{
|
||||
"allow_nil?": true,
|
||||
"default": "nil",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "author_id",
|
||||
"type": "uuid"
|
||||
},
|
||||
{
|
||||
"allow_nil?": true,
|
||||
"default": "nil",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "author_pubkey",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"allow_nil?": false,
|
||||
"default": "nil",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "body",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"allow_nil?": true,
|
||||
"default": "nil",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "client_message_id",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"allow_nil?": false,
|
||||
"default": "%{}",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "metadata",
|
||||
"type": "map"
|
||||
},
|
||||
{
|
||||
"allow_nil?": false,
|
||||
"default": "fragment(\"(now() AT TIME ZONE 'utc')\")",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "inserted_at",
|
||||
"type": "utc_datetime"
|
||||
},
|
||||
{
|
||||
"allow_nil?": false,
|
||||
"default": "fragment(\"(now() AT TIME ZONE 'utc')\")",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "updated_at",
|
||||
"type": "utc_datetime"
|
||||
},
|
||||
{
|
||||
"allow_nil?": false,
|
||||
"default": "nil",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": false,
|
||||
"references": {
|
||||
"deferrable": false,
|
||||
"destination_attribute": "id",
|
||||
"destination_attribute_default": null,
|
||||
"destination_attribute_generated": null,
|
||||
"index?": false,
|
||||
"match_type": null,
|
||||
"match_with": null,
|
||||
"multitenancy": {
|
||||
"attribute": null,
|
||||
"global": null,
|
||||
"strategy": null
|
||||
},
|
||||
"name": "aether_chat_messages_channel_id_fkey",
|
||||
"on_delete": null,
|
||||
"on_update": null,
|
||||
"primary_key?": true,
|
||||
"schema": "public",
|
||||
"table": "aether_chat_channels"
|
||||
},
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "channel_id",
|
||||
"type": "uuid"
|
||||
},
|
||||
{
|
||||
"allow_nil?": true,
|
||||
"default": "nil",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "deleted_at",
|
||||
"type": "utc_datetime"
|
||||
}
|
||||
],
|
||||
"base_filter": "deleted_at IS NULL",
|
||||
"check_constraints": [],
|
||||
"create_table_options": null,
|
||||
"custom_indexes": [
|
||||
{
|
||||
"all_tenants?": false,
|
||||
"concurrently": false,
|
||||
"error_fields": [
|
||||
"channel_id",
|
||||
"inserted_at"
|
||||
],
|
||||
"fields": [
|
||||
{
|
||||
"type": "atom",
|
||||
"value": "channel_id"
|
||||
},
|
||||
{
|
||||
"type": "atom",
|
||||
"value": "inserted_at"
|
||||
}
|
||||
],
|
||||
"include": null,
|
||||
"message": null,
|
||||
"name": null,
|
||||
"nulls_distinct": true,
|
||||
"prefix": null,
|
||||
"table": null,
|
||||
"unique": false,
|
||||
"using": null,
|
||||
"where": null
|
||||
},
|
||||
{
|
||||
"all_tenants?": false,
|
||||
"concurrently": false,
|
||||
"error_fields": [
|
||||
"author_pubkey"
|
||||
],
|
||||
"fields": [
|
||||
{
|
||||
"type": "atom",
|
||||
"value": "author_pubkey"
|
||||
}
|
||||
],
|
||||
"include": null,
|
||||
"message": null,
|
||||
"name": null,
|
||||
"nulls_distinct": true,
|
||||
"prefix": null,
|
||||
"table": null,
|
||||
"unique": false,
|
||||
"using": null,
|
||||
"where": null
|
||||
},
|
||||
{
|
||||
"all_tenants?": false,
|
||||
"concurrently": false,
|
||||
"error_fields": [
|
||||
"client_message_id"
|
||||
],
|
||||
"fields": [
|
||||
{
|
||||
"type": "atom",
|
||||
"value": "client_message_id"
|
||||
}
|
||||
],
|
||||
"include": null,
|
||||
"message": null,
|
||||
"name": null,
|
||||
"nulls_distinct": true,
|
||||
"prefix": null,
|
||||
"table": null,
|
||||
"unique": true,
|
||||
"using": null,
|
||||
"where": "client_message_id IS NOT NULL AND deleted_at IS NULL"
|
||||
}
|
||||
],
|
||||
"custom_statements": [],
|
||||
"has_create_action": true,
|
||||
"hash": "476E6D316A778142AA734E0506BCC0CE1726F1CE12BBE1798BF41A34A5946BFA",
|
||||
"identities": [],
|
||||
"multitenancy": {
|
||||
"attribute": null,
|
||||
"global": null,
|
||||
"strategy": null
|
||||
},
|
||||
"repo": "Elixir.Tribes.Repo",
|
||||
"schema": null,
|
||||
"table": "aether_chat_messages"
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
{
|
||||
"attributes": [
|
||||
{
|
||||
"allow_nil?": false,
|
||||
"default": "fragment(\"gen_random_uuid()\")",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": true,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "id",
|
||||
"type": "uuid"
|
||||
},
|
||||
{
|
||||
"allow_nil?": false,
|
||||
"default": "nil",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "pubkey",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"allow_nil?": true,
|
||||
"default": "nil",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "user_id",
|
||||
"type": "uuid"
|
||||
},
|
||||
{
|
||||
"allow_nil?": true,
|
||||
"default": "nil",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "display_name",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"allow_nil?": false,
|
||||
"default": "\"member\"",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "role",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"allow_nil?": false,
|
||||
"default": "%{}",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "metadata",
|
||||
"type": "map"
|
||||
},
|
||||
{
|
||||
"allow_nil?": false,
|
||||
"default": "fragment(\"(now() AT TIME ZONE 'utc')\")",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "inserted_at",
|
||||
"type": "utc_datetime"
|
||||
},
|
||||
{
|
||||
"allow_nil?": false,
|
||||
"default": "fragment(\"(now() AT TIME ZONE 'utc')\")",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "updated_at",
|
||||
"type": "utc_datetime"
|
||||
},
|
||||
{
|
||||
"allow_nil?": false,
|
||||
"default": "nil",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": false,
|
||||
"references": {
|
||||
"deferrable": false,
|
||||
"destination_attribute": "id",
|
||||
"destination_attribute_default": null,
|
||||
"destination_attribute_generated": null,
|
||||
"index?": false,
|
||||
"match_type": null,
|
||||
"match_with": null,
|
||||
"multitenancy": {
|
||||
"attribute": null,
|
||||
"global": null,
|
||||
"strategy": null
|
||||
},
|
||||
"name": "aether_chat_participants_channel_id_fkey",
|
||||
"on_delete": null,
|
||||
"on_update": null,
|
||||
"primary_key?": true,
|
||||
"schema": "public",
|
||||
"table": "aether_chat_channels"
|
||||
},
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "channel_id",
|
||||
"type": "uuid"
|
||||
},
|
||||
{
|
||||
"allow_nil?": true,
|
||||
"default": "nil",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "deleted_at",
|
||||
"type": "utc_datetime"
|
||||
}
|
||||
],
|
||||
"base_filter": "deleted_at IS NULL",
|
||||
"check_constraints": [],
|
||||
"create_table_options": null,
|
||||
"custom_indexes": [
|
||||
{
|
||||
"all_tenants?": false,
|
||||
"concurrently": false,
|
||||
"error_fields": [
|
||||
"channel_id",
|
||||
"pubkey"
|
||||
],
|
||||
"fields": [
|
||||
{
|
||||
"type": "atom",
|
||||
"value": "channel_id"
|
||||
},
|
||||
{
|
||||
"type": "atom",
|
||||
"value": "pubkey"
|
||||
}
|
||||
],
|
||||
"include": null,
|
||||
"message": null,
|
||||
"name": null,
|
||||
"nulls_distinct": true,
|
||||
"prefix": null,
|
||||
"table": null,
|
||||
"unique": true,
|
||||
"using": null,
|
||||
"where": "deleted_at IS NULL"
|
||||
},
|
||||
{
|
||||
"all_tenants?": false,
|
||||
"concurrently": false,
|
||||
"error_fields": [
|
||||
"pubkey"
|
||||
],
|
||||
"fields": [
|
||||
{
|
||||
"type": "atom",
|
||||
"value": "pubkey"
|
||||
}
|
||||
],
|
||||
"include": null,
|
||||
"message": null,
|
||||
"name": null,
|
||||
"nulls_distinct": true,
|
||||
"prefix": null,
|
||||
"table": null,
|
||||
"unique": false,
|
||||
"using": null,
|
||||
"where": "deleted_at IS NULL"
|
||||
}
|
||||
],
|
||||
"custom_statements": [],
|
||||
"has_create_action": true,
|
||||
"hash": "FE4AA6C74C1C2B9D844C47CDFA9418A41D4ACAFD7B2EF6D15CDA264295CBD6B3",
|
||||
"identities": [],
|
||||
"multitenancy": {
|
||||
"attribute": null,
|
||||
"global": null,
|
||||
"strategy": null
|
||||
},
|
||||
"repo": "Elixir.Tribes.Repo",
|
||||
"schema": null,
|
||||
"table": "aether_chat_participants"
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"ash_functions_version": 5,
|
||||
"installed": [
|
||||
"ash-functions",
|
||||
"citext"
|
||||
]
|
||||
}
|
||||
Executable
+38
@@ -0,0 +1,38 @@
|
||||
#!/usr/bin/env sh
|
||||
set -eu
|
||||
|
||||
maxkb=500
|
||||
while [ "$#" -gt 0 ]; do
|
||||
case "$1" in
|
||||
--maxkb=*)
|
||||
maxkb=${1#--maxkb=}
|
||||
shift
|
||||
;;
|
||||
--)
|
||||
shift
|
||||
break
|
||||
;;
|
||||
-*)
|
||||
echo "check-added-large-files: unknown option: $1" >&2
|
||||
exit 2
|
||||
;;
|
||||
*)
|
||||
break
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
limit=$((maxkb * 1024))
|
||||
failed=0
|
||||
|
||||
for file in "$@"; do
|
||||
[ -f "$file" ] || continue
|
||||
size=$(wc -c < "$file" | tr -d '[:space:]')
|
||||
if [ "$size" -gt "$limit" ]; then
|
||||
kb=$(((size + 1023) / 1024))
|
||||
echo "$file (${kb} KB) exceeds ${maxkb} KB" >&2
|
||||
failed=1
|
||||
fi
|
||||
done
|
||||
|
||||
exit "$failed"
|
||||
Executable
+106
@@ -0,0 +1,106 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
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
|
||||
}
|
||||
|
||||
fail() {
|
||||
echo "plugin: $*" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)"
|
||||
plugin_root="$(cd "$script_dir/.." && pwd -P)"
|
||||
host_root="${TRIBES_HOST_ROOT:-$plugin_root/../tribes}"
|
||||
host_root="$(cd "$host_root" && pwd -P)"
|
||||
host_script="$host_root/scripts/plugin"
|
||||
|
||||
json_field() {
|
||||
local field="$1"
|
||||
|
||||
FIELD="$field" mix run --no-start -e '
|
||||
field = System.fetch_env!("FIELD")
|
||||
manifest = "manifest.json" |> File.read!() |> JSON.decode!()
|
||||
value = Map.fetch!(manifest, field)
|
||||
IO.write(value)
|
||||
'
|
||||
}
|
||||
|
||||
run_smoke() {
|
||||
cd "$plugin_root"
|
||||
|
||||
mix compile
|
||||
|
||||
local otp_app
|
||||
local entry_module
|
||||
otp_app="$(json_field otp_app)"
|
||||
entry_module="$(json_field entry_module)"
|
||||
|
||||
local beam_path="_build/dev/lib/$otp_app/ebin/Elixir.$entry_module.beam"
|
||||
[[ -f "$beam_path" ]] || fail "expected entry module beam at $beam_path"
|
||||
|
||||
cd "$host_root"
|
||||
PLUGIN_ROOT="$plugin_root" ENTRY_MODULE="$entry_module" mix run --no-start -e '
|
||||
plugin_root = System.fetch_env!("PLUGIN_ROOT")
|
||||
entry_module = System.fetch_env!("ENTRY_MODULE")
|
||||
|
||||
plugin_root
|
||||
|> Path.join("_build/dev/lib/*/ebin")
|
||||
|> Path.wildcard()
|
||||
|> Enum.each(&(:code.add_patha(String.to_charlist(&1))))
|
||||
|
||||
module = String.to_atom("Elixir." <> entry_module)
|
||||
|
||||
case Code.ensure_loaded(module) do
|
||||
{:module, ^module} ->
|
||||
IO.puts("Loaded #{entry_module}")
|
||||
|
||||
other ->
|
||||
raise "failed to load #{entry_module}: #{inspect(other)}"
|
||||
end
|
||||
'
|
||||
}
|
||||
|
||||
command_name="${1:-}"
|
||||
if [[ -z "$command_name" ]]; then
|
||||
usage
|
||||
exit 1
|
||||
fi
|
||||
shift
|
||||
|
||||
case "$command_name" in
|
||||
validate | test | precommit | ecto.migration | ash.codegen | smoke | shell) ;;
|
||||
-h | --help | help)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
usage
|
||||
fail "unknown command: $command_name"
|
||||
;;
|
||||
esac
|
||||
|
||||
[[ -x "$host_script" || -f "$host_script" ]] || fail "expected host plugin script at $host_script"
|
||||
|
||||
if [[ "$command_name" == "smoke" ]]; then
|
||||
run_smoke "$@"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ "${DEVENV_ROOT:-}" == "$plugin_root" ]]; then
|
||||
command -v devenv >/dev/null 2>&1 || fail "devenv is required when running from the plugin devenv shell"
|
||||
cd "$host_root"
|
||||
exec devenv shell -- bash ./scripts/plugin "$command_name" "$plugin_root" "$@"
|
||||
fi
|
||||
|
||||
exec bash "$host_script" "$command_name" "$plugin_root" "$@"
|
||||
+26
-7
@@ -45,13 +45,32 @@ if [ -d "test/my_plugin" ]; then
|
||||
mv "test/my_plugin" "test/$SNAKE"
|
||||
fi
|
||||
|
||||
# Replace in all text files
|
||||
find . -type f \( -name '*.ex' -o -name '*.exs' -o -name '*.json' -o -name '*.js' -o -name '*.css' -o -name '*.md' -o -name '*.yml' -o -name '*.yaml' -o -name '.formatter.exs' \) -exec \
|
||||
sed -i '' \
|
||||
-e "s/my_plugin/$SNAKE/g" \
|
||||
-e "s/MyPlugin/$MODULE/g" \
|
||||
-e "s/my-plugin/$SNAKE/g" \
|
||||
{} +
|
||||
# Replace in all text files (portable across GNU/BSD sed)
|
||||
sed_in_place() {
|
||||
file=$1
|
||||
|
||||
if sed --version >/dev/null 2>&1; then
|
||||
sed -i \
|
||||
-e "s/my_plugin/$SNAKE/g" \
|
||||
-e "s/MyPlugin/$MODULE/g" \
|
||||
-e "s/my-plugin/$SNAKE/g" \
|
||||
"$file"
|
||||
else
|
||||
sed -i '' \
|
||||
-e "s/my_plugin/$SNAKE/g" \
|
||||
-e "s/MyPlugin/$MODULE/g" \
|
||||
-e "s/my-plugin/$SNAKE/g" \
|
||||
"$file"
|
||||
fi
|
||||
}
|
||||
|
||||
while IFS= read -r -d '' file; do
|
||||
sed_in_place "$file"
|
||||
done < <(
|
||||
find . -type f \
|
||||
\( -name '*.ex' -o -name '*.exs' -o -name '*.json' -o -name '*.js' -o -name '*.css' -o -name '*.md' -o -name '*.yml' -o -name '*.yaml' -o -name '.formatter.exs' \) \
|
||||
-print0
|
||||
)
|
||||
|
||||
# Rename asset files
|
||||
for ext in js css; do
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
defmodule TribeOne.TribesPlugin.Aether.ChatBackendTest do
|
||||
use Tribes.PluginTest.PageCase, plugin: TribeOne.TribesPlugin.Aether.Plugin
|
||||
|
||||
alias TribeOne.TribesPlugin.Aether.Chat
|
||||
alias TribeOne.TribesPlugin.Aether.Chat.Nostr.Nak
|
||||
alias Parrhesia.API.Events
|
||||
alias Parrhesia.API.RequestContext
|
||||
|
||||
test "NIP-17 DM flow sends a user-to-user message", %{
|
||||
signed_in_conn: sender_conn,
|
||||
current_user: sender,
|
||||
conn: conn
|
||||
} do
|
||||
recipient = create_user!()
|
||||
|
||||
{:ok, view, _html} = live(sender_conn, "/aether/chat")
|
||||
|
||||
assert has_element?(view, "#chat-recipient-picker")
|
||||
|
||||
view
|
||||
|> form("#chat-recipient-search-form", %{"recipient" => %{"query" => recipient.username}})
|
||||
|> render_change()
|
||||
|
||||
view
|
||||
|> element("#chat-recipient-#{recipient.id}")
|
||||
|> render_click()
|
||||
|
||||
assert_redirect(
|
||||
view,
|
||||
Chat.standalone_path(Chat.direct_slug(sender.pubkey_hex, recipient.pubkey_hex))
|
||||
)
|
||||
|
||||
{:ok, sender_view, _html} =
|
||||
live(
|
||||
sender_conn,
|
||||
Chat.standalone_path(Chat.direct_slug(sender.pubkey_hex, recipient.pubkey_hex))
|
||||
)
|
||||
|
||||
sender_view
|
||||
|> form("#chat-message-form", %{"message" => %{"body" => "hello recipient"}})
|
||||
|> render_submit()
|
||||
|
||||
assert has_element?(sender_view, "#chat-messages", "hello recipient")
|
||||
|
||||
recipient_conn = sign_in(conn, recipient.username)
|
||||
|
||||
{:ok, recipient_view, _html} =
|
||||
live(
|
||||
recipient_conn,
|
||||
Chat.standalone_path(Chat.direct_slug(sender.pubkey_hex, recipient.pubkey_hex))
|
||||
)
|
||||
|
||||
assert has_element?(recipient_view, "#chat-messages", "hello recipient")
|
||||
end
|
||||
|
||||
test "Aether publishes real NIP-17 giftwraps that nak can unwrap" do
|
||||
require_executable!("nak")
|
||||
|
||||
{sender_pubkey, sender_privkey} = Tribes.Keyring.generate_keypair()
|
||||
recipient_privkey = nak_key_generate!()
|
||||
recipient_pubkey = nak_key_public!(recipient_privkey)
|
||||
|
||||
{:ok, channel} =
|
||||
Chat.ensure_direct_conversation(sender_pubkey, recipient_pubkey, %{
|
||||
title: "NIP-17 interop"
|
||||
})
|
||||
|
||||
assert {:ok, message} =
|
||||
Chat.send_message(
|
||||
channel,
|
||||
%{body: "hello nak recipient", author_pubkey: sender_pubkey},
|
||||
session_privkey: sender_privkey
|
||||
)
|
||||
|
||||
assert message.body == "hello nak recipient"
|
||||
|
||||
[giftwrap] = giftwraps_for(recipient_pubkey)
|
||||
rumor = nak_gift_unwrap!(recipient_privkey, giftwrap)
|
||||
|
||||
assert giftwrap["kind"] == 1059
|
||||
assert ["p", recipient_pubkey] in Enum.map(giftwrap["tags"], &Enum.take(&1, 2))
|
||||
assert rumor["kind"] == 14
|
||||
assert rumor["pubkey"] == sender_pubkey
|
||||
assert rumor["content"] == "hello nak recipient"
|
||||
assert ["p", recipient_pubkey] in Enum.map(rumor["tags"], &Enum.take(&1, 2))
|
||||
end
|
||||
|
||||
test "Aether reads external NIP-17 giftwraps produced by nak" do
|
||||
require_executable!("nak")
|
||||
|
||||
external_sender_privkey = nak_key_generate!()
|
||||
external_sender_pubkey = nak_key_public!(external_sender_privkey)
|
||||
{recipient_pubkey, recipient_privkey} = Tribes.Keyring.generate_keypair()
|
||||
|
||||
{:ok, channel} =
|
||||
Chat.ensure_direct_conversation(recipient_pubkey, external_sender_pubkey, %{
|
||||
title: "External NIP-17"
|
||||
})
|
||||
|
||||
giftwrap =
|
||||
nak_nip17_giftwrap!(external_sender_privkey, recipient_pubkey, "hello from external nak")
|
||||
|
||||
:ok = publish_event!(giftwrap)
|
||||
|
||||
assert {:ok, [message]} =
|
||||
Chat.list_conversation_messages(channel, session_privkey: recipient_privkey)
|
||||
|
||||
assert message.author_pubkey == external_sender_pubkey
|
||||
assert message.body == "hello from external nak"
|
||||
end
|
||||
|
||||
test "NIP-04 backend imports decryptable legacy DMs as read-only" do
|
||||
require_executable!("nak")
|
||||
|
||||
external_sender_privkey = nak_key_generate!()
|
||||
external_sender_pubkey = nak_key_public!(external_sender_privkey)
|
||||
{recipient_pubkey, recipient_privkey} = Tribes.Keyring.generate_keypair()
|
||||
|
||||
{:ok, channel} =
|
||||
Chat.ensure_direct_conversation(recipient_pubkey, external_sender_pubkey, %{
|
||||
title: "Legacy NIP-04",
|
||||
backend: :nostr_nip04,
|
||||
conversation_kind: :legacy_dm
|
||||
})
|
||||
|
||||
{:ok, ciphertext} =
|
||||
Nak.nip04_encrypt(external_sender_privkey, recipient_pubkey, "legacy hello from nak")
|
||||
|
||||
legacy_event = nak_event!(external_sender_privkey, 4, recipient_pubkey, ciphertext)
|
||||
:ok = publish_event!(legacy_event)
|
||||
|
||||
assert {:ok, [message]} =
|
||||
Chat.list_conversation_messages(channel, session_privkey: recipient_privkey)
|
||||
|
||||
assert message.author_pubkey == external_sender_pubkey
|
||||
assert message.body == "legacy hello from nak"
|
||||
|
||||
assert {:error, :read_only_backend} =
|
||||
Chat.send_message(channel, %{body: "nope"}, session_privkey: recipient_privkey)
|
||||
end
|
||||
|
||||
test "backend capabilities prepare Nostr-compatible non-custodial backends" do
|
||||
assert %{canonical_store: :parrhesia_events, non_custodial_signing?: true} =
|
||||
Chat.backend_capabilities(:nostr_nip17)
|
||||
|
||||
assert %{read_only?: true, required_protocols: [:nip04]} =
|
||||
Chat.backend_capabilities(:nostr_nip04)
|
||||
end
|
||||
|
||||
defp create_user! do
|
||||
username = "chat_recipient_#{System.unique_integer([:positive])}"
|
||||
|
||||
{:ok, user} =
|
||||
Ash.create(
|
||||
Tribes.Accounts.User,
|
||||
%{username: username, password: "password_123", password_confirmation: "password_123"},
|
||||
action: :register_with_password,
|
||||
domain: Tribes.Accounts
|
||||
)
|
||||
|
||||
Tribes.Plugin.User.from_host(user)
|
||||
end
|
||||
|
||||
defp publish_event!(event) do
|
||||
assert {:ok, result} =
|
||||
Events.publish(event,
|
||||
context: %RequestContext{
|
||||
caller: :local,
|
||||
authenticated_pubkeys: MapSet.new([event["pubkey"]])
|
||||
}
|
||||
)
|
||||
|
||||
assert result.accepted
|
||||
:ok
|
||||
end
|
||||
|
||||
defp giftwraps_for(recipient_pubkey) do
|
||||
assert {:ok, events} =
|
||||
Events.query(
|
||||
[%{"kinds" => [1059], "#p" => [recipient_pubkey], "limit" => 10}],
|
||||
context: %RequestContext{
|
||||
caller: :local,
|
||||
authenticated_pubkeys: MapSet.new([recipient_pubkey])
|
||||
}
|
||||
)
|
||||
|
||||
events
|
||||
end
|
||||
|
||||
defp nak_nip17_giftwrap!(sender_privkey, recipient_pubkey, body) do
|
||||
script = ~S'''
|
||||
set -euo pipefail
|
||||
nak event --sec "$1" -k 14 -p "$2" -c "$3" </dev/null 2>/dev/null |
|
||||
nak gift wrap --sec "$1" -p "$2" 2>/dev/null
|
||||
'''
|
||||
|
||||
script |> run_script!([sender_privkey, recipient_pubkey, body]) |> JSON.decode!()
|
||||
end
|
||||
|
||||
defp nak_event!(sender_privkey, kind, recipient_pubkey, content) do
|
||||
script = ~S'''
|
||||
set -euo pipefail
|
||||
nak event --sec "$1" -k "$2" -p "$3" -c "$4" </dev/null 2>/dev/null
|
||||
'''
|
||||
|
||||
script
|
||||
|> run_script!([sender_privkey, to_string(kind), recipient_pubkey, content])
|
||||
|> JSON.decode!()
|
||||
end
|
||||
|
||||
defp nak_gift_unwrap!(recipient_privkey, giftwrap) do
|
||||
script = ~S'''
|
||||
set -euo pipefail
|
||||
printf '%s\n' "$2" | nak gift unwrap --sec "$1" 2>/dev/null
|
||||
'''
|
||||
|
||||
script
|
||||
|> run_script!([recipient_privkey, JSON.encode!(giftwrap)])
|
||||
|> JSON.decode!()
|
||||
end
|
||||
|
||||
defp nak_key_generate!, do: run_script!("nak key generate", [])
|
||||
|
||||
defp nak_key_public!(privkey), do: run_script!("nak key public \"$1\"", [privkey])
|
||||
|
||||
defp require_executable!(name) do
|
||||
unless System.find_executable(name) do
|
||||
flunk("expected #{name} in PATH")
|
||||
end
|
||||
end
|
||||
|
||||
defp run_script!(script, args) do
|
||||
task =
|
||||
Task.async(fn ->
|
||||
System.cmd("bash", ["-lc", script, "bash" | args], stderr_to_stdout: true)
|
||||
end)
|
||||
|
||||
case Task.yield(task, 30_000) || Task.shutdown(task, :brutal_kill) do
|
||||
{:ok, {output, 0}} -> String.trim(output)
|
||||
{:ok, {output, status}} -> flunk("script failed with status #{status}:\n#{output}")
|
||||
nil -> flunk("script timed out")
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,74 @@
|
||||
defmodule TribeOne.TribesPlugin.Aether.ChatPageTest do
|
||||
use Tribes.PluginTest.PageCase, plugin: TribeOne.TribesPlugin.Aether.Plugin
|
||||
|
||||
alias TribeOne.TribesPlugin.Aether.Chat
|
||||
|
||||
test "renders the public chat for signed-out visitors", %{conn: conn} do
|
||||
{:ok, view, _html} = live(conn, "/aether/chat")
|
||||
|
||||
assert has_element?(view, "#aether-chat-page")
|
||||
assert has_element?(view, "#chat-signed-out", "Sign in to join the chat.")
|
||||
end
|
||||
|
||||
test "renders the message composer for signed-in users", %{signed_in_conn: conn} do
|
||||
{:ok, view, _html} = live(conn, "/aether/chat")
|
||||
|
||||
assert has_element?(view, "#chat-message-form")
|
||||
assert has_element?(view, "#chat-send-button", "Send")
|
||||
end
|
||||
|
||||
test "posts a public synced message", %{signed_in_conn: conn} do
|
||||
slug = "test-chat-#{System.unique_integer([:positive])}"
|
||||
{:ok, view, _html} = live(conn, "/aether/chat/#{slug}")
|
||||
|
||||
view
|
||||
|> form("#chat-message-form", %{"message" => %{"body" => "Hello from chat test"}})
|
||||
|> render_submit()
|
||||
|
||||
assert has_element?(view, "#chat-messages", "Hello from chat test")
|
||||
end
|
||||
|
||||
test "renders compact embedded chat", %{conn: conn} do
|
||||
slug = "embed-chat-#{System.unique_integer([:positive])}"
|
||||
{:ok, view, _html} = live(conn, "/aether/chat/embed/#{slug}")
|
||||
|
||||
assert has_element?(view, "#aether-chat-embed")
|
||||
assert has_element?(view, "#chat-embed-header")
|
||||
refute has_element?(view, "#aether-chat-page")
|
||||
end
|
||||
|
||||
test "renders Marmot scaffold without public composer", %{signed_in_conn: conn} do
|
||||
slug = "marmot-chat-#{System.unique_integer([:positive])}"
|
||||
{:ok, view, _html} = live(conn, "/aether/chat/marmot/#{slug}")
|
||||
|
||||
assert has_element?(view, "#chat-marmot-notice")
|
||||
assert has_element?(view, "#chat-marmot-disabled")
|
||||
refute has_element?(view, "#chat-message-form")
|
||||
end
|
||||
|
||||
test "ensures context group channels with stable slugs" do
|
||||
attrs = %{
|
||||
context_provider: "sender",
|
||||
context_type: "stream",
|
||||
context_id: "stream-123",
|
||||
title: "Stream chat"
|
||||
}
|
||||
|
||||
assert {:ok, first} = Chat.ensure_channel(attrs)
|
||||
assert {:ok, second} = Chat.ensure_channel(attrs)
|
||||
|
||||
assert first.id == second.id
|
||||
assert first.slug == "sender-stream-stream-123"
|
||||
assert first.conversation_kind == :context_group
|
||||
assert Chat.embed_path(first) == "/aether/chat/embed/sender-stream-stream-123"
|
||||
assert Chat.marmot_path(first) == "/aether/chat/marmot/sender-stream-stream-123"
|
||||
end
|
||||
|
||||
test "ensures context group channels through provider helper" do
|
||||
assert {:ok, channel} =
|
||||
Chat.ensure_context_channel("sender", "stream", "stream-456", %{title: "Stream Chat"})
|
||||
|
||||
assert channel.slug == "sender-stream-stream-456"
|
||||
assert channel.conversation_kind == :context_group
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,90 @@
|
||||
defmodule TribeOne.TribesPlugin.Aether.ExternalClientInteropTest do
|
||||
use ExUnit.Case, async: false
|
||||
|
||||
test "nak can produce and consume NIP-17/NIP-59 and NIP-04 DM artifacts" do
|
||||
require_executable!("nak")
|
||||
|
||||
output =
|
||||
run_script!(~S'''
|
||||
set -euo pipefail
|
||||
sender_sec=$(nak key generate)
|
||||
recipient_sec=$(nak key generate)
|
||||
sender_pub=$(nak key public "$sender_sec")
|
||||
recipient_pub=$(nak key public "$recipient_sec")
|
||||
|
||||
gift=$(nak event --sec "$sender_sec" -k 14 -p "$recipient_pub" -c "hello from nak" </dev/null 2>/dev/null | nak gift wrap --sec "$sender_sec" -p "$recipient_pub" 2>/dev/null)
|
||||
rumor=$(printf '%s\n' "$gift" | nak gift unwrap --sec "$recipient_sec" 2>/dev/null)
|
||||
|
||||
legacy_ciphertext=$(nak encrypt --nip04 --sec "$sender_sec" -p "$recipient_pub" "legacy hello from nak" </dev/null 2>/dev/null)
|
||||
legacy_plaintext=$(nak decrypt --nip04 --sec "$recipient_sec" -p "$sender_pub" "$legacy_ciphertext" 2>/dev/null)
|
||||
|
||||
printf 'SENDER_PUB\t%s\n' "$sender_pub"
|
||||
printf 'RECIPIENT_PUB\t%s\n' "$recipient_pub"
|
||||
printf 'GIFT\t%s\n' "$gift"
|
||||
printf 'RUMOR\t%s\n' "$rumor"
|
||||
printf 'NIP04\t%s\n' "$legacy_plaintext"
|
||||
''')
|
||||
|
||||
lines = prefixed_lines(output)
|
||||
sender_pub = Map.fetch!(lines, "SENDER_PUB")
|
||||
recipient_pub = Map.fetch!(lines, "RECIPIENT_PUB")
|
||||
gift = lines |> Map.fetch!("GIFT") |> JSON.decode!()
|
||||
rumor = lines |> Map.fetch!("RUMOR") |> JSON.decode!()
|
||||
|
||||
assert gift["kind"] == 1059
|
||||
assert ["p", recipient_pub] in Enum.map(gift["tags"], &Enum.take(&1, 2))
|
||||
|
||||
assert rumor["kind"] == 14
|
||||
assert rumor["pubkey"] == sender_pub
|
||||
assert rumor["content"] == "hello from nak"
|
||||
assert ["p", recipient_pub] in Enum.map(rumor["tags"], &Enum.take(&1, 2))
|
||||
|
||||
assert Map.fetch!(lines, "NIP04") == "legacy hello from nak"
|
||||
end
|
||||
|
||||
test "algia can consume Nostr event JSON with isolated config" do
|
||||
require_executable!("nak")
|
||||
require_executable!("algia")
|
||||
|
||||
output =
|
||||
run_script!(~S'''
|
||||
set -euo pipefail
|
||||
home=$(mktemp -d)
|
||||
mkdir -p "$home/.config/algia"
|
||||
sec=$(nak key generate)
|
||||
nsec=$(nak encode nsec "$sec")
|
||||
printf '{"privateKey":"%s","relays":{}}\n' "$nsec" > "$home/.config/algia/config.json"
|
||||
|
||||
nak event --sec "$sec" -k 1 -c "hello from algia cat" </dev/null 2>/dev/null | HOME="$home" algia cat --json 2>/dev/null
|
||||
''')
|
||||
|
||||
event = JSON.decode!(String.trim(output))
|
||||
|
||||
assert event["kind"] == 1
|
||||
assert event["content"] == "hello from algia cat"
|
||||
assert is_binary(event["id"])
|
||||
assert is_binary(event["sig"])
|
||||
end
|
||||
|
||||
defp require_executable!(name) do
|
||||
unless System.find_executable(name) do
|
||||
flunk("expected #{name} in PATH")
|
||||
end
|
||||
end
|
||||
|
||||
defp run_script!(script) do
|
||||
case System.cmd("timeout", ["30s", "bash", "-lc", script], stderr_to_stdout: true) do
|
||||
{output, 0} -> output
|
||||
{output, status} -> flunk("script failed with status #{status}:\n#{output}")
|
||||
end
|
||||
end
|
||||
|
||||
defp prefixed_lines(output) do
|
||||
output
|
||||
|> String.split("\n", trim: true)
|
||||
|> Map.new(fn line ->
|
||||
[key, value] = String.split(line, "\t", parts: 2)
|
||||
{key, value}
|
||||
end)
|
||||
end
|
||||
end
|
||||
@@ -1,11 +1,12 @@
|
||||
defmodule MyPlugin.ManifestTest do
|
||||
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 = Jason.decode!(content)
|
||||
manifest = JSON.decode!(content)
|
||||
%{manifest: manifest}
|
||||
end
|
||||
|
||||
@@ -15,7 +16,17 @@ defmodule MyPlugin.ManifestTest do
|
||||
end
|
||||
|
||||
test "has required fields", %{manifest: manifest} do
|
||||
required = ["name", "version", "entry_module", "host_api", "provides", "requires"]
|
||||
required = [
|
||||
"id",
|
||||
"slug",
|
||||
"display_name",
|
||||
"version",
|
||||
"entry_module",
|
||||
"host_api",
|
||||
"otp_app",
|
||||
"provides",
|
||||
"requires"
|
||||
]
|
||||
|
||||
for field <- required do
|
||||
assert Map.has_key?(manifest, field),
|
||||
@@ -23,37 +34,49 @@ defmodule MyPlugin.ManifestTest do
|
||||
end
|
||||
end
|
||||
|
||||
test "name matches OTP app name", %{manifest: manifest} do
|
||||
assert manifest["name"] == "my_plugin"
|
||||
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 is a valid Elixir module name", %{manifest: manifest} do
|
||||
test "entry_module uses the TribeOne namespace and Plugin suffix", %{manifest: manifest} do
|
||||
module_name = manifest["entry_module"]
|
||||
assert is_binary(module_name)
|
||||
assert String.starts_with?(module_name, "Elixir.") or not String.contains?(module_name, " ")
|
||||
|
||||
assert Regex.match?(
|
||||
~r/^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?(~r/^[a-z][a-z0-9_]*(@\d+)?$/, cap),
|
||||
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?(~r/^[a-z][a-z0-9_]*(@\d+)?$/, cap),
|
||||
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"
|
||||
|
||||
assert Code.ensure_loaded?(module),
|
||||
"entry_module #{manifest["entry_module"]} must be loadable"
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,11 @@
|
||||
defmodule TribeOne.TribesPlugin.Aether.MarmotAssetsTest do
|
||||
use ExUnit.Case, async: true
|
||||
|
||||
@package_path Path.expand("../../assets/package.json", __DIR__)
|
||||
|
||||
test "pins marmot-ts to the selected client version" do
|
||||
package = @package_path |> File.read!() |> JSON.decode!()
|
||||
|
||||
assert get_in(package, ["dependencies", "@internet-privacy/marmot-ts"]) == "0.5.1"
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,3 @@
|
||||
defmodule TribeOne.TribesPlugin.Aether.PluginContractTest do
|
||||
use Tribes.PluginTest.ContractTest, plugin: TribeOne.TribesPlugin.Aether.Plugin
|
||||
end
|
||||
@@ -0,0 +1,44 @@
|
||||
defmodule TribeOne.TribesPlugin.Aether.TimelinePageTest do
|
||||
use Tribes.PluginTest.PageCase, plugin: TribeOne.TribesPlugin.Aether.Plugin
|
||||
|
||||
test "renders the timeline for signed-out visitors", %{conn: conn} do
|
||||
{:ok, _view, html} = live(conn, "/aether")
|
||||
|
||||
assert html =~ "Aether"
|
||||
assert html =~ "Sign in to publish notes from your local identity."
|
||||
end
|
||||
|
||||
test "renders the posting form for signed-in users", %{signed_in_conn: conn, current_user: user} do
|
||||
{:ok, view, _html} = live(conn, "/aether")
|
||||
|
||||
assert has_element?(view, "#note-form")
|
||||
assert has_element?(view, "#post-note-button", "Post note")
|
||||
assert is_binary(user.username)
|
||||
end
|
||||
|
||||
test "handles batched Parrhesia note events", %{signed_in_conn: conn, current_user: user} do
|
||||
{:ok, view, _html} = live(conn, "/aether")
|
||||
|
||||
send(view.pid, {:parrhesia, :events, make_ref(), "aether", [note_event(user.pubkey_hex)]})
|
||||
|
||||
assert render(view) =~ "Streamed batch note"
|
||||
end
|
||||
|
||||
test "dispatches top-level subpaths to the timeline page", %{conn: conn} do
|
||||
{:ok, _view, html} = live(conn, "/aether/tribe-123")
|
||||
|
||||
assert html =~ "Aether"
|
||||
end
|
||||
|
||||
defp note_event(pubkey) do
|
||||
%{
|
||||
"id" => "batch-note-#{System.unique_integer([:positive])}",
|
||||
"pubkey" => pubkey,
|
||||
"created_at" => 1_777_237_381,
|
||||
"kind" => 1,
|
||||
"tags" => [],
|
||||
"content" => "Streamed batch note",
|
||||
"sig" => "test-signature"
|
||||
}
|
||||
end
|
||||
end
|
||||
@@ -1,86 +0,0 @@
|
||||
defmodule MyPlugin.ContractTest do
|
||||
@moduledoc """
|
||||
Contract compliance tests.
|
||||
|
||||
These verify that the plugin conforms to the Tribes plugin contract.
|
||||
When Tribes.Plugin.ContractTest is available (host dep loaded),
|
||||
replace this file with:
|
||||
|
||||
defmodule MyPlugin.ContractTest do
|
||||
use Tribes.Plugin.ContractTest,
|
||||
plugin: MyPlugin.Plugin,
|
||||
otp_app: :my_plugin
|
||||
end
|
||||
|
||||
Until then, this file provides a standalone equivalent.
|
||||
"""
|
||||
|
||||
use ExUnit.Case, async: true
|
||||
|
||||
@plugin MyPlugin.Plugin
|
||||
@manifest_path Path.join(__DIR__, "../manifest.json") |> Path.expand()
|
||||
|
||||
setup_all do
|
||||
manifest = @manifest_path |> File.read!() |> Jason.decode!()
|
||||
spec = @plugin.register(%{pubsub: nil, repo: nil})
|
||||
%{manifest: manifest, spec: spec}
|
||||
end
|
||||
|
||||
test "runtime provides matches manifest provides", %{manifest: manifest, spec: spec} do
|
||||
normalise = fn cap ->
|
||||
if String.contains?(cap, "@"), do: cap, else: "#{cap}@1"
|
||||
end
|
||||
|
||||
manifest_caps = manifest["provides"] |> Enum.map(normalise) |> Enum.sort()
|
||||
runtime_caps = spec.provides |> Enum.map(normalise) |> Enum.sort()
|
||||
|
||||
assert manifest_caps == runtime_caps,
|
||||
"manifest provides #{inspect(manifest_caps)} != runtime provides #{inspect(runtime_caps)}"
|
||||
end
|
||||
|
||||
test "runtime requires matches manifest requires", %{manifest: manifest, spec: spec} do
|
||||
normalise = fn cap ->
|
||||
if String.contains?(cap, "@"), do: cap, else: "#{cap}@1"
|
||||
end
|
||||
|
||||
manifest_caps = manifest["requires"] |> Enum.map(normalise) |> Enum.sort()
|
||||
runtime_caps = spec.requires |> Enum.map(normalise) |> Enum.sort()
|
||||
|
||||
assert manifest_caps == runtime_caps,
|
||||
"manifest requires #{inspect(manifest_caps)} != runtime requires #{inspect(runtime_caps)}"
|
||||
end
|
||||
|
||||
test "spec has all required keys", %{spec: spec} do
|
||||
required_keys = [
|
||||
:name,
|
||||
:version,
|
||||
:provides,
|
||||
:requires,
|
||||
:nav_items,
|
||||
:pages,
|
||||
:children,
|
||||
:global_js,
|
||||
:global_css,
|
||||
:migrations_path,
|
||||
:hooks
|
||||
]
|
||||
|
||||
for key <- required_keys do
|
||||
assert Map.has_key?(spec, key), "spec must contain #{inspect(key)}"
|
||||
end
|
||||
end
|
||||
|
||||
test "name in spec matches manifest", %{manifest: manifest, spec: spec} do
|
||||
assert spec.name == manifest["name"]
|
||||
end
|
||||
|
||||
test "migrations_path exists if manifest declares migrations", %{manifest: manifest, spec: spec} do
|
||||
if manifest["migrations"] do
|
||||
assert is_binary(spec.migrations_path),
|
||||
"migrations_path must be set when manifest declares migrations: true"
|
||||
|
||||
assert File.dir?(spec.migrations_path),
|
||||
"migrations_path #{inspect(spec.migrations_path)} must be an existing directory"
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,63 +0,0 @@
|
||||
defmodule MyPlugin.PluginTest do
|
||||
use ExUnit.Case, async: true
|
||||
|
||||
describe "register/1" do
|
||||
setup do
|
||||
context = %{pubsub: nil, repo: nil}
|
||||
%{spec: MyPlugin.Plugin.register(context)}
|
||||
end
|
||||
|
||||
test "returns plugin name and version", %{spec: spec} do
|
||||
assert spec.name == "my_plugin"
|
||||
assert is_binary(spec.version)
|
||||
end
|
||||
|
||||
test "provides is a list of capability strings", %{spec: spec} do
|
||||
assert is_list(spec.provides)
|
||||
|
||||
for cap <- spec.provides do
|
||||
assert is_binary(cap), "capability must be a string, got: #{inspect(cap)}"
|
||||
end
|
||||
end
|
||||
|
||||
test "requires is a list of capability strings", %{spec: spec} do
|
||||
assert is_list(spec.requires)
|
||||
|
||||
for cap <- spec.requires do
|
||||
assert is_binary(cap), "capability must be a string, got: #{inspect(cap)}"
|
||||
end
|
||||
end
|
||||
|
||||
test "nav items have required fields", %{spec: spec} do
|
||||
assert is_list(spec.nav_items)
|
||||
|
||||
for item <- spec.nav_items do
|
||||
assert is_binary(item.label), "nav item must have a label"
|
||||
assert is_binary(item.path), "nav item must have a path"
|
||||
assert is_integer(item.order), "nav item must have an integer order"
|
||||
end
|
||||
end
|
||||
|
||||
test "pages reference existing modules", %{spec: spec} do
|
||||
assert is_list(spec.pages)
|
||||
|
||||
for page <- spec.pages do
|
||||
assert is_binary(page.path), "page must have a path"
|
||||
assert is_atom(page.live_view), "page must have a live_view module"
|
||||
assert Code.ensure_loaded?(page.live_view), "module #{page.live_view} must be loadable"
|
||||
end
|
||||
end
|
||||
|
||||
test "children are valid child specs", %{spec: spec} do
|
||||
assert is_list(spec.children)
|
||||
end
|
||||
|
||||
test "asset lists are string lists", %{spec: spec} do
|
||||
assert is_list(spec.global_js)
|
||||
assert is_list(spec.global_css)
|
||||
|
||||
for js <- spec.global_js, do: assert(is_binary(js))
|
||||
for css <- spec.global_css, do: assert(is_binary(css))
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1 +1,10 @@
|
||||
ExUnit.start()
|
||||
|
||||
{:ok, _apps} = Application.ensure_all_started(:tribes)
|
||||
|
||||
Ecto.Migrator.run(
|
||||
Tribes.Repo,
|
||||
Path.expand("../priv/repo/migrations", __DIR__),
|
||||
:up,
|
||||
all: true
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user