From 454e80a8f862fc4fcec03eda5b77f1497b2a8539 Mon Sep 17 00:00:00 2001
From: Maxfield Luke
Date: Thu, 30 Jul 2026 01:36:18 -0400
Subject: [PATCH] Initial commit
Fresh repository on git.elektrine.com/elektrine/elektrine.
---
.credo.exs | 170 +
.dockerignore | 71 +
.env.example | 292 +
.forgejo/README.md | 41 +
.forgejo/workflows/ci.yml | 129 +
.forgejo/workflows/docker-deploy.yml | 373 +
.forgejo/workflows/smoke.yml | 21 +
.formatter.exs | 12 +
.github/ISSUE_TEMPLATE/bug_report.yml | 61 +
.github/ISSUE_TEMPLATE/config.yml | 5 +
.github/ISSUE_TEMPLATE/feature_request.yml | 45 +
.github/dependabot.yml | 36 +
.github/pull_request_template.md | 35 +
.github/workflows/ci.yml | 119 +
.github/workflows/docker-deploy.yml | 314 +
.gitignore | 85 +
AGENTS.md | 127 +
CODE_OF_CONDUCT.md | 56 +
CONTRIBUTING.md | 66 +
LICENSE | 663 +
README.md | 206 +
SECURITY.md | 30 +
apps/README.md | 44 +
apps/arblarg/.formatter.exs | 4 +
apps/arblarg/.gitignore | 24 +
apps/arblarg/README.md | 20 +
apps/arblarg/lib/arblarg.ex | 138 +
apps/arblarg/lib/arblarg/application.ex | 20 +
apps/arblarg/lib/arblarg_web.ex | 96 +
.../lib/arblarg_web/components/chat/call.ex | 368 +
.../components/chat/sender_badge.ex | 46 +
.../arblarg_web/components/platform/e_nav.ex | 50 +
.../components/social/content_journey.ex | 11 +
.../components/social/embedded_post.ex | 11 +
.../arblarg_web/components/ui/image_modal.ex | 11 +
.../admin/chat_messages_controller.ex | 410 +
.../controllers/admin/chat_messages_html.ex | 19 +
.../chat_messages.html.heex | 389 +
.../view_chat_message.html.heex | 228 +
.../api/conversation_controller.ex | 907 +
.../controllers/api/ext_chat_controller.ex | 423 +
.../controllers/api/message_controller.ex | 473 +
.../controllers/api/server_controller.ex | 301 +
.../controllers/api/webhook_controller.ex | 255 +
.../arblarg_web/live/chat_live/bootstrap.ex | 104 +
.../chat_live/components/channel_modal.ex | 207 +
.../live/chat_live/components/group_modal.ex | 272 +
.../live/chat_live/handle_formatter.ex | 108 +
.../lib/arblarg_web/live/chat_live/index.ex | 3604 ++++
.../live/chat_live/index.html.heex | 2873 +++
.../operations/call_info_operations.ex | 103 +
.../chat_live/operations/call_operations.ex | 497 +
.../operations/context_menu_operations.ex | 102 +
.../operations/conversation_operations.ex | 417 +
.../operations/direct_message_operations.ex | 174 +
.../operations/emoji_gif_operations.ex | 43 +
.../operations/group_channel_operations.ex | 672 +
.../live/chat_live/operations/helpers.ex | 757 +
.../chat_live/operations/member_operations.ex | 322 +
.../operations/message_info_operations.ex | 413 +
.../operations/message_operations.ex | 1099 ++
.../chat_live/operations/thread_operations.ex | 319 +
.../chat_live/operations/ui_operations.ex | 260 +
.../operations/voice_channel_operations.ex | 242 +
.../lib/arblarg_web/live/chat_live/router.ex | 195 +
.../lib/arblarg_web/live/chat_live/state.ex | 159 +
.../lib/arblarg_web/optional_module.ex | 14 +
apps/arblarg/mix.exs | 52 +
apps/arblarg/test/arblarg_test.exs | 10 +
.../operations/emoji_gif_operations_test.exs | 21 +
apps/arblarg/test/test_helper.exs | 1 +
apps/atomine/lib/atomine.ex | 5 +
apps/atomine/lib/atomine/attestation.ex | 80 +
apps/atomine/lib/atomine/attestations.ex | 656 +
apps/atomine/lib/atomine/credit_account.ex | 40 +
.../lib/atomine/credit_earning_policy.ex | 344 +
.../lib/atomine/credit_ledger_entry.ex | 45 +
apps/atomine/lib/atomine/credit_policy.ex | 77 +
apps/atomine/lib/atomine/credit_spend.ex | 43 +
apps/atomine/lib/atomine/credits.ex | 244 +
.../lib/atomine/live_proof_recheck_worker.ex | 16 +
apps/atomine/lib/atomine/personhood.ex | 927 +
apps/atomine/lib/atomine/proof.ex | 204 +
apps/atomine/lib/atomine/scoring.ex | 228 +
apps/atomine/lib/atomine/trust_session.ex | 118 +
apps/atomine/mix.exs | 39 +
.../test/atomine/attestations_test.exs | 147 +
apps/atomine/test/atomine/credits_test.exs | 74 +
apps/atomine/test/atomine/personhood_test.exs | 680 +
apps/atomine/test/test_helper.exs | 2 +
apps/elektrine/.gitignore | 24 +
apps/elektrine/README.md | 24 +
apps/elektrine/assets/css/animations.css | 99 +
apps/elektrine/assets/css/app.css | 40 +
apps/elektrine/assets/css/base.css | 610 +
apps/elektrine/assets/css/components.css | 391 +
apps/elektrine/assets/css/effects.css | 444 +
apps/elektrine/assets/css/email.css | 245 +
apps/elektrine/assets/css/utilities.css | 541 +
apps/elektrine/assets/js/admin_security.js | 361 +
apps/elektrine/assets/js/app.js | 594 +
apps/elektrine/assets/js/blinkenlights.js | 233 +
apps/elektrine/assets/js/cursor_glow.js | 209 +
apps/elektrine/assets/js/e_nav.js | 57 +
apps/elektrine/assets/js/email_raw.js | 43 +
apps/elektrine/assets/js/error_page.js | 110 +
apps/elektrine/assets/js/form_helpers.js | 105 +
apps/elektrine/assets/js/hashtag_links.js | 77 +
.../assets/js/hooks/analytics_hooks.js | 231 +
.../assets/js/hooks/backup_codes_printer.js | 69 +
apps/elektrine/assets/js/hooks/call_hooks.js | 415 +
.../js/hooks/chat_context_menu_hooks.js | 199 +
.../assets/js/hooks/chat_e2ee_crypto.js | 312 +
.../assets/js/hooks/chat_e2ee_hook.js | 1298 ++
.../assets/js/hooks/chat_e2ee_messages.js | 92 +
apps/elektrine/assets/js/hooks/chat_hooks.js | 625 +
.../js/hooks/chat_voice_recorder_hook.js | 167 +
.../js/hooks/email_compose_shortcuts_hook.js | 264 +
apps/elektrine/assets/js/hooks/email_hooks.js | 739 +
.../js/hooks/email_iframe_resize_hook.js | 126 +
.../assets/js/hooks/email_shortcut_helpers.js | 37 +
.../assets/js/hooks/file_explorer_hook.js | 347 +
apps/elektrine/assets/js/hooks/form_hooks.js | 488 +
apps/elektrine/assets/js/hooks/index.js | 211 +
.../assets/js/hooks/kairo_graph_hook.js | 442 +
apps/elektrine/assets/js/hooks/kairo_hooks.js | 213 +
.../js/hooks/mailbox_private_auth_forms.js | 112 +
.../js/hooks/mailbox_private_auth_payloads.js | 68 +
.../js/hooks/mailbox_private_compose_hook.js | 198 +
.../js/hooks/mailbox_private_content.js | 301 +
.../assets/js/hooks/mailbox_private_crypto.js | 435 +
.../js/hooks/mailbox_private_messages_hook.js | 242 +
.../js/hooks/mailbox_private_storage_hooks.js | 538 +
.../assets/js/hooks/markdown_hooks.js | 308 +
apps/elektrine/assets/js/hooks/nerve_hooks.js | 275 +
.../assets/js/hooks/notification_hooks.js | 75 +
.../js/hooks/notification_visibility.js | 124 +
.../assets/js/hooks/paige_search_hook.js | 73 +
.../assets/js/hooks/passkey_hooks.js | 278 +
.../assets/js/hooks/portal_dropdowns.js | 272 +
.../assets/js/hooks/presence_hooks.js | 133 +
.../assets/js/hooks/profile_hooks.js | 279 +
.../js/hooks/profile_link_reorder_hook.js | 64 +
.../assets/js/hooks/proof_graph_dom.js | 18 +
.../assets/js/hooks/proof_graph_hook.js | 599 +
.../assets/js/hooks/proof_graph_paints.js | 215 +
.../assets/js/hooks/proof_graph_styles.js | 119 +
.../assets/js/hooks/static_site_hooks.js | 86 +
.../assets/js/hooks/timeline_hooks.js | 714 +
.../assets/js/hooks/timeline_media_hooks.js | 157 +
.../js/hooks/timeline_preservation_hooks.js | 306 +
.../js/hooks/timeline_session_continuity.js | 112 +
.../assets/js/hooks/timeline_status_hooks.js | 195 +
apps/elektrine/assets/js/hooks/ui_hooks.js | 535 +
.../elektrine/assets/js/hooks/vault_crypto.js | 178 +
apps/elektrine/assets/js/hooks/vault_hooks.js | 304 +
.../assets/js/hooks/vault_session.js | 100 +
.../assets/js/hooks/voice_channel_hook.js | 403 +
.../assets/js/hooks/web_push_hook.js | 98 +
apps/elektrine/assets/js/ip_lookup.js | 120 +
apps/elektrine/assets/js/live_clock.js | 28 +
apps/elektrine/assets/js/markdown_editor.js | 258 +
apps/elektrine/assets/js/markdown_helpers.js | 178 +
apps/elektrine/assets/js/markdown_toolbar.js | 281 +
apps/elektrine/assets/js/modal_controls.js | 17 +
.../assets/js/notification_system.js | 523 +
apps/elektrine/assets/js/profile_static.js | 644 +
apps/elektrine/assets/js/ringtone_player.js | 225 +
apps/elektrine/assets/js/tagline_cycler.js | 24 +
apps/elektrine/assets/js/theme.js | 171 +
apps/elektrine/assets/js/utils/clipboard.js | 152 +
apps/elektrine/assets/js/utils/dropdown.js | 49 +
.../assets/js/utils/email_selection.js | 79 +
.../assets/js/utils/form_submission.js | 19 +
apps/elektrine/assets/js/utils/index.js | 9 +
.../assets/js/utils/overlay_portal.js | 85 +
apps/elektrine/assets/js/utils/spinner.js | 20 +
.../assets/js/utils/user_hover_card_scroll.js | 26 +
apps/elektrine/assets/js/webrtc_client.js | 638 +
apps/elektrine/assets/package-lock.json | 1204 ++
apps/elektrine/assets/package.json | 19 +
apps/elektrine/assets/scripts/check_js.mjs | 154 +
apps/elektrine/assets/tailwind.config.mjs | 90 +
.../elektrine/assets/vendor/heroicons/LICENSE | 21 +
.../assets/vendor/heroicons/SOURCE.md | 9 +
.../optimized/16/solid/academic-cap.svg | 4 +
.../16/solid/adjustments-horizontal.svg | 3 +
.../16/solid/adjustments-vertical.svg | 3 +
.../16/solid/archive-box-arrow-down.svg | 4 +
.../optimized/16/solid/archive-box-x-mark.svg | 4 +
.../optimized/16/solid/archive-box.svg | 4 +
.../optimized/16/solid/arrow-down-circle.svg | 3 +
.../optimized/16/solid/arrow-down-left.svg | 3 +
.../16/solid/arrow-down-on-square-stack.svg | 4 +
.../16/solid/arrow-down-on-square.svg | 3 +
.../optimized/16/solid/arrow-down-right.svg | 3 +
.../optimized/16/solid/arrow-down-tray.svg | 4 +
.../optimized/16/solid/arrow-down.svg | 3 +
.../optimized/16/solid/arrow-left-circle.svg | 3 +
.../16/solid/arrow-left-end-on-rectangle.svg | 4 +
.../solid/arrow-left-start-on-rectangle.svg | 3 +
.../optimized/16/solid/arrow-left.svg | 3 +
.../optimized/16/solid/arrow-long-down.svg | 3 +
.../optimized/16/solid/arrow-long-left.svg | 3 +
.../optimized/16/solid/arrow-long-right.svg | 3 +
.../optimized/16/solid/arrow-long-up.svg | 3 +
.../16/solid/arrow-path-rounded-square.svg | 3 +
.../optimized/16/solid/arrow-path.svg | 3 +
.../optimized/16/solid/arrow-right-circle.svg | 3 +
.../16/solid/arrow-right-end-on-rectangle.svg | 4 +
.../solid/arrow-right-start-on-rectangle.svg | 3 +
.../optimized/16/solid/arrow-right.svg | 3 +
.../16/solid/arrow-top-right-on-square.svg | 4 +
.../16/solid/arrow-trending-down.svg | 3 +
.../optimized/16/solid/arrow-trending-up.svg | 3 +
.../16/solid/arrow-turn-down-left.svg | 3 +
.../16/solid/arrow-turn-down-right.svg | 3 +
.../16/solid/arrow-turn-left-down.svg | 3 +
.../optimized/16/solid/arrow-turn-left-up.svg | 3 +
.../16/solid/arrow-turn-right-down.svg | 3 +
.../16/solid/arrow-turn-right-up.svg | 3 +
.../optimized/16/solid/arrow-turn-up-left.svg | 3 +
.../16/solid/arrow-turn-up-right.svg | 3 +
.../optimized/16/solid/arrow-up-circle.svg | 3 +
.../optimized/16/solid/arrow-up-left.svg | 3 +
.../16/solid/arrow-up-on-square-stack.svg | 4 +
.../optimized/16/solid/arrow-up-on-square.svg | 3 +
.../optimized/16/solid/arrow-up-right.svg | 3 +
.../optimized/16/solid/arrow-up-tray.svg | 4 +
.../heroicons/optimized/16/solid/arrow-up.svg | 3 +
.../optimized/16/solid/arrow-uturn-down.svg | 3 +
.../optimized/16/solid/arrow-uturn-left.svg | 3 +
.../optimized/16/solid/arrow-uturn-right.svg | 3 +
.../optimized/16/solid/arrow-uturn-up.svg | 3 +
.../optimized/16/solid/arrows-pointing-in.svg | 3 +
.../16/solid/arrows-pointing-out.svg | 3 +
.../optimized/16/solid/arrows-right-left.svg | 3 +
.../optimized/16/solid/arrows-up-down.svg | 3 +
.../optimized/16/solid/at-symbol.svg | 3 +
.../optimized/16/solid/backspace.svg | 3 +
.../heroicons/optimized/16/solid/backward.svg | 3 +
.../optimized/16/solid/banknotes.svg | 4 +
.../heroicons/optimized/16/solid/bars-2.svg | 3 +
.../optimized/16/solid/bars-3-bottom-left.svg | 3 +
.../16/solid/bars-3-bottom-right.svg | 3 +
.../optimized/16/solid/bars-3-center-left.svg | 3 +
.../heroicons/optimized/16/solid/bars-3.svg | 3 +
.../heroicons/optimized/16/solid/bars-4.svg | 3 +
.../optimized/16/solid/bars-arrow-down.svg | 3 +
.../optimized/16/solid/bars-arrow-up.svg | 3 +
.../optimized/16/solid/battery-0.svg | 3 +
.../optimized/16/solid/battery-100.svg | 4 +
.../optimized/16/solid/battery-50.svg | 4 +
.../heroicons/optimized/16/solid/beaker.svg | 3 +
.../optimized/16/solid/bell-alert.svg | 4 +
.../optimized/16/solid/bell-slash.svg | 4 +
.../optimized/16/solid/bell-snooze.svg | 3 +
.../heroicons/optimized/16/solid/bell.svg | 3 +
.../heroicons/optimized/16/solid/bold.svg | 3 +
.../optimized/16/solid/bolt-slash.svg | 3 +
.../heroicons/optimized/16/solid/bolt.svg | 3 +
.../optimized/16/solid/book-open.svg | 3 +
.../optimized/16/solid/bookmark-slash.svg | 3 +
.../optimized/16/solid/bookmark-square.svg | 3 +
.../heroicons/optimized/16/solid/bookmark.svg | 3 +
.../optimized/16/solid/briefcase.svg | 4 +
.../heroicons/optimized/16/solid/bug-ant.svg | 3 +
.../optimized/16/solid/building-library.svg | 3 +
.../optimized/16/solid/building-office-2.svg | 3 +
.../optimized/16/solid/building-office.svg | 3 +
.../16/solid/building-storefront.svg | 3 +
.../heroicons/optimized/16/solid/cake.svg | 3 +
.../optimized/16/solid/calculator.svg | 3 +
.../16/solid/calendar-date-range.svg | 4 +
.../optimized/16/solid/calendar-days.svg | 4 +
.../heroicons/optimized/16/solid/calendar.svg | 3 +
.../heroicons/optimized/16/solid/camera.svg | 4 +
.../optimized/16/solid/chart-bar-square.svg | 3 +
.../optimized/16/solid/chart-bar.svg | 3 +
.../optimized/16/solid/chart-pie.svg | 4 +
.../solid/chat-bubble-bottom-center-text.svg | 3 +
.../16/solid/chat-bubble-bottom-center.svg | 3 +
.../16/solid/chat-bubble-left-ellipsis.svg | 3 +
.../16/solid/chat-bubble-left-right.svg | 4 +
.../optimized/16/solid/chat-bubble-left.svg | 3 +
.../solid/chat-bubble-oval-left-ellipsis.svg | 3 +
.../16/solid/chat-bubble-oval-left.svg | 3 +
.../optimized/16/solid/check-badge.svg | 3 +
.../optimized/16/solid/check-circle.svg | 3 +
.../heroicons/optimized/16/solid/check.svg | 3 +
.../16/solid/chevron-double-down.svg | 3 +
.../16/solid/chevron-double-left.svg | 3 +
.../16/solid/chevron-double-right.svg | 3 +
.../optimized/16/solid/chevron-double-up.svg | 3 +
.../optimized/16/solid/chevron-down.svg | 3 +
.../optimized/16/solid/chevron-left.svg | 3 +
.../optimized/16/solid/chevron-right.svg | 3 +
.../optimized/16/solid/chevron-up-down.svg | 3 +
.../optimized/16/solid/chevron-up.svg | 3 +
.../optimized/16/solid/circle-stack.svg | 5 +
.../16/solid/clipboard-document-check.svg | 4 +
.../16/solid/clipboard-document-list.svg | 4 +
.../optimized/16/solid/clipboard-document.svg | 4 +
.../optimized/16/solid/clipboard.svg | 3 +
.../heroicons/optimized/16/solid/clock.svg | 3 +
.../optimized/16/solid/cloud-arrow-down.svg | 3 +
.../optimized/16/solid/cloud-arrow-up.svg | 3 +
.../heroicons/optimized/16/solid/cloud.svg | 3 +
.../16/solid/code-bracket-square.svg | 3 +
.../optimized/16/solid/code-bracket.svg | 3 +
.../optimized/16/solid/cog-6-tooth.svg | 3 +
.../optimized/16/solid/cog-8-tooth.svg | 3 +
.../heroicons/optimized/16/solid/cog.svg | 3 +
.../optimized/16/solid/command-line.svg | 3 +
.../optimized/16/solid/computer-desktop.svg | 3 +
.../heroicons/optimized/16/solid/cpu-chip.svg | 4 +
.../optimized/16/solid/credit-card.svg | 4 +
.../optimized/16/solid/cube-transparent.svg | 3 +
.../heroicons/optimized/16/solid/cube.svg | 3 +
.../16/solid/currency-bangladeshi.svg | 3 +
.../optimized/16/solid/currency-dollar.svg | 4 +
.../optimized/16/solid/currency-euro.svg | 3 +
.../optimized/16/solid/currency-pound.svg | 3 +
.../optimized/16/solid/currency-rupee.svg | 3 +
.../optimized/16/solid/currency-yen.svg | 3 +
.../optimized/16/solid/cursor-arrow-rays.svg | 3 +
.../16/solid/cursor-arrow-ripple.svg | 5 +
.../16/solid/device-phone-mobile.svg | 4 +
.../optimized/16/solid/device-tablet.svg | 4 +
.../heroicons/optimized/16/solid/divide.svg | 4 +
.../16/solid/document-arrow-down.svg | 3 +
.../optimized/16/solid/document-arrow-up.svg | 3 +
.../optimized/16/solid/document-chart-bar.svg | 3 +
.../optimized/16/solid/document-check.svg | 3 +
.../solid/document-currency-bangladeshi.svg | 3 +
.../16/solid/document-currency-dollar.svg | 4 +
.../16/solid/document-currency-euro.svg | 3 +
.../16/solid/document-currency-pound.svg | 3 +
.../16/solid/document-currency-rupee.svg | 3 +
.../16/solid/document-currency-yen.svg | 3 +
.../optimized/16/solid/document-duplicate.svg | 4 +
.../16/solid/document-magnifying-glass.svg | 4 +
.../optimized/16/solid/document-minus.svg | 3 +
.../optimized/16/solid/document-plus.svg | 3 +
.../optimized/16/solid/document-text.svg | 3 +
.../heroicons/optimized/16/solid/document.svg | 3 +
.../16/solid/ellipsis-horizontal-circle.svg | 3 +
.../16/solid/ellipsis-horizontal.svg | 3 +
.../optimized/16/solid/ellipsis-vertical.svg | 3 +
.../optimized/16/solid/envelope-open.svg | 3 +
.../heroicons/optimized/16/solid/envelope.svg | 4 +
.../heroicons/optimized/16/solid/equals.svg | 3 +
.../optimized/16/solid/exclamation-circle.svg | 3 +
.../16/solid/exclamation-triangle.svg | 3 +
.../optimized/16/solid/eye-dropper.svg | 3 +
.../optimized/16/solid/eye-slash.svg | 4 +
.../heroicons/optimized/16/solid/eye.svg | 4 +
.../optimized/16/solid/face-frown.svg | 3 +
.../optimized/16/solid/face-smile.svg | 3 +
.../heroicons/optimized/16/solid/film.svg | 3 +
.../optimized/16/solid/finger-print.svg | 3 +
.../heroicons/optimized/16/solid/fire.svg | 3 +
.../heroicons/optimized/16/solid/flag.svg | 3 +
.../optimized/16/solid/folder-arrow-down.svg | 3 +
.../optimized/16/solid/folder-minus.svg | 3 +
.../optimized/16/solid/folder-open.svg | 3 +
.../optimized/16/solid/folder-plus.svg | 3 +
.../heroicons/optimized/16/solid/folder.svg | 3 +
.../heroicons/optimized/16/solid/forward.svg | 3 +
.../heroicons/optimized/16/solid/funnel.svg | 3 +
.../heroicons/optimized/16/solid/gif.svg | 3 +
.../heroicons/optimized/16/solid/gift-top.svg | 4 +
.../heroicons/optimized/16/solid/gift.svg | 4 +
.../optimized/16/solid/globe-alt.svg | 3 +
.../optimized/16/solid/globe-americas.svg | 3 +
.../16/solid/globe-asia-australia.svg | 3 +
.../16/solid/globe-europe-africa.svg | 3 +
.../heroicons/optimized/16/solid/h1.svg | 3 +
.../heroicons/optimized/16/solid/h2.svg | 3 +
.../heroicons/optimized/16/solid/h3.svg | 3 +
.../optimized/16/solid/hand-raised.svg | 3 +
.../optimized/16/solid/hand-thumb-down.svg | 3 +
.../optimized/16/solid/hand-thumb-up.svg | 3 +
.../heroicons/optimized/16/solid/hashtag.svg | 3 +
.../heroicons/optimized/16/solid/heart.svg | 3 +
.../optimized/16/solid/home-modern.svg | 3 +
.../heroicons/optimized/16/solid/home.svg | 3 +
.../optimized/16/solid/identification.svg | 3 +
.../optimized/16/solid/inbox-arrow-down.svg | 4 +
.../optimized/16/solid/inbox-stack.svg | 4 +
.../heroicons/optimized/16/solid/inbox.svg | 3 +
.../optimized/16/solid/information-circle.svg | 3 +
.../heroicons/optimized/16/solid/italic.svg | 3 +
.../heroicons/optimized/16/solid/key.svg | 3 +
.../heroicons/optimized/16/solid/language.svg | 3 +
.../heroicons/optimized/16/solid/lifebuoy.svg | 3 +
.../optimized/16/solid/light-bulb.svg | 3 +
.../optimized/16/solid/link-slash.svg | 3 +
.../heroicons/optimized/16/solid/link.svg | 4 +
.../optimized/16/solid/list-bullet.svg | 3 +
.../optimized/16/solid/lock-closed.svg | 3 +
.../optimized/16/solid/lock-open.svg | 3 +
.../16/solid/magnifying-glass-circle.svg | 4 +
.../16/solid/magnifying-glass-minus.svg | 4 +
.../16/solid/magnifying-glass-plus.svg | 4 +
.../optimized/16/solid/magnifying-glass.svg | 3 +
.../heroicons/optimized/16/solid/map-pin.svg | 3 +
.../heroicons/optimized/16/solid/map.svg | 3 +
.../optimized/16/solid/megaphone.svg | 3 +
.../optimized/16/solid/microphone.svg | 4 +
.../optimized/16/solid/minus-circle.svg | 3 +
.../heroicons/optimized/16/solid/minus.svg | 3 +
.../heroicons/optimized/16/solid/moon.svg | 3 +
.../optimized/16/solid/musical-note.svg | 3 +
.../optimized/16/solid/newspaper.svg | 4 +
.../optimized/16/solid/no-symbol.svg | 3 +
.../optimized/16/solid/numbered-list.svg | 3 +
.../optimized/16/solid/paint-brush.svg | 3 +
.../optimized/16/solid/paper-airplane.svg | 3 +
.../optimized/16/solid/paper-clip.svg | 3 +
.../optimized/16/solid/pause-circle.svg | 3 +
.../heroicons/optimized/16/solid/pause.svg | 3 +
.../optimized/16/solid/pencil-square.svg | 4 +
.../heroicons/optimized/16/solid/pencil.svg | 3 +
.../optimized/16/solid/percent-badge.svg | 3 +
.../16/solid/phone-arrow-down-left.svg | 4 +
.../16/solid/phone-arrow-up-right.svg | 4 +
.../optimized/16/solid/phone-x-mark.svg | 4 +
.../heroicons/optimized/16/solid/phone.svg | 3 +
.../heroicons/optimized/16/solid/photo.svg | 3 +
.../optimized/16/solid/play-circle.svg | 3 +
.../optimized/16/solid/play-pause.svg | 3 +
.../heroicons/optimized/16/solid/play.svg | 3 +
.../optimized/16/solid/plus-circle.svg | 3 +
.../heroicons/optimized/16/solid/plus.svg | 3 +
.../heroicons/optimized/16/solid/power.svg | 3 +
.../16/solid/presentation-chart-bar.svg | 3 +
.../16/solid/presentation-chart-line.svg | 3 +
.../heroicons/optimized/16/solid/printer.svg | 3 +
.../optimized/16/solid/puzzle-piece.svg | 3 +
.../heroicons/optimized/16/solid/qr-code.svg | 9 +
.../16/solid/question-mark-circle.svg | 3 +
.../optimized/16/solid/queue-list.svg | 3 +
.../heroicons/optimized/16/solid/radio.svg | 3 +
.../optimized/16/solid/receipt-percent.svg | 3 +
.../optimized/16/solid/receipt-refund.svg | 3 +
.../optimized/16/solid/rectangle-group.svg | 3 +
.../optimized/16/solid/rectangle-stack.svg | 3 +
.../optimized/16/solid/rocket-launch.svg | 4 +
.../heroicons/optimized/16/solid/rss.svg | 3 +
.../heroicons/optimized/16/solid/scale.svg | 3 +
.../heroicons/optimized/16/solid/scissors.svg | 4 +
.../optimized/16/solid/server-stack.svg | 4 +
.../heroicons/optimized/16/solid/server.svg | 4 +
.../heroicons/optimized/16/solid/share.svg | 3 +
.../optimized/16/solid/shield-check.svg | 3 +
.../optimized/16/solid/shield-exclamation.svg | 3 +
.../optimized/16/solid/shopping-bag.svg | 3 +
.../optimized/16/solid/shopping-cart.svg | 3 +
.../optimized/16/solid/signal-slash.svg | 3 +
.../heroicons/optimized/16/solid/signal.svg | 5 +
.../heroicons/optimized/16/solid/slash.svg | 3 +
.../heroicons/optimized/16/solid/sparkles.svg | 3 +
.../optimized/16/solid/speaker-wave.svg | 4 +
.../optimized/16/solid/speaker-x-mark.svg | 3 +
.../optimized/16/solid/square-2-stack.svg | 4 +
.../optimized/16/solid/square-3-stack-3d.svg | 5 +
.../optimized/16/solid/squares-2x2.svg | 3 +
.../optimized/16/solid/squares-plus.svg | 3 +
.../heroicons/optimized/16/solid/star.svg | 3 +
.../optimized/16/solid/stop-circle.svg | 3 +
.../heroicons/optimized/16/solid/stop.svg | 3 +
.../optimized/16/solid/strikethrough.svg | 3 +
.../heroicons/optimized/16/solid/sun.svg | 3 +
.../heroicons/optimized/16/solid/swatch.svg | 4 +
.../optimized/16/solid/table-cells.svg | 3 +
.../heroicons/optimized/16/solid/tag.svg | 3 +
.../heroicons/optimized/16/solid/ticket.svg | 3 +
.../heroicons/optimized/16/solid/trash.svg | 3 +
.../heroicons/optimized/16/solid/trophy.svg | 3 +
.../heroicons/optimized/16/solid/truck.svg | 5 +
.../heroicons/optimized/16/solid/tv.svg | 4 +
.../optimized/16/solid/underline.svg | 3 +
.../optimized/16/solid/user-circle.svg | 3 +
.../optimized/16/solid/user-group.svg | 3 +
.../optimized/16/solid/user-minus.svg | 3 +
.../optimized/16/solid/user-plus.svg | 3 +
.../heroicons/optimized/16/solid/user.svg | 3 +
.../heroicons/optimized/16/solid/users.svg | 3 +
.../heroicons/optimized/16/solid/variable.svg | 3 +
.../optimized/16/solid/video-camera-slash.svg | 3 +
.../optimized/16/solid/video-camera.svg | 3 +
.../optimized/16/solid/view-columns.svg | 3 +
.../optimized/16/solid/viewfinder-circle.svg | 3 +
.../heroicons/optimized/16/solid/wallet.svg | 3 +
.../heroicons/optimized/16/solid/wifi.svg | 3 +
.../heroicons/optimized/16/solid/window.svg | 3 +
.../optimized/16/solid/wrench-screwdriver.svg | 4 +
.../heroicons/optimized/16/solid/wrench.svg | 3 +
.../heroicons/optimized/16/solid/x-circle.svg | 3 +
.../heroicons/optimized/16/solid/x-mark.svg | 3 +
.../optimized/20/solid/academic-cap.svg | 3 +
.../20/solid/adjustments-horizontal.svg | 3 +
.../20/solid/adjustments-vertical.svg | 3 +
.../20/solid/archive-box-arrow-down.svg | 3 +
.../optimized/20/solid/archive-box-x-mark.svg | 4 +
.../optimized/20/solid/archive-box.svg | 4 +
.../optimized/20/solid/arrow-down-circle.svg | 3 +
.../optimized/20/solid/arrow-down-left.svg | 3 +
.../20/solid/arrow-down-on-square-stack.svg | 3 +
.../20/solid/arrow-down-on-square.svg | 3 +
.../optimized/20/solid/arrow-down-right.svg | 3 +
.../optimized/20/solid/arrow-down-tray.svg | 4 +
.../optimized/20/solid/arrow-down.svg | 3 +
.../optimized/20/solid/arrow-left-circle.svg | 3 +
.../20/solid/arrow-left-end-on-rectangle.svg | 4 +
.../20/solid/arrow-left-on-rectangle.svg | 4 +
.../solid/arrow-left-start-on-rectangle.svg | 4 +
.../optimized/20/solid/arrow-left.svg | 3 +
.../optimized/20/solid/arrow-long-down.svg | 3 +
.../optimized/20/solid/arrow-long-left.svg | 3 +
.../optimized/20/solid/arrow-long-right.svg | 3 +
.../optimized/20/solid/arrow-long-up.svg | 3 +
.../20/solid/arrow-path-rounded-square.svg | 3 +
.../optimized/20/solid/arrow-path.svg | 3 +
.../optimized/20/solid/arrow-right-circle.svg | 3 +
.../20/solid/arrow-right-end-on-rectangle.svg | 4 +
.../20/solid/arrow-right-on-rectangle.svg | 4 +
.../solid/arrow-right-start-on-rectangle.svg | 4 +
.../optimized/20/solid/arrow-right.svg | 3 +
.../optimized/20/solid/arrow-small-down.svg | 3 +
.../optimized/20/solid/arrow-small-left.svg | 3 +
.../optimized/20/solid/arrow-small-right.svg | 3 +
.../optimized/20/solid/arrow-small-up.svg | 3 +
.../20/solid/arrow-top-right-on-square.svg | 4 +
.../20/solid/arrow-trending-down.svg | 3 +
.../optimized/20/solid/arrow-trending-up.svg | 3 +
.../20/solid/arrow-turn-down-left.svg | 3 +
.../20/solid/arrow-turn-down-right.svg | 3 +
.../20/solid/arrow-turn-left-down.svg | 3 +
.../optimized/20/solid/arrow-turn-left-up.svg | 3 +
.../20/solid/arrow-turn-right-down.svg | 3 +
.../20/solid/arrow-turn-right-up.svg | 3 +
.../optimized/20/solid/arrow-turn-up-left.svg | 3 +
.../20/solid/arrow-turn-up-right.svg | 3 +
.../optimized/20/solid/arrow-up-circle.svg | 3 +
.../optimized/20/solid/arrow-up-left.svg | 3 +
.../20/solid/arrow-up-on-square-stack.svg | 3 +
.../optimized/20/solid/arrow-up-on-square.svg | 3 +
.../optimized/20/solid/arrow-up-right.svg | 3 +
.../optimized/20/solid/arrow-up-tray.svg | 4 +
.../heroicons/optimized/20/solid/arrow-up.svg | 3 +
.../optimized/20/solid/arrow-uturn-down.svg | 3 +
.../optimized/20/solid/arrow-uturn-left.svg | 3 +
.../optimized/20/solid/arrow-uturn-right.svg | 3 +
.../optimized/20/solid/arrow-uturn-up.svg | 3 +
.../optimized/20/solid/arrows-pointing-in.svg | 3 +
.../20/solid/arrows-pointing-out.svg | 3 +
.../optimized/20/solid/arrows-right-left.svg | 3 +
.../optimized/20/solid/arrows-up-down.svg | 3 +
.../optimized/20/solid/at-symbol.svg | 3 +
.../optimized/20/solid/backspace.svg | 3 +
.../heroicons/optimized/20/solid/backward.svg | 3 +
.../optimized/20/solid/banknotes.svg | 3 +
.../heroicons/optimized/20/solid/bars-2.svg | 3 +
.../optimized/20/solid/bars-3-bottom-left.svg | 3 +
.../20/solid/bars-3-bottom-right.svg | 3 +
.../optimized/20/solid/bars-3-center-left.svg | 3 +
.../heroicons/optimized/20/solid/bars-3.svg | 3 +
.../heroicons/optimized/20/solid/bars-4.svg | 3 +
.../optimized/20/solid/bars-arrow-down.svg | 3 +
.../optimized/20/solid/bars-arrow-up.svg | 3 +
.../optimized/20/solid/battery-0.svg | 3 +
.../optimized/20/solid/battery-100.svg | 4 +
.../optimized/20/solid/battery-50.svg | 4 +
.../heroicons/optimized/20/solid/beaker.svg | 3 +
.../optimized/20/solid/bell-alert.svg | 4 +
.../optimized/20/solid/bell-slash.svg | 3 +
.../optimized/20/solid/bell-snooze.svg | 3 +
.../heroicons/optimized/20/solid/bell.svg | 3 +
.../heroicons/optimized/20/solid/bold.svg | 3 +
.../optimized/20/solid/bolt-slash.svg | 4 +
.../heroicons/optimized/20/solid/bolt.svg | 3 +
.../optimized/20/solid/book-open.svg | 3 +
.../optimized/20/solid/bookmark-slash.svg | 3 +
.../optimized/20/solid/bookmark-square.svg | 3 +
.../heroicons/optimized/20/solid/bookmark.svg | 3 +
.../optimized/20/solid/briefcase.svg | 4 +
.../heroicons/optimized/20/solid/bug-ant.svg | 3 +
.../optimized/20/solid/building-library.svg | 3 +
.../optimized/20/solid/building-office-2.svg | 3 +
.../optimized/20/solid/building-office.svg | 3 +
.../20/solid/building-storefront.svg | 3 +
.../heroicons/optimized/20/solid/cake.svg | 3 +
.../optimized/20/solid/calculator.svg | 3 +
.../20/solid/calendar-date-range.svg | 4 +
.../optimized/20/solid/calendar-days.svg | 4 +
.../heroicons/optimized/20/solid/calendar.svg | 3 +
.../heroicons/optimized/20/solid/camera.svg | 3 +
.../optimized/20/solid/chart-bar-square.svg | 3 +
.../optimized/20/solid/chart-bar.svg | 3 +
.../optimized/20/solid/chart-pie.svg | 4 +
.../solid/chat-bubble-bottom-center-text.svg | 3 +
.../20/solid/chat-bubble-bottom-center.svg | 3 +
.../20/solid/chat-bubble-left-ellipsis.svg | 3 +
.../20/solid/chat-bubble-left-right.svg | 4 +
.../optimized/20/solid/chat-bubble-left.svg | 3 +
.../solid/chat-bubble-oval-left-ellipsis.svg | 3 +
.../20/solid/chat-bubble-oval-left.svg | 3 +
.../optimized/20/solid/check-badge.svg | 3 +
.../optimized/20/solid/check-circle.svg | 3 +
.../heroicons/optimized/20/solid/check.svg | 3 +
.../20/solid/chevron-double-down.svg | 3 +
.../20/solid/chevron-double-left.svg | 3 +
.../20/solid/chevron-double-right.svg | 3 +
.../optimized/20/solid/chevron-double-up.svg | 3 +
.../optimized/20/solid/chevron-down.svg | 3 +
.../optimized/20/solid/chevron-left.svg | 3 +
.../optimized/20/solid/chevron-right.svg | 3 +
.../optimized/20/solid/chevron-up-down.svg | 3 +
.../optimized/20/solid/chevron-up.svg | 3 +
.../optimized/20/solid/circle-stack.svg | 3 +
.../20/solid/clipboard-document-check.svg | 4 +
.../20/solid/clipboard-document-list.svg | 4 +
.../optimized/20/solid/clipboard-document.svg | 4 +
.../optimized/20/solid/clipboard.svg | 3 +
.../heroicons/optimized/20/solid/clock.svg | 3 +
.../optimized/20/solid/cloud-arrow-down.svg | 3 +
.../optimized/20/solid/cloud-arrow-up.svg | 3 +
.../heroicons/optimized/20/solid/cloud.svg | 3 +
.../20/solid/code-bracket-square.svg | 3 +
.../optimized/20/solid/code-bracket.svg | 3 +
.../optimized/20/solid/cog-6-tooth.svg | 3 +
.../optimized/20/solid/cog-8-tooth.svg | 3 +
.../heroicons/optimized/20/solid/cog.svg | 4 +
.../optimized/20/solid/command-line.svg | 3 +
.../optimized/20/solid/computer-desktop.svg | 3 +
.../heroicons/optimized/20/solid/cpu-chip.svg | 4 +
.../optimized/20/solid/credit-card.svg | 3 +
.../optimized/20/solid/cube-transparent.svg | 3 +
.../heroicons/optimized/20/solid/cube.svg | 3 +
.../20/solid/currency-bangladeshi.svg | 3 +
.../optimized/20/solid/currency-dollar.svg | 4 +
.../optimized/20/solid/currency-euro.svg | 3 +
.../optimized/20/solid/currency-pound.svg | 3 +
.../optimized/20/solid/currency-rupee.svg | 3 +
.../optimized/20/solid/currency-yen.svg | 3 +
.../optimized/20/solid/cursor-arrow-rays.svg | 3 +
.../20/solid/cursor-arrow-ripple.svg | 5 +
.../20/solid/device-phone-mobile.svg | 4 +
.../optimized/20/solid/device-tablet.svg | 3 +
.../heroicons/optimized/20/solid/divide.svg | 3 +
.../20/solid/document-arrow-down.svg | 3 +
.../optimized/20/solid/document-arrow-up.svg | 3 +
.../optimized/20/solid/document-chart-bar.svg | 3 +
.../optimized/20/solid/document-check.svg | 3 +
.../solid/document-currency-bangladeshi.svg | 3 +
.../20/solid/document-currency-dollar.svg | 3 +
.../20/solid/document-currency-euro.svg | 3 +
.../20/solid/document-currency-pound.svg | 3 +
.../20/solid/document-currency-rupee.svg | 3 +
.../20/solid/document-currency-yen.svg | 3 +
.../optimized/20/solid/document-duplicate.svg | 4 +
.../20/solid/document-magnifying-glass.svg | 4 +
.../optimized/20/solid/document-minus.svg | 3 +
.../optimized/20/solid/document-plus.svg | 3 +
.../optimized/20/solid/document-text.svg | 3 +
.../heroicons/optimized/20/solid/document.svg | 3 +
.../20/solid/ellipsis-horizontal-circle.svg | 3 +
.../20/solid/ellipsis-horizontal.svg | 3 +
.../optimized/20/solid/ellipsis-vertical.svg | 3 +
.../optimized/20/solid/envelope-open.svg | 3 +
.../heroicons/optimized/20/solid/envelope.svg | 4 +
.../heroicons/optimized/20/solid/equals.svg | 3 +
.../optimized/20/solid/exclamation-circle.svg | 3 +
.../20/solid/exclamation-triangle.svg | 3 +
.../optimized/20/solid/eye-dropper.svg | 3 +
.../optimized/20/solid/eye-slash.svg | 4 +
.../heroicons/optimized/20/solid/eye.svg | 4 +
.../optimized/20/solid/face-frown.svg | 3 +
.../optimized/20/solid/face-smile.svg | 3 +
.../heroicons/optimized/20/solid/film.svg | 3 +
.../optimized/20/solid/finger-print.svg | 3 +
.../heroicons/optimized/20/solid/fire.svg | 3 +
.../heroicons/optimized/20/solid/flag.svg | 3 +
.../optimized/20/solid/folder-arrow-down.svg | 3 +
.../optimized/20/solid/folder-minus.svg | 3 +
.../optimized/20/solid/folder-open.svg | 3 +
.../optimized/20/solid/folder-plus.svg | 3 +
.../heroicons/optimized/20/solid/folder.svg | 3 +
.../heroicons/optimized/20/solid/forward.svg | 3 +
.../heroicons/optimized/20/solid/funnel.svg | 3 +
.../heroicons/optimized/20/solid/gif.svg | 3 +
.../heroicons/optimized/20/solid/gift-top.svg | 3 +
.../heroicons/optimized/20/solid/gift.svg | 4 +
.../optimized/20/solid/globe-alt.svg | 3 +
.../optimized/20/solid/globe-americas.svg | 3 +
.../20/solid/globe-asia-australia.svg | 3 +
.../20/solid/globe-europe-africa.svg | 3 +
.../heroicons/optimized/20/solid/h1.svg | 3 +
.../heroicons/optimized/20/solid/h2.svg | 3 +
.../heroicons/optimized/20/solid/h3.svg | 3 +
.../optimized/20/solid/hand-raised.svg | 3 +
.../optimized/20/solid/hand-thumb-down.svg | 3 +
.../optimized/20/solid/hand-thumb-up.svg | 3 +
.../heroicons/optimized/20/solid/hashtag.svg | 3 +
.../heroicons/optimized/20/solid/heart.svg | 3 +
.../optimized/20/solid/home-modern.svg | 3 +
.../heroicons/optimized/20/solid/home.svg | 3 +
.../optimized/20/solid/identification.svg | 3 +
.../optimized/20/solid/inbox-arrow-down.svg | 4 +
.../optimized/20/solid/inbox-stack.svg | 4 +
.../heroicons/optimized/20/solid/inbox.svg | 3 +
.../optimized/20/solid/information-circle.svg | 3 +
.../heroicons/optimized/20/solid/italic.svg | 3 +
.../heroicons/optimized/20/solid/key.svg | 3 +
.../heroicons/optimized/20/solid/language.svg | 4 +
.../heroicons/optimized/20/solid/lifebuoy.svg | 3 +
.../optimized/20/solid/light-bulb.svg | 3 +
.../optimized/20/solid/link-slash.svg | 3 +
.../heroicons/optimized/20/solid/link.svg | 4 +
.../optimized/20/solid/list-bullet.svg | 3 +
.../optimized/20/solid/lock-closed.svg | 3 +
.../optimized/20/solid/lock-open.svg | 3 +
.../20/solid/magnifying-glass-circle.svg | 4 +
.../20/solid/magnifying-glass-minus.svg | 4 +
.../20/solid/magnifying-glass-plus.svg | 4 +
.../optimized/20/solid/magnifying-glass.svg | 3 +
.../heroicons/optimized/20/solid/map-pin.svg | 3 +
.../heroicons/optimized/20/solid/map.svg | 3 +
.../optimized/20/solid/megaphone.svg | 3 +
.../optimized/20/solid/microphone.svg | 4 +
.../optimized/20/solid/minus-circle.svg | 3 +
.../optimized/20/solid/minus-small.svg | 3 +
.../heroicons/optimized/20/solid/minus.svg | 3 +
.../heroicons/optimized/20/solid/moon.svg | 3 +
.../optimized/20/solid/musical-note.svg | 3 +
.../optimized/20/solid/newspaper.svg | 4 +
.../optimized/20/solid/no-symbol.svg | 3 +
.../optimized/20/solid/numbered-list.svg | 3 +
.../optimized/20/solid/paint-brush.svg | 3 +
.../optimized/20/solid/paper-airplane.svg | 3 +
.../optimized/20/solid/paper-clip.svg | 3 +
.../optimized/20/solid/pause-circle.svg | 3 +
.../heroicons/optimized/20/solid/pause.svg | 3 +
.../optimized/20/solid/pencil-square.svg | 4 +
.../heroicons/optimized/20/solid/pencil.svg | 3 +
.../optimized/20/solid/percent-badge.svg | 3 +
.../20/solid/phone-arrow-down-left.svg | 3 +
.../20/solid/phone-arrow-up-right.svg | 3 +
.../optimized/20/solid/phone-x-mark.svg | 3 +
.../heroicons/optimized/20/solid/phone.svg | 3 +
.../heroicons/optimized/20/solid/photo.svg | 3 +
.../optimized/20/solid/play-circle.svg | 3 +
.../optimized/20/solid/play-pause.svg | 3 +
.../heroicons/optimized/20/solid/play.svg | 3 +
.../optimized/20/solid/plus-circle.svg | 3 +
.../optimized/20/solid/plus-small.svg | 3 +
.../heroicons/optimized/20/solid/plus.svg | 3 +
.../heroicons/optimized/20/solid/power.svg | 3 +
.../20/solid/presentation-chart-bar.svg | 3 +
.../20/solid/presentation-chart-line.svg | 3 +
.../heroicons/optimized/20/solid/printer.svg | 3 +
.../optimized/20/solid/puzzle-piece.svg | 3 +
.../heroicons/optimized/20/solid/qr-code.svg | 3 +
.../20/solid/question-mark-circle.svg | 3 +
.../optimized/20/solid/queue-list.svg | 3 +
.../heroicons/optimized/20/solid/radio.svg | 3 +
.../optimized/20/solid/receipt-percent.svg | 3 +
.../optimized/20/solid/receipt-refund.svg | 3 +
.../optimized/20/solid/rectangle-group.svg | 3 +
.../optimized/20/solid/rectangle-stack.svg | 3 +
.../optimized/20/solid/rocket-launch.svg | 4 +
.../heroicons/optimized/20/solid/rss.svg | 4 +
.../heroicons/optimized/20/solid/scale.svg | 3 +
.../heroicons/optimized/20/solid/scissors.svg | 4 +
.../optimized/20/solid/server-stack.svg | 4 +
.../heroicons/optimized/20/solid/server.svg | 4 +
.../heroicons/optimized/20/solid/share.svg | 3 +
.../optimized/20/solid/shield-check.svg | 3 +
.../optimized/20/solid/shield-exclamation.svg | 3 +
.../optimized/20/solid/shopping-bag.svg | 3 +
.../optimized/20/solid/shopping-cart.svg | 3 +
.../optimized/20/solid/signal-slash.svg | 3 +
.../heroicons/optimized/20/solid/signal.svg | 4 +
.../heroicons/optimized/20/solid/slash.svg | 3 +
.../heroicons/optimized/20/solid/sparkles.svg | 3 +
.../optimized/20/solid/speaker-wave.svg | 4 +
.../optimized/20/solid/speaker-x-mark.svg | 3 +
.../optimized/20/solid/square-2-stack.svg | 4 +
.../optimized/20/solid/square-3-stack-3d.svg | 5 +
.../optimized/20/solid/squares-2x2.svg | 3 +
.../optimized/20/solid/squares-plus.svg | 3 +
.../heroicons/optimized/20/solid/star.svg | 3 +
.../optimized/20/solid/stop-circle.svg | 3 +
.../heroicons/optimized/20/solid/stop.svg | 3 +
.../optimized/20/solid/strikethrough.svg | 3 +
.../heroicons/optimized/20/solid/sun.svg | 3 +
.../heroicons/optimized/20/solid/swatch.svg | 3 +
.../optimized/20/solid/table-cells.svg | 3 +
.../heroicons/optimized/20/solid/tag.svg | 3 +
.../heroicons/optimized/20/solid/ticket.svg | 3 +
.../heroicons/optimized/20/solid/trash.svg | 3 +
.../heroicons/optimized/20/solid/trophy.svg | 3 +
.../heroicons/optimized/20/solid/truck.svg | 4 +
.../heroicons/optimized/20/solid/tv.svg | 4 +
.../optimized/20/solid/underline.svg | 3 +
.../optimized/20/solid/user-circle.svg | 3 +
.../optimized/20/solid/user-group.svg | 3 +
.../optimized/20/solid/user-minus.svg | 3 +
.../optimized/20/solid/user-plus.svg | 3 +
.../heroicons/optimized/20/solid/user.svg | 3 +
.../heroicons/optimized/20/solid/users.svg | 3 +
.../heroicons/optimized/20/solid/variable.svg | 3 +
.../optimized/20/solid/video-camera-slash.svg | 3 +
.../optimized/20/solid/video-camera.svg | 3 +
.../optimized/20/solid/view-columns.svg | 3 +
.../optimized/20/solid/viewfinder-circle.svg | 3 +
.../heroicons/optimized/20/solid/wallet.svg | 3 +
.../heroicons/optimized/20/solid/wifi.svg | 3 +
.../heroicons/optimized/20/solid/window.svg | 3 +
.../optimized/20/solid/wrench-screwdriver.svg | 4 +
.../heroicons/optimized/20/solid/wrench.svg | 3 +
.../heroicons/optimized/20/solid/x-circle.svg | 3 +
.../heroicons/optimized/20/solid/x-mark.svg | 3 +
.../optimized/24/outline/academic-cap.svg | 3 +
.../24/outline/adjustments-horizontal.svg | 3 +
.../24/outline/adjustments-vertical.svg | 3 +
.../24/outline/archive-box-arrow-down.svg | 3 +
.../24/outline/archive-box-x-mark.svg | 3 +
.../optimized/24/outline/archive-box.svg | 3 +
.../24/outline/arrow-down-circle.svg | 3 +
.../optimized/24/outline/arrow-down-left.svg | 3 +
.../24/outline/arrow-down-on-square-stack.svg | 3 +
.../24/outline/arrow-down-on-square.svg | 3 +
.../optimized/24/outline/arrow-down-right.svg | 3 +
.../optimized/24/outline/arrow-down-tray.svg | 3 +
.../optimized/24/outline/arrow-down.svg | 3 +
.../24/outline/arrow-left-circle.svg | 3 +
.../outline/arrow-left-end-on-rectangle.svg | 3 +
.../24/outline/arrow-left-on-rectangle.svg | 3 +
.../outline/arrow-left-start-on-rectangle.svg | 3 +
.../optimized/24/outline/arrow-left.svg | 3 +
.../optimized/24/outline/arrow-long-down.svg | 3 +
.../optimized/24/outline/arrow-long-left.svg | 3 +
.../optimized/24/outline/arrow-long-right.svg | 3 +
.../optimized/24/outline/arrow-long-up.svg | 3 +
.../24/outline/arrow-path-rounded-square.svg | 3 +
.../optimized/24/outline/arrow-path.svg | 3 +
.../24/outline/arrow-right-circle.svg | 3 +
.../outline/arrow-right-end-on-rectangle.svg | 3 +
.../24/outline/arrow-right-on-rectangle.svg | 3 +
.../arrow-right-start-on-rectangle.svg | 3 +
.../optimized/24/outline/arrow-right.svg | 3 +
.../optimized/24/outline/arrow-small-down.svg | 3 +
.../optimized/24/outline/arrow-small-left.svg | 3 +
.../24/outline/arrow-small-right.svg | 3 +
.../optimized/24/outline/arrow-small-up.svg | 3 +
.../24/outline/arrow-top-right-on-square.svg | 3 +
.../24/outline/arrow-trending-down.svg | 3 +
.../24/outline/arrow-trending-up.svg | 3 +
.../24/outline/arrow-turn-down-left.svg | 3 +
.../24/outline/arrow-turn-down-right.svg | 3 +
.../24/outline/arrow-turn-left-down.svg | 3 +
.../24/outline/arrow-turn-left-up.svg | 3 +
.../24/outline/arrow-turn-right-down.svg | 3 +
.../24/outline/arrow-turn-right-up.svg | 3 +
.../24/outline/arrow-turn-up-left.svg | 3 +
.../24/outline/arrow-turn-up-right.svg | 3 +
.../optimized/24/outline/arrow-up-circle.svg | 3 +
.../optimized/24/outline/arrow-up-left.svg | 3 +
.../24/outline/arrow-up-on-square-stack.svg | 3 +
.../24/outline/arrow-up-on-square.svg | 3 +
.../optimized/24/outline/arrow-up-right.svg | 3 +
.../optimized/24/outline/arrow-up-tray.svg | 3 +
.../optimized/24/outline/arrow-up.svg | 3 +
.../optimized/24/outline/arrow-uturn-down.svg | 3 +
.../optimized/24/outline/arrow-uturn-left.svg | 3 +
.../24/outline/arrow-uturn-right.svg | 3 +
.../optimized/24/outline/arrow-uturn-up.svg | 3 +
.../24/outline/arrows-pointing-in.svg | 3 +
.../24/outline/arrows-pointing-out.svg | 3 +
.../24/outline/arrows-right-left.svg | 3 +
.../optimized/24/outline/arrows-up-down.svg | 3 +
.../optimized/24/outline/at-symbol.svg | 3 +
.../optimized/24/outline/backspace.svg | 3 +
.../optimized/24/outline/backward.svg | 3 +
.../optimized/24/outline/banknotes.svg | 3 +
.../heroicons/optimized/24/outline/bars-2.svg | 3 +
.../24/outline/bars-3-bottom-left.svg | 3 +
.../24/outline/bars-3-bottom-right.svg | 3 +
.../24/outline/bars-3-center-left.svg | 3 +
.../heroicons/optimized/24/outline/bars-3.svg | 3 +
.../heroicons/optimized/24/outline/bars-4.svg | 3 +
.../optimized/24/outline/bars-arrow-down.svg | 3 +
.../optimized/24/outline/bars-arrow-up.svg | 3 +
.../optimized/24/outline/battery-0.svg | 3 +
.../optimized/24/outline/battery-100.svg | 3 +
.../optimized/24/outline/battery-50.svg | 3 +
.../heroicons/optimized/24/outline/beaker.svg | 3 +
.../optimized/24/outline/bell-alert.svg | 3 +
.../optimized/24/outline/bell-slash.svg | 3 +
.../optimized/24/outline/bell-snooze.svg | 3 +
.../heroicons/optimized/24/outline/bell.svg | 3 +
.../heroicons/optimized/24/outline/bold.svg | 3 +
.../optimized/24/outline/bolt-slash.svg | 3 +
.../heroicons/optimized/24/outline/bolt.svg | 3 +
.../optimized/24/outline/book-open.svg | 3 +
.../optimized/24/outline/bookmark-slash.svg | 3 +
.../optimized/24/outline/bookmark-square.svg | 3 +
.../optimized/24/outline/bookmark.svg | 3 +
.../optimized/24/outline/briefcase.svg | 3 +
.../optimized/24/outline/bug-ant.svg | 3 +
.../optimized/24/outline/building-library.svg | 3 +
.../24/outline/building-office-2.svg | 3 +
.../optimized/24/outline/building-office.svg | 3 +
.../24/outline/building-storefront.svg | 3 +
.../heroicons/optimized/24/outline/cake.svg | 3 +
.../optimized/24/outline/calculator.svg | 3 +
.../24/outline/calendar-date-range.svg | 3 +
.../optimized/24/outline/calendar-days.svg | 3 +
.../optimized/24/outline/calendar.svg | 3 +
.../heroicons/optimized/24/outline/camera.svg | 4 +
.../optimized/24/outline/chart-bar-square.svg | 3 +
.../optimized/24/outline/chart-bar.svg | 3 +
.../optimized/24/outline/chart-pie.svg | 4 +
.../chat-bubble-bottom-center-text.svg | 3 +
.../24/outline/chat-bubble-bottom-center.svg | 3 +
.../24/outline/chat-bubble-left-ellipsis.svg | 3 +
.../24/outline/chat-bubble-left-right.svg | 3 +
.../optimized/24/outline/chat-bubble-left.svg | 3 +
.../chat-bubble-oval-left-ellipsis.svg | 3 +
.../24/outline/chat-bubble-oval-left.svg | 3 +
.../optimized/24/outline/check-badge.svg | 3 +
.../optimized/24/outline/check-circle.svg | 3 +
.../heroicons/optimized/24/outline/check.svg | 3 +
.../24/outline/chevron-double-down.svg | 3 +
.../24/outline/chevron-double-left.svg | 3 +
.../24/outline/chevron-double-right.svg | 3 +
.../24/outline/chevron-double-up.svg | 4 +
.../optimized/24/outline/chevron-down.svg | 3 +
.../optimized/24/outline/chevron-left.svg | 3 +
.../optimized/24/outline/chevron-right.svg | 3 +
.../optimized/24/outline/chevron-up-down.svg | 3 +
.../optimized/24/outline/chevron-up.svg | 3 +
.../optimized/24/outline/circle-stack.svg | 3 +
.../24/outline/clipboard-document-check.svg | 3 +
.../24/outline/clipboard-document-list.svg | 3 +
.../24/outline/clipboard-document.svg | 3 +
.../optimized/24/outline/clipboard.svg | 3 +
.../heroicons/optimized/24/outline/clock.svg | 3 +
.../optimized/24/outline/cloud-arrow-down.svg | 3 +
.../optimized/24/outline/cloud-arrow-up.svg | 3 +
.../heroicons/optimized/24/outline/cloud.svg | 3 +
.../24/outline/code-bracket-square.svg | 3 +
.../optimized/24/outline/code-bracket.svg | 3 +
.../optimized/24/outline/cog-6-tooth.svg | 4 +
.../optimized/24/outline/cog-8-tooth.svg | 4 +
.../heroicons/optimized/24/outline/cog.svg | 3 +
.../optimized/24/outline/command-line.svg | 3 +
.../optimized/24/outline/computer-desktop.svg | 3 +
.../optimized/24/outline/cpu-chip.svg | 3 +
.../optimized/24/outline/credit-card.svg | 3 +
.../optimized/24/outline/cube-transparent.svg | 3 +
.../heroicons/optimized/24/outline/cube.svg | 3 +
.../24/outline/currency-bangladeshi.svg | 3 +
.../optimized/24/outline/currency-dollar.svg | 3 +
.../optimized/24/outline/currency-euro.svg | 3 +
.../optimized/24/outline/currency-pound.svg | 3 +
.../optimized/24/outline/currency-rupee.svg | 3 +
.../optimized/24/outline/currency-yen.svg | 3 +
.../24/outline/cursor-arrow-rays.svg | 3 +
.../24/outline/cursor-arrow-ripple.svg | 3 +
.../24/outline/device-phone-mobile.svg | 3 +
.../optimized/24/outline/device-tablet.svg | 3 +
.../heroicons/optimized/24/outline/divide.svg | 3 +
.../24/outline/document-arrow-down.svg | 3 +
.../24/outline/document-arrow-up.svg | 3 +
.../24/outline/document-chart-bar.svg | 3 +
.../optimized/24/outline/document-check.svg | 3 +
.../outline/document-currency-bangladeshi.svg | 3 +
.../24/outline/document-currency-dollar.svg | 3 +
.../24/outline/document-currency-euro.svg | 3 +
.../24/outline/document-currency-pound.svg | 3 +
.../24/outline/document-currency-rupee.svg | 3 +
.../24/outline/document-currency-yen.svg | 3 +
.../24/outline/document-duplicate.svg | 3 +
.../24/outline/document-magnifying-glass.svg | 3 +
.../optimized/24/outline/document-minus.svg | 3 +
.../optimized/24/outline/document-plus.svg | 3 +
.../optimized/24/outline/document-text.svg | 3 +
.../optimized/24/outline/document.svg | 3 +
.../24/outline/ellipsis-horizontal-circle.svg | 3 +
.../24/outline/ellipsis-horizontal.svg | 3 +
.../24/outline/ellipsis-vertical.svg | 3 +
.../optimized/24/outline/envelope-open.svg | 3 +
.../optimized/24/outline/envelope.svg | 3 +
.../heroicons/optimized/24/outline/equals.svg | 3 +
.../24/outline/exclamation-circle.svg | 3 +
.../24/outline/exclamation-triangle.svg | 3 +
.../optimized/24/outline/eye-dropper.svg | 3 +
.../optimized/24/outline/eye-slash.svg | 3 +
.../heroicons/optimized/24/outline/eye.svg | 4 +
.../optimized/24/outline/face-frown.svg | 3 +
.../optimized/24/outline/face-smile.svg | 3 +
.../heroicons/optimized/24/outline/film.svg | 3 +
.../optimized/24/outline/finger-print.svg | 3 +
.../heroicons/optimized/24/outline/fire.svg | 4 +
.../heroicons/optimized/24/outline/flag.svg | 3 +
.../24/outline/folder-arrow-down.svg | 3 +
.../optimized/24/outline/folder-minus.svg | 3 +
.../optimized/24/outline/folder-open.svg | 3 +
.../optimized/24/outline/folder-plus.svg | 3 +
.../heroicons/optimized/24/outline/folder.svg | 3 +
.../optimized/24/outline/forward.svg | 3 +
.../heroicons/optimized/24/outline/funnel.svg | 3 +
.../heroicons/optimized/24/outline/gif.svg | 3 +
.../optimized/24/outline/gift-top.svg | 3 +
.../heroicons/optimized/24/outline/gift.svg | 3 +
.../optimized/24/outline/globe-alt.svg | 3 +
.../optimized/24/outline/globe-americas.svg | 3 +
.../24/outline/globe-asia-australia.svg | 3 +
.../24/outline/globe-europe-africa.svg | 3 +
.../heroicons/optimized/24/outline/h1.svg | 3 +
.../heroicons/optimized/24/outline/h2.svg | 3 +
.../heroicons/optimized/24/outline/h3.svg | 3 +
.../optimized/24/outline/hand-raised.svg | 3 +
.../optimized/24/outline/hand-thumb-down.svg | 3 +
.../optimized/24/outline/hand-thumb-up.svg | 3 +
.../optimized/24/outline/hashtag.svg | 3 +
.../heroicons/optimized/24/outline/heart.svg | 3 +
.../optimized/24/outline/home-modern.svg | 3 +
.../heroicons/optimized/24/outline/home.svg | 3 +
.../optimized/24/outline/identification.svg | 3 +
.../optimized/24/outline/inbox-arrow-down.svg | 3 +
.../optimized/24/outline/inbox-stack.svg | 3 +
.../heroicons/optimized/24/outline/inbox.svg | 3 +
.../24/outline/information-circle.svg | 3 +
.../heroicons/optimized/24/outline/italic.svg | 3 +
.../heroicons/optimized/24/outline/key.svg | 3 +
.../optimized/24/outline/language.svg | 3 +
.../optimized/24/outline/lifebuoy.svg | 3 +
.../optimized/24/outline/light-bulb.svg | 3 +
.../optimized/24/outline/link-slash.svg | 3 +
.../heroicons/optimized/24/outline/link.svg | 3 +
.../optimized/24/outline/list-bullet.svg | 3 +
.../optimized/24/outline/lock-closed.svg | 3 +
.../optimized/24/outline/lock-open.svg | 3 +
.../24/outline/magnifying-glass-circle.svg | 3 +
.../24/outline/magnifying-glass-minus.svg | 3 +
.../24/outline/magnifying-glass-plus.svg | 3 +
.../optimized/24/outline/magnifying-glass.svg | 3 +
.../optimized/24/outline/map-pin.svg | 4 +
.../heroicons/optimized/24/outline/map.svg | 3 +
.../optimized/24/outline/megaphone.svg | 3 +
.../optimized/24/outline/microphone.svg | 3 +
.../optimized/24/outline/minus-circle.svg | 3 +
.../optimized/24/outline/minus-small.svg | 3 +
.../heroicons/optimized/24/outline/minus.svg | 3 +
.../heroicons/optimized/24/outline/moon.svg | 3 +
.../optimized/24/outline/musical-note.svg | 3 +
.../optimized/24/outline/newspaper.svg | 3 +
.../optimized/24/outline/no-symbol.svg | 3 +
.../optimized/24/outline/numbered-list.svg | 3 +
.../optimized/24/outline/paint-brush.svg | 3 +
.../optimized/24/outline/paper-airplane.svg | 3 +
.../optimized/24/outline/paper-clip.svg | 3 +
.../optimized/24/outline/pause-circle.svg | 3 +
.../heroicons/optimized/24/outline/pause.svg | 3 +
.../optimized/24/outline/pencil-square.svg | 3 +
.../heroicons/optimized/24/outline/pencil.svg | 3 +
.../optimized/24/outline/percent-badge.svg | 3 +
.../24/outline/phone-arrow-down-left.svg | 3 +
.../24/outline/phone-arrow-up-right.svg | 3 +
.../optimized/24/outline/phone-x-mark.svg | 3 +
.../heroicons/optimized/24/outline/phone.svg | 3 +
.../heroicons/optimized/24/outline/photo.svg | 3 +
.../optimized/24/outline/play-circle.svg | 4 +
.../optimized/24/outline/play-pause.svg | 3 +
.../heroicons/optimized/24/outline/play.svg | 3 +
.../optimized/24/outline/plus-circle.svg | 3 +
.../optimized/24/outline/plus-small.svg | 3 +
.../heroicons/optimized/24/outline/plus.svg | 3 +
.../heroicons/optimized/24/outline/power.svg | 3 +
.../24/outline/presentation-chart-bar.svg | 3 +
.../24/outline/presentation-chart-line.svg | 3 +
.../optimized/24/outline/printer.svg | 3 +
.../optimized/24/outline/puzzle-piece.svg | 3 +
.../optimized/24/outline/qr-code.svg | 4 +
.../24/outline/question-mark-circle.svg | 3 +
.../optimized/24/outline/queue-list.svg | 3 +
.../heroicons/optimized/24/outline/radio.svg | 3 +
.../optimized/24/outline/receipt-percent.svg | 3 +
.../optimized/24/outline/receipt-refund.svg | 3 +
.../optimized/24/outline/rectangle-group.svg | 3 +
.../optimized/24/outline/rectangle-stack.svg | 3 +
.../optimized/24/outline/rocket-launch.svg | 3 +
.../heroicons/optimized/24/outline/rss.svg | 3 +
.../heroicons/optimized/24/outline/scale.svg | 3 +
.../optimized/24/outline/scissors.svg | 3 +
.../optimized/24/outline/server-stack.svg | 3 +
.../heroicons/optimized/24/outline/server.svg | 3 +
.../heroicons/optimized/24/outline/share.svg | 3 +
.../optimized/24/outline/shield-check.svg | 3 +
.../24/outline/shield-exclamation.svg | 3 +
.../optimized/24/outline/shopping-bag.svg | 3 +
.../optimized/24/outline/shopping-cart.svg | 3 +
.../optimized/24/outline/signal-slash.svg | 3 +
.../heroicons/optimized/24/outline/signal.svg | 3 +
.../heroicons/optimized/24/outline/slash.svg | 3 +
.../optimized/24/outline/sparkles.svg | 3 +
.../optimized/24/outline/speaker-wave.svg | 3 +
.../optimized/24/outline/speaker-x-mark.svg | 3 +
.../optimized/24/outline/square-2-stack.svg | 3 +
.../24/outline/square-3-stack-3d.svg | 3 +
.../optimized/24/outline/squares-2x2.svg | 3 +
.../optimized/24/outline/squares-plus.svg | 3 +
.../heroicons/optimized/24/outline/star.svg | 3 +
.../optimized/24/outline/stop-circle.svg | 4 +
.../heroicons/optimized/24/outline/stop.svg | 3 +
.../optimized/24/outline/strikethrough.svg | 3 +
.../heroicons/optimized/24/outline/sun.svg | 3 +
.../heroicons/optimized/24/outline/swatch.svg | 3 +
.../optimized/24/outline/table-cells.svg | 3 +
.../heroicons/optimized/24/outline/tag.svg | 4 +
.../heroicons/optimized/24/outline/ticket.svg | 3 +
.../heroicons/optimized/24/outline/trash.svg | 3 +
.../heroicons/optimized/24/outline/trophy.svg | 3 +
.../heroicons/optimized/24/outline/truck.svg | 3 +
.../heroicons/optimized/24/outline/tv.svg | 3 +
.../optimized/24/outline/underline.svg | 3 +
.../optimized/24/outline/user-circle.svg | 3 +
.../optimized/24/outline/user-group.svg | 3 +
.../optimized/24/outline/user-minus.svg | 3 +
.../optimized/24/outline/user-plus.svg | 3 +
.../heroicons/optimized/24/outline/user.svg | 3 +
.../heroicons/optimized/24/outline/users.svg | 3 +
.../optimized/24/outline/variable.svg | 3 +
.../24/outline/video-camera-slash.svg | 3 +
.../optimized/24/outline/video-camera.svg | 3 +
.../optimized/24/outline/view-columns.svg | 3 +
.../24/outline/viewfinder-circle.svg | 3 +
.../heroicons/optimized/24/outline/wallet.svg | 3 +
.../heroicons/optimized/24/outline/wifi.svg | 3 +
.../heroicons/optimized/24/outline/window.svg | 3 +
.../24/outline/wrench-screwdriver.svg | 3 +
.../heroicons/optimized/24/outline/wrench.svg | 4 +
.../optimized/24/outline/x-circle.svg | 3 +
.../heroicons/optimized/24/outline/x-mark.svg | 3 +
.../optimized/24/solid/academic-cap.svg | 5 +
.../24/solid/adjustments-horizontal.svg | 3 +
.../24/solid/adjustments-vertical.svg | 3 +
.../24/solid/archive-box-arrow-down.svg | 4 +
.../optimized/24/solid/archive-box-x-mark.svg | 4 +
.../optimized/24/solid/archive-box.svg | 4 +
.../optimized/24/solid/arrow-down-circle.svg | 3 +
.../optimized/24/solid/arrow-down-left.svg | 3 +
.../24/solid/arrow-down-on-square-stack.svg | 4 +
.../24/solid/arrow-down-on-square.svg | 3 +
.../optimized/24/solid/arrow-down-right.svg | 3 +
.../optimized/24/solid/arrow-down-tray.svg | 3 +
.../optimized/24/solid/arrow-down.svg | 3 +
.../optimized/24/solid/arrow-left-circle.svg | 3 +
.../24/solid/arrow-left-end-on-rectangle.svg | 3 +
.../24/solid/arrow-left-on-rectangle.svg | 3 +
.../solid/arrow-left-start-on-rectangle.svg | 3 +
.../optimized/24/solid/arrow-left.svg | 3 +
.../optimized/24/solid/arrow-long-down.svg | 3 +
.../optimized/24/solid/arrow-long-left.svg | 3 +
.../optimized/24/solid/arrow-long-right.svg | 3 +
.../optimized/24/solid/arrow-long-up.svg | 3 +
.../24/solid/arrow-path-rounded-square.svg | 3 +
.../optimized/24/solid/arrow-path.svg | 3 +
.../optimized/24/solid/arrow-right-circle.svg | 3 +
.../24/solid/arrow-right-end-on-rectangle.svg | 3 +
.../24/solid/arrow-right-on-rectangle.svg | 3 +
.../solid/arrow-right-start-on-rectangle.svg | 3 +
.../optimized/24/solid/arrow-right.svg | 3 +
.../optimized/24/solid/arrow-small-down.svg | 3 +
.../optimized/24/solid/arrow-small-left.svg | 3 +
.../optimized/24/solid/arrow-small-right.svg | 3 +
.../optimized/24/solid/arrow-small-up.svg | 3 +
.../24/solid/arrow-top-right-on-square.svg | 3 +
.../24/solid/arrow-trending-down.svg | 3 +
.../optimized/24/solid/arrow-trending-up.svg | 3 +
.../24/solid/arrow-turn-down-left.svg | 3 +
.../24/solid/arrow-turn-down-right.svg | 3 +
.../24/solid/arrow-turn-left-down.svg | 3 +
.../optimized/24/solid/arrow-turn-left-up.svg | 3 +
.../24/solid/arrow-turn-right-down.svg | 3 +
.../24/solid/arrow-turn-right-up.svg | 3 +
.../optimized/24/solid/arrow-turn-up-left.svg | 3 +
.../24/solid/arrow-turn-up-right.svg | 3 +
.../optimized/24/solid/arrow-up-circle.svg | 3 +
.../optimized/24/solid/arrow-up-left.svg | 3 +
.../24/solid/arrow-up-on-square-stack.svg | 4 +
.../optimized/24/solid/arrow-up-on-square.svg | 3 +
.../optimized/24/solid/arrow-up-right.svg | 3 +
.../optimized/24/solid/arrow-up-tray.svg | 3 +
.../heroicons/optimized/24/solid/arrow-up.svg | 3 +
.../optimized/24/solid/arrow-uturn-down.svg | 3 +
.../optimized/24/solid/arrow-uturn-left.svg | 3 +
.../optimized/24/solid/arrow-uturn-right.svg | 3 +
.../optimized/24/solid/arrow-uturn-up.svg | 3 +
.../optimized/24/solid/arrows-pointing-in.svg | 3 +
.../24/solid/arrows-pointing-out.svg | 3 +
.../optimized/24/solid/arrows-right-left.svg | 3 +
.../optimized/24/solid/arrows-up-down.svg | 3 +
.../optimized/24/solid/at-symbol.svg | 3 +
.../optimized/24/solid/backspace.svg | 3 +
.../heroicons/optimized/24/solid/backward.svg | 3 +
.../optimized/24/solid/banknotes.svg | 5 +
.../heroicons/optimized/24/solid/bars-2.svg | 3 +
.../optimized/24/solid/bars-3-bottom-left.svg | 3 +
.../24/solid/bars-3-bottom-right.svg | 3 +
.../optimized/24/solid/bars-3-center-left.svg | 3 +
.../heroicons/optimized/24/solid/bars-3.svg | 3 +
.../heroicons/optimized/24/solid/bars-4.svg | 3 +
.../optimized/24/solid/bars-arrow-down.svg | 3 +
.../optimized/24/solid/bars-arrow-up.svg | 3 +
.../optimized/24/solid/battery-0.svg | 3 +
.../optimized/24/solid/battery-100.svg | 3 +
.../optimized/24/solid/battery-50.svg | 4 +
.../heroicons/optimized/24/solid/beaker.svg | 3 +
.../optimized/24/solid/bell-alert.svg | 4 +
.../optimized/24/solid/bell-slash.svg | 4 +
.../optimized/24/solid/bell-snooze.svg | 3 +
.../heroicons/optimized/24/solid/bell.svg | 3 +
.../heroicons/optimized/24/solid/bold.svg | 3 +
.../optimized/24/solid/bolt-slash.svg | 3 +
.../heroicons/optimized/24/solid/bolt.svg | 3 +
.../optimized/24/solid/book-open.svg | 3 +
.../optimized/24/solid/bookmark-slash.svg | 3 +
.../optimized/24/solid/bookmark-square.svg | 3 +
.../heroicons/optimized/24/solid/bookmark.svg | 3 +
.../optimized/24/solid/briefcase.svg | 4 +
.../heroicons/optimized/24/solid/bug-ant.svg | 3 +
.../optimized/24/solid/building-library.svg | 5 +
.../optimized/24/solid/building-office-2.svg | 3 +
.../optimized/24/solid/building-office.svg | 3 +
.../24/solid/building-storefront.svg | 4 +
.../heroicons/optimized/24/solid/cake.svg | 3 +
.../optimized/24/solid/calculator.svg | 3 +
.../24/solid/calendar-date-range.svg | 4 +
.../optimized/24/solid/calendar-days.svg | 4 +
.../heroicons/optimized/24/solid/calendar.svg | 3 +
.../heroicons/optimized/24/solid/camera.svg | 4 +
.../optimized/24/solid/chart-bar-square.svg | 3 +
.../optimized/24/solid/chart-bar.svg | 3 +
.../optimized/24/solid/chart-pie.svg | 4 +
.../solid/chat-bubble-bottom-center-text.svg | 3 +
.../24/solid/chat-bubble-bottom-center.svg | 3 +
.../24/solid/chat-bubble-left-ellipsis.svg | 3 +
.../24/solid/chat-bubble-left-right.svg | 4 +
.../optimized/24/solid/chat-bubble-left.svg | 3 +
.../solid/chat-bubble-oval-left-ellipsis.svg | 3 +
.../24/solid/chat-bubble-oval-left.svg | 3 +
.../optimized/24/solid/check-badge.svg | 3 +
.../optimized/24/solid/check-circle.svg | 3 +
.../heroicons/optimized/24/solid/check.svg | 3 +
.../24/solid/chevron-double-down.svg | 4 +
.../24/solid/chevron-double-left.svg | 4 +
.../24/solid/chevron-double-right.svg | 4 +
.../optimized/24/solid/chevron-double-up.svg | 4 +
.../optimized/24/solid/chevron-down.svg | 3 +
.../optimized/24/solid/chevron-left.svg | 3 +
.../optimized/24/solid/chevron-right.svg | 3 +
.../optimized/24/solid/chevron-up-down.svg | 3 +
.../optimized/24/solid/chevron-up.svg | 3 +
.../optimized/24/solid/circle-stack.svg | 6 +
.../24/solid/clipboard-document-check.svg | 4 +
.../24/solid/clipboard-document-list.svg | 4 +
.../optimized/24/solid/clipboard-document.svg | 5 +
.../optimized/24/solid/clipboard.svg | 3 +
.../heroicons/optimized/24/solid/clock.svg | 3 +
.../optimized/24/solid/cloud-arrow-down.svg | 3 +
.../optimized/24/solid/cloud-arrow-up.svg | 3 +
.../heroicons/optimized/24/solid/cloud.svg | 3 +
.../24/solid/code-bracket-square.svg | 3 +
.../optimized/24/solid/code-bracket.svg | 3 +
.../optimized/24/solid/cog-6-tooth.svg | 3 +
.../optimized/24/solid/cog-8-tooth.svg | 3 +
.../heroicons/optimized/24/solid/cog.svg | 4 +
.../optimized/24/solid/command-line.svg | 3 +
.../optimized/24/solid/computer-desktop.svg | 3 +
.../heroicons/optimized/24/solid/cpu-chip.svg | 4 +
.../optimized/24/solid/credit-card.svg | 4 +
.../optimized/24/solid/cube-transparent.svg | 3 +
.../heroicons/optimized/24/solid/cube.svg | 3 +
.../24/solid/currency-bangladeshi.svg | 3 +
.../optimized/24/solid/currency-dollar.svg | 4 +
.../optimized/24/solid/currency-euro.svg | 3 +
.../optimized/24/solid/currency-pound.svg | 3 +
.../optimized/24/solid/currency-rupee.svg | 3 +
.../optimized/24/solid/currency-yen.svg | 3 +
.../optimized/24/solid/cursor-arrow-rays.svg | 3 +
.../24/solid/cursor-arrow-ripple.svg | 3 +
.../24/solid/device-phone-mobile.svg | 4 +
.../optimized/24/solid/device-tablet.svg | 4 +
.../heroicons/optimized/24/solid/divide.svg | 3 +
.../24/solid/document-arrow-down.svg | 4 +
.../optimized/24/solid/document-arrow-up.svg | 4 +
.../optimized/24/solid/document-chart-bar.svg | 4 +
.../optimized/24/solid/document-check.svg | 4 +
.../solid/document-currency-bangladeshi.svg | 3 +
.../24/solid/document-currency-dollar.svg | 3 +
.../24/solid/document-currency-euro.svg | 4 +
.../24/solid/document-currency-pound.svg | 3 +
.../24/solid/document-currency-rupee.svg | 3 +
.../24/solid/document-currency-yen.svg | 3 +
.../optimized/24/solid/document-duplicate.svg | 4 +
.../24/solid/document-magnifying-glass.svg | 5 +
.../optimized/24/solid/document-minus.svg | 4 +
.../optimized/24/solid/document-plus.svg | 4 +
.../optimized/24/solid/document-text.svg | 4 +
.../heroicons/optimized/24/solid/document.svg | 4 +
.../24/solid/ellipsis-horizontal-circle.svg | 3 +
.../24/solid/ellipsis-horizontal.svg | 3 +
.../optimized/24/solid/ellipsis-vertical.svg | 3 +
.../optimized/24/solid/envelope-open.svg | 4 +
.../heroicons/optimized/24/solid/envelope.svg | 4 +
.../heroicons/optimized/24/solid/equals.svg | 3 +
.../optimized/24/solid/exclamation-circle.svg | 3 +
.../24/solid/exclamation-triangle.svg | 3 +
.../optimized/24/solid/eye-dropper.svg | 3 +
.../optimized/24/solid/eye-slash.svg | 5 +
.../heroicons/optimized/24/solid/eye.svg | 4 +
.../optimized/24/solid/face-frown.svg | 3 +
.../optimized/24/solid/face-smile.svg | 3 +
.../heroicons/optimized/24/solid/film.svg | 3 +
.../optimized/24/solid/finger-print.svg | 3 +
.../heroicons/optimized/24/solid/fire.svg | 3 +
.../heroicons/optimized/24/solid/flag.svg | 3 +
.../optimized/24/solid/folder-arrow-down.svg | 3 +
.../optimized/24/solid/folder-minus.svg | 3 +
.../optimized/24/solid/folder-open.svg | 3 +
.../optimized/24/solid/folder-plus.svg | 3 +
.../heroicons/optimized/24/solid/folder.svg | 3 +
.../heroicons/optimized/24/solid/forward.svg | 3 +
.../heroicons/optimized/24/solid/funnel.svg | 3 +
.../heroicons/optimized/24/solid/gif.svg | 3 +
.../heroicons/optimized/24/solid/gift-top.svg | 4 +
.../heroicons/optimized/24/solid/gift.svg | 3 +
.../optimized/24/solid/globe-alt.svg | 3 +
.../optimized/24/solid/globe-americas.svg | 3 +
.../24/solid/globe-asia-australia.svg | 4 +
.../24/solid/globe-europe-africa.svg | 3 +
.../heroicons/optimized/24/solid/h1.svg | 3 +
.../heroicons/optimized/24/solid/h2.svg | 3 +
.../heroicons/optimized/24/solid/h3.svg | 3 +
.../optimized/24/solid/hand-raised.svg | 3 +
.../optimized/24/solid/hand-thumb-down.svg | 3 +
.../optimized/24/solid/hand-thumb-up.svg | 3 +
.../heroicons/optimized/24/solid/hashtag.svg | 3 +
.../heroicons/optimized/24/solid/heart.svg | 3 +
.../optimized/24/solid/home-modern.svg | 4 +
.../heroicons/optimized/24/solid/home.svg | 4 +
.../optimized/24/solid/identification.svg | 3 +
.../optimized/24/solid/inbox-arrow-down.svg | 4 +
.../optimized/24/solid/inbox-stack.svg | 4 +
.../heroicons/optimized/24/solid/inbox.svg | 3 +
.../optimized/24/solid/information-circle.svg | 3 +
.../heroicons/optimized/24/solid/italic.svg | 3 +
.../heroicons/optimized/24/solid/key.svg | 3 +
.../heroicons/optimized/24/solid/language.svg | 3 +
.../heroicons/optimized/24/solid/lifebuoy.svg | 3 +
.../optimized/24/solid/light-bulb.svg | 4 +
.../optimized/24/solid/link-slash.svg | 3 +
.../heroicons/optimized/24/solid/link.svg | 3 +
.../optimized/24/solid/list-bullet.svg | 3 +
.../optimized/24/solid/lock-closed.svg | 3 +
.../optimized/24/solid/lock-open.svg | 3 +
.../24/solid/magnifying-glass-circle.svg | 4 +
.../24/solid/magnifying-glass-minus.svg | 3 +
.../24/solid/magnifying-glass-plus.svg | 3 +
.../optimized/24/solid/magnifying-glass.svg | 3 +
.../heroicons/optimized/24/solid/map-pin.svg | 3 +
.../heroicons/optimized/24/solid/map.svg | 3 +
.../optimized/24/solid/megaphone.svg | 3 +
.../optimized/24/solid/microphone.svg | 4 +
.../optimized/24/solid/minus-circle.svg | 3 +
.../optimized/24/solid/minus-small.svg | 3 +
.../heroicons/optimized/24/solid/minus.svg | 3 +
.../heroicons/optimized/24/solid/moon.svg | 3 +
.../optimized/24/solid/musical-note.svg | 3 +
.../optimized/24/solid/newspaper.svg | 4 +
.../optimized/24/solid/no-symbol.svg | 3 +
.../optimized/24/solid/numbered-list.svg | 3 +
.../optimized/24/solid/paint-brush.svg | 3 +
.../optimized/24/solid/paper-airplane.svg | 3 +
.../optimized/24/solid/paper-clip.svg | 3 +
.../optimized/24/solid/pause-circle.svg | 3 +
.../heroicons/optimized/24/solid/pause.svg | 3 +
.../optimized/24/solid/pencil-square.svg | 4 +
.../heroicons/optimized/24/solid/pencil.svg | 3 +
.../optimized/24/solid/percent-badge.svg | 3 +
.../24/solid/phone-arrow-down-left.svg | 4 +
.../24/solid/phone-arrow-up-right.svg | 4 +
.../optimized/24/solid/phone-x-mark.svg | 3 +
.../heroicons/optimized/24/solid/phone.svg | 3 +
.../heroicons/optimized/24/solid/photo.svg | 3 +
.../optimized/24/solid/play-circle.svg | 3 +
.../optimized/24/solid/play-pause.svg | 3 +
.../heroicons/optimized/24/solid/play.svg | 3 +
.../optimized/24/solid/plus-circle.svg | 3 +
.../optimized/24/solid/plus-small.svg | 3 +
.../heroicons/optimized/24/solid/plus.svg | 3 +
.../heroicons/optimized/24/solid/power.svg | 3 +
.../24/solid/presentation-chart-bar.svg | 3 +
.../24/solid/presentation-chart-line.svg | 3 +
.../heroicons/optimized/24/solid/printer.svg | 3 +
.../optimized/24/solid/puzzle-piece.svg | 3 +
.../heroicons/optimized/24/solid/qr-code.svg | 3 +
.../24/solid/question-mark-circle.svg | 3 +
.../optimized/24/solid/queue-list.svg | 3 +
.../heroicons/optimized/24/solid/radio.svg | 3 +
.../optimized/24/solid/receipt-percent.svg | 3 +
.../optimized/24/solid/receipt-refund.svg | 3 +
.../optimized/24/solid/rectangle-group.svg | 3 +
.../optimized/24/solid/rectangle-stack.svg | 3 +
.../optimized/24/solid/rocket-launch.svg | 4 +
.../heroicons/optimized/24/solid/rss.svg | 3 +
.../heroicons/optimized/24/solid/scale.svg | 3 +
.../heroicons/optimized/24/solid/scissors.svg | 4 +
.../optimized/24/solid/server-stack.svg | 4 +
.../heroicons/optimized/24/solid/server.svg | 4 +
.../heroicons/optimized/24/solid/share.svg | 3 +
.../optimized/24/solid/shield-check.svg | 3 +
.../optimized/24/solid/shield-exclamation.svg | 3 +
.../optimized/24/solid/shopping-bag.svg | 3 +
.../optimized/24/solid/shopping-cart.svg | 3 +
.../optimized/24/solid/signal-slash.svg | 3 +
.../heroicons/optimized/24/solid/signal.svg | 3 +
.../heroicons/optimized/24/solid/slash.svg | 3 +
.../heroicons/optimized/24/solid/sparkles.svg | 3 +
.../optimized/24/solid/speaker-wave.svg | 4 +
.../optimized/24/solid/speaker-x-mark.svg | 3 +
.../optimized/24/solid/square-2-stack.svg | 4 +
.../optimized/24/solid/square-3-stack-3d.svg | 5 +
.../optimized/24/solid/squares-2x2.svg | 3 +
.../optimized/24/solid/squares-plus.svg | 3 +
.../heroicons/optimized/24/solid/star.svg | 3 +
.../optimized/24/solid/stop-circle.svg | 3 +
.../heroicons/optimized/24/solid/stop.svg | 3 +
.../optimized/24/solid/strikethrough.svg | 3 +
.../heroicons/optimized/24/solid/sun.svg | 3 +
.../heroicons/optimized/24/solid/swatch.svg | 4 +
.../optimized/24/solid/table-cells.svg | 3 +
.../heroicons/optimized/24/solid/tag.svg | 3 +
.../heroicons/optimized/24/solid/ticket.svg | 3 +
.../heroicons/optimized/24/solid/trash.svg | 3 +
.../heroicons/optimized/24/solid/trophy.svg | 3 +
.../heroicons/optimized/24/solid/truck.svg | 5 +
.../heroicons/optimized/24/solid/tv.svg | 4 +
.../optimized/24/solid/underline.svg | 3 +
.../optimized/24/solid/user-circle.svg | 3 +
.../optimized/24/solid/user-group.svg | 4 +
.../optimized/24/solid/user-minus.svg | 3 +
.../optimized/24/solid/user-plus.svg | 3 +
.../heroicons/optimized/24/solid/user.svg | 3 +
.../heroicons/optimized/24/solid/users.svg | 3 +
.../heroicons/optimized/24/solid/variable.svg | 3 +
.../optimized/24/solid/video-camera-slash.svg | 3 +
.../optimized/24/solid/video-camera.svg | 3 +
.../optimized/24/solid/view-columns.svg | 3 +
.../optimized/24/solid/viewfinder-circle.svg | 3 +
.../heroicons/optimized/24/solid/wallet.svg | 3 +
.../heroicons/optimized/24/solid/wifi.svg | 3 +
.../heroicons/optimized/24/solid/window.svg | 3 +
.../optimized/24/solid/wrench-screwdriver.svg | 5 +
.../heroicons/optimized/24/solid/wrench.svg | 3 +
.../heroicons/optimized/24/solid/x-circle.svg | 3 +
.../heroicons/optimized/24/solid/x-mark.svg | 3 +
apps/elektrine/assets/vendor/topbar.js | 165 +
apps/elektrine/lib/elektrine.ex | 9 +
.../lib/elektrine/account_identifiers.ex | 58 +
apps/elektrine/lib/elektrine/accounts.ex | 1716 ++
.../accounts/account_deletion_request.ex | 63 +
.../elektrine/accounts/account_endorsement.ex | 55 +
.../accounts/account_import_worker.ex | 228 +
.../lib/elektrine/accounts/account_note.ex | 45 +
.../lib/elektrine/accounts/account_notes.ex | 68 +
.../accounts/account_subscription.ex | 55 +
.../lib/elektrine/accounts/app_password.ex | 223 +
.../lib/elektrine/accounts/authentication.ex | 911 +
.../elektrine/accounts/blocked_users_cache.ex | 56 +
.../lib/elektrine/accounts/blocking.ex | 424 +
.../elektrine/accounts/built_in_subdomain.ex | 12 +
.../lib/elektrine/accounts/cached.ex | 107 +
.../lib/elektrine/accounts/capabilities.ex | 420 +
.../elektrine/accounts/client_app_setting.ex | 39 +
.../elektrine/accounts/client_app_settings.ex | 65 +
.../elektrine/accounts/connected_account.ex | 84 +
.../lib/elektrine/accounts/endorsements.ex | 93 +
.../lib/elektrine/accounts/invite_code.ex | 100 +
.../lib/elektrine/accounts/invite_code_use.ex | 22 +
.../lib/elektrine/accounts/moderation.ex | 521 +
.../elektrine/accounts/mute_expire_worker.ex | 36 +
.../lib/elektrine/accounts/muting.ex | 159 +
.../elektrine/accounts/passkey_credential.ex | 82 +
.../lib/elektrine/accounts/passkeys.ex | 409 +
.../accounts/recovery_email_verification.ex | 511 +
.../lib/elektrine/accounts/storage.ex | 394 +
.../lib/elektrine/accounts/subscriptions.ex | 182 +
.../lib/elektrine/accounts/tracking.ex | 192 +
.../lib/elektrine/accounts/trust_level.ex | 405 +
.../lib/elektrine/accounts/trust_level_log.ex | 28 +
.../lib/elektrine/accounts/trusted_device.ex | 82 +
.../lib/elektrine/accounts/two_factor.ex | 121 +
apps/elektrine/lib/elektrine/accounts/user.ex | 1489 ++
.../elektrine/accounts/user_activity_stats.ex | 74 +
.../lib/elektrine/accounts/user_block.ex | 36 +
.../lib/elektrine/accounts/user_mute.ex | 37 +
.../lib/elektrine/accounts/user_session.ex | 108 +
.../elektrine/accounts/username_history.ex | 24 +
.../elektrine/acme/wildcard_renewal_worker.ex | 148 +
apps/elektrine/lib/elektrine/activitypub.ex | 2243 +++
.../lib/elektrine/activitypub/activity.ex | 56 +
.../activitypub/activity_delivery_worker.ex | 282 +
.../lib/elektrine/activitypub/actor.ex | 160 +
.../activitypub/actor_refresh_worker.ex | 82 +
.../lib/elektrine/activitypub/builder.ex | 1340 ++
.../collection_count_sync_worker.ex | 101 +
.../activitypub/collection_fetcher.ex | 316 +
.../activitypub/community_fetcher.ex | 69 +
.../lib/elektrine/activitypub/containment.ex | 145 +
.../lib/elektrine/activitypub/delivery.ex | 34 +
.../elektrine/activitypub/delivery_cleanup.ex | 44 +
.../activitypub/delivery_cleanup_worker.ex | 38 +
.../activitypub/delivery_retry_worker.ex | 22 +
.../activitypub/domain_delivery_health.ex | 68 +
.../elektrine/activitypub/domain_migration.ex | 353 +
.../elektrine/activitypub/domain_throttler.ex | 227 +
.../activitypub/federation_cleanup_worker.ex | 73 +
.../activitypub/federation_helpers.ex | 168 +
.../activitypub/federation_load_guard.ex | 59 +
.../activitypub/fetch_remote_poll_service.ex | 58 +
.../activitypub/fetch_remote_poll_worker.ex | 69 +
.../lib/elektrine/activitypub/fetcher.ex | 1123 ++
.../lib/elektrine/activitypub/group_follow.ex | 25 +
.../lib/elektrine/activitypub/handler.ex | 327 +
.../activitypub/handlers/announce_handler.ex | 445 +
.../activitypub/handlers/block_handler.ex | 62 +
.../activitypub/handlers/create_handler.ex | 926 +
.../activitypub/handlers/delete_handler.ex | 108 +
.../activitypub/handlers/flag_handler.ex | 329 +
.../activitypub/handlers/follow_handler.ex | 419 +
.../activitypub/handlers/like_handler.ex | 424 +
.../activitypub/handlers/move_handler.ex | 168 +
.../activitypub/handlers/update_handler.ex | 413 +
.../lib/elektrine/activitypub/helpers.ex | 503 +
.../elektrine/activitypub/http_signature.ex | 158 +
.../activitypub/inbound_delivery_policy.ex | 223 +
.../lib/elektrine/activitypub/inbox_queue.ex | 292 +
.../activitypub/inbox_rate_limiter.ex | 144 +
.../activitypub/incoming_activity_worker.ex | 160 +
.../lib/elektrine/activitypub/instance.ex | 356 +
.../lib/elektrine/activitypub/instances.ex | 235 +
.../lib/elektrine/activitypub/key_manager.ex | 83 +
.../lib/elektrine/activitypub/lemmy_api.ex | 721 +
.../lib/elektrine/activitypub/lemmy_cache.ex | 173 +
.../activitypub/lemmy_cache_worker.ex | 43 +
.../activitypub/lemmy_comment_backfill.ex | 168 +
.../elektrine/activitypub/local_references.ex | 272 +
.../lib/elektrine/activitypub/mastodon_api.ex | 944 +
.../lib/elektrine/activitypub/mentions.ex | 136 +
.../lib/elektrine/activitypub/mrf.ex | 164 +
.../activitypub/mrf/anti_followbot_policy.ex | 85 +
.../activitypub/mrf/anti_link_spam_policy.ex | 92 +
.../mrf/anti_mention_spam_policy.ex | 68 +
.../elektrine/activitypub/mrf/emoji_policy.ex | 102 +
.../mrf/force_bot_unlisted_policy.ex | 51 +
.../activitypub/mrf/hashtag_policy.ex | 53 +
.../activitypub/mrf/hellthread_policy.ex | 138 +
.../activitypub/mrf/inline_quote_policy.ex | 56 +
.../activitypub/mrf/keyword_policy.ex | 207 +
.../activitypub/mrf/mention_policy.ex | 35 +
.../activitypub/mrf/no_empty_policy.ex | 48 +
.../mrf/no_placeholder_text_policy.ex | 24 +
.../activitypub/mrf/normalize_policy.ex | 42 +
.../activitypub/mrf/object_age_policy.ex | 125 +
.../lib/elektrine/activitypub/mrf/policy.ex | 50 +
.../activitypub/mrf/quiet_reply_policy.ex | 34 +
.../mrf/quote_to_link_tag_policy.ex | 37 +
.../mrf/reject_non_public_policy.ex | 44 +
.../activitypub/mrf/remote_report_policy.ex | 61 +
.../activitypub/mrf/simple_policy.ex | 445 +
.../activitypub/mrf/user_allow_list_policy.ex | 67 +
.../lib/elektrine/activitypub/mrf/utils.ex | 180 +
.../activitypub/mrf/vocabulary_policy.ex | 43 +
.../lib/elektrine/activitypub/nodeinfo.ex | 250 +
.../activitypub/nodeinfo_fetcher_worker.ex | 305 +
.../lib/elektrine/activitypub/normalizer.ex | 1369 ++
.../activitypub/object_deliveries.ex | 83 +
.../elektrine/activitypub/object_delivery.ex | 34 +
.../elektrine/activitypub/object_validator.ex | 549 +
.../lib/elektrine/activitypub/outbox.ex | 1109 ++
.../lib/elektrine/activitypub/pipeline.ex | 221 +
.../activitypub/process_activity_worker.ex | 421 +
.../lib/elektrine/activitypub/publisher.ex | 291 +
.../activitypub/reachability_worker.ex | 81 +
.../activitypub/refresh_counts_worker.ex | 890 +
.../lib/elektrine/activitypub/relay.ex | 630 +
.../activitypub/relay_subscription.ex | 82 +
.../lib/elektrine/activitypub/remote_fetch.ex | 191 +
.../elektrine/activitypub/replies_fetcher.ex | 868 +
.../activitypub/replies_ingest_worker.ex | 44 +
.../activitypub/reply_fetch_policy.ex | 130 +
.../elektrine/activitypub/request_replay.ex | 46 +
.../activitypub/request_replay_cache.ex | 118 +
.../lib/elektrine/activitypub/side_effects.ex | 117 +
.../activitypub/signature_retry_worker.ex | 251 +
.../activitypub/signature_verification.ex | 479 +
.../lib/elektrine/activitypub/signing_key.ex | 212 +
.../submitted_link_repair_worker.ex | 183 +
.../activitypub/thread_backfill_worker.ex | 121 +
.../lib/elektrine/activitypub/tombstone.ex | 26 +
.../lib/elektrine/activitypub/tombstones.ex | 157 +
.../elektrine/activitypub/undo_resolver.ex | 157 +
.../lib/elektrine/activitypub/user_block.ex | 23 +
.../lib/elektrine/activitypub/visibility.ex | 72 +
apps/elektrine/lib/elektrine/admin.ex | 305 +
.../lib/elektrine/admin/announcement.ex | 72 +
.../elektrine/admin/announcement_dismissal.ex | 28 +
.../lib/elektrine/api/import_rate_limiter.ex | 14 +
.../lib/elektrine/api/media_rate_limiter.ex | 14 +
.../lib/elektrine/api/rate_limiter.ex | 20 +
.../lib/elektrine/api/search_rate_limiter.ex | 14 +
.../elektrine/api/timeline_rate_limiter.ex | 14 +
.../lib/elektrine/api/write_rate_limiter.ex | 14 +
apps/elektrine/lib/elektrine/app_cache.ex | 921 +
apps/elektrine/lib/elektrine/application.ex | 118 +
apps/elektrine/lib/elektrine/async.ex | 66 +
.../lib/elektrine/atomine_proof_bundle.ex | 232 +
.../lib/elektrine/atomine_proof_graph.ex | 401 +
apps/elektrine/lib/elektrine/audit_log.ex | 91 +
.../elektrine/auth/api_token_revocation.ex | 24 +
.../lib/elektrine/auth/rate_limiter.ex | 25 +
apps/elektrine/lib/elektrine/bluesky.ex | 1773 ++
.../lib/elektrine/bluesky/finch_client.ex | 11 +
.../lib/elektrine/bluesky/inbound.ex | 484 +
.../lib/elektrine/bluesky/inbound_event.ex | 26 +
.../elektrine/bluesky/inbound_poll_worker.ex | 20 +
.../lib/elektrine/bluesky/managed.ex | 519 +
.../lib/elektrine/bluesky/outbound_worker.ex | 163 +
apps/elektrine/lib/elektrine/booking.ex | 363 +
.../lib/elektrine/booking/booking.ex | 94 +
.../elektrine/booking/confirmation_worker.ex | 78 +
.../elektrine/booking/expire_holds_worker.ex | 25 +
.../lib/elektrine/booking/notifier.ex | 101 +
apps/elektrine/lib/elektrine/booking/page.ex | 116 +
.../lib/elektrine/caddy_tls_domain_cache.ex | 26 +
apps/elektrine/lib/elektrine/calendar.ex | 313 +
.../lib/elektrine/calendar/availability.ex | 325 +
.../lib/elektrine/calendar/calendar.ex | 45 +
.../elektrine/lib/elektrine/calendar/event.ex | 159 +
.../lib/elektrine/calendar/icalendar.ex | 620 +
.../lib/elektrine/calendar/recurrence.ex | 353 +
apps/elektrine/lib/elektrine/calls.ex | 354 +
apps/elektrine/lib/elektrine/calls/call.ex | 50 +
.../lib/elektrine/calls/transport.ex | 118 +
apps/elektrine/lib/elektrine/captcha.ex | 217 +
.../lib/elektrine/components/copy_button.ex | 43 +
.../components/experimental_notice.ex | 47 +
.../elektrine/components/loaders/skeleton.ex | 171 +
.../lib/elektrine/components/report_modal.ex | 267 +
.../elektrine/components/trust_level_badge.ex | 98 +
.../lib/elektrine/components/user/avatar.ex | 343 +
.../components/user/username_effects.ex | 244 +
.../components/user/verification_badge.ex | 55 +
apps/elektrine/lib/elektrine/constants.ex | 156 +
.../lib/elektrine/database/index_repair.ex | 69 +
.../lib/elektrine/dav/rate_limiter.ex | 23 +
.../elektrine/lib/elektrine/db/write_guard.ex | 67 +
apps/elektrine/lib/elektrine/developer.ex | 854 +
.../lib/elektrine/developer/api_token.ex | 375 +
.../lib/elektrine/developer/data_export.ex | 227 +
.../developer/exports/account_exporter.ex | 546 +
.../developer/exports/chat_exporter.ex | 191 +
.../developer/exports/email_exporter.ex | 147 +
.../developer/exports/social_exporter.ex | 250 +
.../lib/elektrine/developer/webhook.ex | 170 +
.../elektrine/developer/webhook_delivery.ex | 58 +
.../developer/workers/export_worker.ex | 280 +
.../workers/webhook_delivery_worker.ex | 37 +
apps/elektrine/lib/elektrine/discord.ex | 180 +
apps/elektrine/lib/elektrine/domains.ex | 707 +
apps/elektrine/lib/elektrine/drive.ex | 1287 ++
.../lib/elektrine/drive/file_share.ex | 69 +
.../lib/elektrine/drive/stored_file.ex | 72 +
.../lib/elektrine/drive/stored_folder.ex | 51 +
.../lib/elektrine/email_addresses.ex | 49 +
apps/elektrine/lib/elektrine/emojis.ex | 427 +
.../lib/elektrine/emojis/custom_emoji.ex | 91 +
apps/elektrine/lib/elektrine/encryption.ex | 219 +
.../lib/elektrine/encryption/key_cache.ex | 120 +
apps/elektrine/lib/elektrine/friends.ex | 405 +
.../lib/elektrine/friends/friend_request.ex | 32 +
apps/elektrine/lib/elektrine/home_blog.ex | 79 +
apps/elektrine/lib/elektrine/http/backoff.ex | 211 +
.../lib/elektrine/http/safe_fetch.ex | 230 +
apps/elektrine/lib/elektrine/input.ex | 24 +
apps/elektrine/lib/elektrine/internal_api.ex | 11 +
apps/elektrine/lib/elektrine/ip_lookup.ex | 130 +
apps/elektrine/lib/elektrine/job_queue.ex | 50 +
.../lib/elektrine/job_queue_monitor.ex | 209 +
.../lib/elektrine/jobs/attachment_uploader.ex | 124 +
.../auto_promote_eligible_users_worker.ex | 22 +
...deactivate_expired_announcements_worker.ex | 22 +
.../lib/elektrine/jobs/email_recategorizer.ex | 95 +
.../profile_custom_domain_health_worker.ex | 18 +
...lculate_recent_discussion_scores_worker.ex | 31 +
.../elektrine/jobs/reply_later_processor.ex | 63 +
.../lib/elektrine/jobs/stale_call_cleanup.ex | 71 +
apps/elektrine/lib/elektrine/link_archive.ex | 298 +
.../lib/elektrine/link_archive/archive.ex | 67 +
.../elektrine/link_archive/fetch_worker.ex | 198 +
.../elektrine/lib/elektrine/mail/telemetry.ex | 54 +
.../lib/elektrine/mail_auth/rate_limiter.ex | 84 +
.../lib/elektrine/mail_client_settings.ex | 125 +
apps/elektrine/lib/elektrine/mailer.ex | 18 +
.../lib/elektrine/mailer/delivery_worker.ex | 56 +
apps/elektrine/lib/elektrine/markdown.ex | 97 +
apps/elektrine/lib/elektrine/markers.ex | 141 +
.../elektrine/lib/elektrine/markers/marker.ex | 33 +
apps/elektrine/lib/elektrine/media_proxy.ex | 365 +
apps/elektrine/lib/elektrine/messaging.ex | 1287 ++
.../elektrine/messaging/arblarg_profiles.ex | 379 +
.../lib/elektrine/messaging/arblarg_sdk.ex | 2794 +++
.../lib/elektrine/messaging/auto_mod_rule.ex | 40 +
.../elektrine/messaging/channel_categories.ex | 228 +
.../elektrine/messaging/channel_category.ex | 33 +
.../elektrine/messaging/chat_conversation.ex | 97 +
.../chat_conversation_encryption_key.ex | 31 +
.../chat_conversation_key_recipient.ex | 55 +
.../messaging/chat_conversation_member.ex | 8 +
.../elektrine/messaging/chat_conversations.ex | 1176 ++
.../messaging/chat_encryption_device.ex | 73 +
.../lib/elektrine/messaging/chat_message.ex | 567 +
.../elektrine/messaging/chat_message_pin.ex | 29 +
.../elektrine/messaging/chat_message_pins.ex | 355 +
.../messaging/chat_message_reaction.ex | 45 +
.../lib/elektrine/messaging/chat_messages.ex | 2015 ++
.../messaging/chat_moderation_action.ex | 35 +
.../chat_remote_encryption_device.ex | 81 +
.../lib/elektrine/messaging/chat_thread.ex | 86 +
.../lib/elektrine/messaging/chat_threads.ex | 665 +
.../messaging/chat_user_hidden_message.ex | 26 +
.../elektrine/messaging/chat_user_timeout.ex | 24 +
.../lib/elektrine/messaging/chat_webhook.ex | 106 +
.../lib/elektrine/messaging/chat_webhooks.ex | 352 +
.../lib/elektrine/messaging/community_ban.ex | 61 +
.../elektrine/messaging/community_flair.ex | 45 +
.../elektrine/messaging/community_search.ex | 196 +
.../messaging/conversation_member.ex | 25 +
.../elektrine/messaging/federated_boost.ex | 24 +
.../elektrine/messaging/federated_dislike.ex | 24 +
.../lib/elektrine/messaging/federated_like.ex | 24 +
.../lib/elektrine/messaging/federation.ex | 227 +
.../elektrine/messaging/federation/actors.ex | 265 +
.../messaging/federation/attachments.ex | 118 +
.../elektrine/messaging/federation/auth.ex | 383 +
.../messaging/federation/builders.ex | 1116 ++
.../elektrine/messaging/federation/config.ex | 815 +
.../messaging/federation/contexts.ex | 680 +
.../messaging/federation/delivery.ex | 710 +
.../federation/direct_message_state.ex | 555 +
.../messaging/federation/direct_messages.ex | 173 +
.../messaging/federation/discovery.ex | 1608 ++
.../messaging/federation/dispatch.ex | 232 +
.../elektrine/messaging/federation/egress.ex | 489 +
.../elektrine/messaging/federation/errors.ex | 96 +
.../messaging/federation/event_router.ex | 110 +
.../messaging/federation/event_tracking.ex | 90 +
.../messaging/federation/extension_events.ex | 512 +
.../elektrine/messaging/federation/inbound.ex | 271 +
.../elektrine/messaging/federation/ingress.ex | 362 +
.../federation/ingress_rate_limiter.ex | 158 +
.../messaging/federation/maintenance.ex | 60 +
.../messaging/federation/mirror_broadcasts.ex | 199 +
.../messaging/federation/mirror_events.ex | 1297 ++
.../elektrine/messaging/federation/mirrors.ex | 701 +
.../messaging/federation/peer_policies.ex | 407 +
.../elektrine/messaging/federation/peers.ex | 311 +
.../messaging/federation/protocol.ex | 328 +
.../messaging/federation/public_discovery.ex | 89 +
.../messaging/federation/publisher.ex | 484 +
.../messaging/federation/request_auth.ex | 256 +
.../elektrine/messaging/federation/runtime.ex | 215 +
.../messaging/federation/snapshots.ex | 1229 ++
.../elektrine/messaging/federation/state.ex | 975 +
.../messaging/federation/transport.ex | 287 +
.../elektrine/messaging/federation/utils.ex | 721 +
.../messaging/federation/validation.ex | 892 +
.../messaging/federation/visibility.ex | 408 +
.../messaging/federation/voice_calls.ex | 583 +
.../federation_account_presence_state.ex | 44 +
.../messaging/federation_call_session.ex | 71 +
.../messaging/federation_discovered_peer.ex | 82 +
.../elektrine/messaging/federation_event.ex | 53 +
.../messaging/federation_extension_event.ex | 43 +
.../messaging/federation_invite_state.ex | 63 +
.../messaging/federation_membership_state.ex | 53 +
.../messaging/federation_outbox_event.ex | 61 +
.../federation_outbox_retry_worker.ex | 17 +
.../messaging/federation_outbox_worker.ex | 51 +
.../messaging/federation_peer_policy.ex | 51 +
.../messaging/federation_read_cursor.ex | 45 +
.../messaging/federation_request_replay.ex | 43 +
.../messaging/federation_retention_worker.ex | 17 +
.../federation_room_presence_state.ex | 48 +
.../messaging/federation_session_client.ex | 995 +
.../messaging/federation_session_websock.ex | 248 +
.../messaging/federation_stream_position.ex | 22 +
.../messaging/link_preview_broadcast.ex | 67 +
.../elektrine/messaging/member_schema_base.ex | 129 +
.../lib/elektrine/messaging/membership.ex | 651 +
.../elektrine/messaging/membership_authz.ex | 54 +
.../elektrine/messaging/message_reaction.ex | 88 +
.../lib/elektrine/messaging/moderation.ex | 644 +
.../elektrine/messaging/moderation_action.ex | 109 +
.../elektrine/messaging/moderation_tools.ex | 714 +
.../lib/elektrine/messaging/moderator_note.ex | 29 +
.../lib/elektrine/messaging/rate_limiter.ex | 208 +
.../lib/elektrine/messaging/reactions.ex | 183 +
.../lib/elektrine/messaging/reference_peer.ex | 323 +
.../messaging/reference_peer_protocol.ex | 475 +
.../reference_peer_session_server.ex | 644 +
.../lib/elektrine/messaging/room_acl.ex | 578 +
.../lib/elektrine/messaging/server.ex | 80 +
.../lib/elektrine/messaging/server_member.ex | 56 +
.../lib/elektrine/messaging/servers.ex | 706 +
.../lib/elektrine/messaging/slash_commands.ex | 80 +
.../messaging/social_conversation.ex | 343 +
.../lib/elektrine/messaging/social_message.ex | 1004 +
.../messaging/sync_remote_counts_worker.ex | 53 +
.../messaging/user_hidden_message.ex | 24 +
.../messaging/user_post_timestamp.ex | 26 +
.../lib/elektrine/messaging/user_timeout.ex | 44 +
.../lib/elektrine/messaging/user_warning.ex | 40 +
.../lib/elektrine/messaging/voice_channels.ex | 96 +
apps/elektrine/lib/elektrine/notifications.ex | 1393 ++
.../notifications/federation_notifications.ex | 174 +
.../elektrine/notifications/notification.ex | 83 +
apps/elektrine/lib/elektrine/oauth.ex | 177 +
apps/elektrine/lib/elektrine/oauth/app.ex | 362 +
.../lib/elektrine/oauth/authorization.ex | 226 +
apps/elektrine/lib/elektrine/oauth/scopes.ex | 184 +
apps/elektrine/lib/elektrine/oauth/token.ex | 414 +
apps/elektrine/lib/elektrine/oidc.ex | 261 +
.../lib/elektrine/oidc/signing_key.ex | 37 +
apps/elektrine/lib/elektrine/own_root.ex | 248 +
apps/elektrine/lib/elektrine/paths.ex | 372 +
.../elektrine/lib/elektrine/platform/e_nav.ex | 333 +
.../lib/elektrine/platform/e_nav_component.ex | 199 +
.../lib/elektrine/platform/integrations.ex | 13 +
.../lib/elektrine/platform/module_provider.ex | 14 +
.../elektrine/platform/module_providers.ex | 52 +
.../lib/elektrine/platform/modules.ex | 213 +
.../platform/runtime_config_validator.ex | 288 +
apps/elektrine/lib/elektrine/privacy.ex | 331 +
apps/elektrine/lib/elektrine/profiles.ex | 2336 +++
.../profiles/analytics_retention_worker.ex | 17 +
.../lib/elektrine/profiles/custom_domain.ex | 91 +
.../lib/elektrine/profiles/custom_domains.ex | 369 +
.../lib/elektrine/profiles/follow.ex | 67 +
.../elektrine/profiles/per_site_identities.ex | 100 +
.../elektrine/profiles/per_site_identity.ex | 106 +
.../lib/elektrine/profiles/profile_link.ex | 315 +
.../elektrine/profiles/profile_site_visit.ex | 59 +
.../lib/elektrine/profiles/profile_view.ex | 52 +
.../lib/elektrine/profiles/profile_widget.ex | 60 +
.../public_site_analytics_rollup_worker.ex | 33 +
.../lib/elektrine/profiles/site_page_visit.ex | 61 +
.../lib/elektrine/profiles/site_session.ex | 83 +
.../elektrine/profiles/static_site_deploy.ex | 63 +
.../profiles/static_site_deployment.ex | 137 +
.../elektrine/profiles/static_site_file.ex | 109 +
.../lib/elektrine/profiles/user_badge.ex | 147 +
.../lib/elektrine/profiles/user_profile.ex | 524 +
.../elektrine/lib/elektrine/proxy_protocol.ex | 168 +
apps/elektrine/lib/elektrine/pubsub_topics.ex | 140 +
apps/elektrine/lib/elektrine/push.ex | 739 +
.../lib/elektrine/push/device_token.ex | 52 +
.../lib/elektrine/push/web_push_client.ex | 197 +
.../lib/elektrine/push/web_subscription.ex | 87 +
apps/elektrine/lib/elektrine/rate_limiter.ex | 355 +
apps/elektrine/lib/elektrine/release.ex | 98 +
apps/elektrine/lib/elektrine/repo.ex | 5 +
apps/elektrine/lib/elektrine/reports.ex | 680 +
.../elektrine/lib/elektrine/reports/report.ex | 76 +
apps/elektrine/lib/elektrine/rss.ex | 294 +
apps/elektrine/lib/elektrine/rss/feed.ex | 88 +
.../lib/elektrine/rss/fetch_feed_worker.ex | 187 +
apps/elektrine/lib/elektrine/rss/item.ex | 47 +
apps/elektrine/lib/elektrine/rss/parser.ex | 355 +
.../lib/elektrine/rss/scheduler_worker.ex | 25 +
.../lib/elektrine/rss/subscription.ex | 36 +
apps/elektrine/lib/elektrine/runtime_env.ex | 57 +
.../lib/elektrine/runtime_secrets.ex | 68 +
apps/elektrine/lib/elektrine/search.ex | 1175 ++
apps/elektrine/lib/elektrine/search/cached.ex | 120 +
.../lib/elektrine/search/domain_rule.ex | 45 +
.../lib/elektrine/search/domain_rules.ex | 94 +
.../elektrine/search/paige_rate_limiter.ex | 49 +
.../lib/elektrine/search/rate_limiter.ex | 38 +
.../lib/elektrine/secrets/backfill.ex | 166 +
.../lib/elektrine/secrets/encrypted_string.ex | 92 +
.../elektrine/security/content_validator.ex | 195 +
.../lib/elektrine/security/file_path.ex | 49 +
.../elektrine/security/safe_external_url.ex | 52 +
.../lib/elektrine/security/url_validator.ex | 496 +
.../lib/elektrine/security_alerts.ex | 315 +
.../lib/elektrine/security_alerts/cache.ex | 24 +
.../social/attachment_cleanup_worker.ex | 64 +
.../lib/elektrine/social/conversations.ex | 1348 ++
.../social/engagement_count_repair.ex | 181 +
.../social/engagement_count_repair_worker.ex | 30 +
.../lib/elektrine/social/engagement_counts.ex | 41 +
.../social/expired_data_cleanup_worker.ex | 61 +
.../elektrine/lib/elektrine/social/hashtag.ex | 39 +
.../lib/elektrine/social/link_preview.ex | 102 +
.../lib/elektrine/social/message_stat.ex | 49 +
.../lib/elektrine/social/message_stats.ex | 84 +
.../lib/elektrine/social/messages.ex | 2604 +++
apps/elektrine/lib/elektrine/social/poll.ex | 81 +
.../lib/elektrine/social/poll_option.ex | 27 +
.../lib/elektrine/social/poll_vote.ex | 28 +
.../lib/elektrine/social/post_hashtag.ex | 20 +
.../lib/elektrine/social/scrobble.ex | 73 +
.../lib/elektrine/social/scrobbles.ex | 88 +
apps/elektrine/lib/elektrine/static_sites.ex | 1215 ++
.../static_sites/github_deploy_worker.ex | 199 +
.../static_sites/request_resolver.ex | 120 +
apps/elektrine/lib/elektrine/strings.ex | 16 +
apps/elektrine/lib/elektrine/subscriptions.ex | 1231 ++
.../lib/elektrine/subscriptions/product.ex | 230 +
.../subscriptions/registration_checkout.ex | 67 +
.../elektrine/subscriptions/stripe_client.ex | 9 +
.../subscriptions/stripe_client/live.ex | 20 +
.../elektrine/subscriptions/subscription.ex | 102 +
apps/elektrine/lib/elektrine/system.ex | 191 +
apps/elektrine/lib/elektrine/system/config.ex | 43 +
.../lib/elektrine/telemetry/events.ex | 237 +
apps/elektrine/lib/elektrine/text_helpers.ex | 296 +
apps/elektrine/lib/elektrine/theme.ex | 456 +
apps/elektrine/lib/elektrine/time.ex | 11 +
.../lib/elektrine/timeline/rate_limiter.ex | 32 +
apps/elektrine/lib/elektrine/uploads.ex | 1619 ++
apps/elektrine/lib/elektrine/user_notifier.ex | 147 +
.../lib/elektrine/utils/safe_convert.ex | 178 +
apps/elektrine/lib/elektrine/utils/slug.ex | 60 +
apps/elektrine/lib/elektrine/vault.ex | 86 +
.../lib/elektrine/vault/master_key.ex | 107 +
apps/elektrine/lib/elektrine/web_index.ex | 313 +
.../lib/elektrine/web_index/crawl_worker.ex | 12 +
.../lib/elektrine/web_index/crawler.ex | 314 +
.../lib/elektrine/web_index/document.ex | 56 +
.../lib/elektrine/web_index/extractor.ex | 117 +
.../lib/elektrine/web_index/fetcher.ex | 78 +
.../elektrine/lib/elektrine/web_index/host.ex | 35 +
.../lib/elektrine/web_index/provider.ex | 32 +
.../lib/elektrine/web_index/robots.ex | 126 +
.../elektrine/web_index/scheduler_worker.ex | 22 +
.../lib/elektrine/web_index/sitemap_worker.ex | 12 +
.../lib/elektrine/webhook/rate_limiter.ex | 16 +
.../activitypub.backfill_signing_keys.ex | 109 +
.../mix/tasks/activitypub.migrate_domain.ex | 82 +
.../activitypub.repair_submitted_links.ex | 31 +
apps/elektrine/lib/mix/tasks/admin.make.ex | 44 +
.../lib/mix/tasks/analytics.seed_site_load.ex | 274 +
.../lib/mix/tasks/arblarg.conformance.ex | 113 +
apps/elektrine/lib/mix/tasks/badge.grant.ex | 101 +
apps/elektrine/lib/mix/tasks/badge.revoke.ex | 40 +
.../mix/tasks/cleanup_duplicate_mailboxes.ex | 122 +
.../lib/mix/tasks/discussions.demo_posts.ex | 225 +
.../mix/tasks/elektrine.db.repair_indexes.ex | 53 +
.../lib/mix/tasks/email.inbound_dlq.ex | 172 +
.../lib/mix/tasks/email.pubsub_test.ex | 34 +
.../mix/tasks/email.recalculate_storage.ex | 64 +
.../lib/mix/tasks/email.recategorize.ex | 172 +
.../lib/mix/tasks/email.seed_plaintext.ex | 258 +
.../lib/mix/tasks/email.seed_realistic.ex | 829 +
apps/elektrine/lib/mix/tasks/email.test.ex | 106 +
.../lib/mix/tasks/fix_username_duplicates.ex | 161 +
.../tasks/messaging.arblarg.sync_artifacts.ex | 49 +
.../messaging.federation.interop_smoke.ex | 193 +
.../tasks/messaging.federation.load_test.ex | 400 +
.../mix/tasks/migrate_email_attachments.ex | 143 +
.../lib/mix/tasks/secrets.backfill.ex | 28 +
.../social.backfill_lemmy_comment_votes.ex | 53 +
.../tasks/social.recalculate_reply_counts.ex | 69 +
.../tasks/social.repair_engagement_counts.ex | 48 +
apps/elektrine/lib/mix/tasks/test_2fa.ex | 43 +
.../lib/mix/tasks/users.import_existing.ex | 87 +
apps/elektrine/mix.exs | 153 +
apps/elektrine/priv/gettext/default.pot | 3159 ++++
apps/elektrine/priv/gettext/errors.pot | 109 +
.../priv/gettext/zh/LC_MESSAGES/default.po | 3154 ++++
.../priv/gettext/zh/LC_MESSAGES/errors.po | 102 +
apps/elektrine/priv/repo/baseline_schema.sql | 15533 ++++++++++++++++
.../priv/repo/generate_activitypub_keys.exs | 35 +
.../priv/repo/migrations/.formatter.exs | 4 +
.../20250511044001_create_users_table.exs | 14 +
.../20250512000001_create_mailboxes.exs | 16 +
.../20250512000002_create_email_messages.exs | 28 +
...50513061546_create_temporary_mailboxes.exs | 17 +
...13064926_make_mailbox_user_id_nullable.exs | 22 +
...pdate_messages_for_temporary_mailboxes.exs | 16 +
.../20250529050458_add_avatar_to_users.exs | 9 +
...809_add_hey_features_to_email_messages.exs | 43 +
...20250608202829_create_approved_senders.exs | 20 +
...dd_spam_and_archived_to_email_messages.exs | 13 +
.../20250615002423_import_existing_users.exs | 12 +
...615202324_add_username_change_tracking.exs | 9 +
...20250615235823_add_admin_role_to_users.exs | 9 +
.../20250616000949_add_banned_to_users.exs | 11 +
.../20250622162141_create_email_aliases.exs | 20 +
...0622164337_add_forwarding_to_mailboxes.exs | 12 +
...50622165252_make_target_email_nullable.exs | 9 +
...10_add_alias_username_conflict_indexes.exs | 11 +
...3020_add_attachments_to_email_messages.exs | 12 +
...65910_create_account_deletion_requests.exs | 21 +
...628155713_add_two_factor_auth_to_users.exs | 12 +
.../20250630003407_create_invite_codes.exs | 21 +
...20250630003424_create_invite_code_uses.exs | 15 +
.../20250630011535_create_system_config.exs | 23 +
...20250706183626_create_rejected_senders.exs | 20 +
...184034_add_user_to_temporary_mailboxes.exs | 11 +
...12172013_add_bluesky_identity_to_users.exs | 18 +
...250720223936_add_github_token_to_users.exs | 12 +
...20250726184803_remove_screener_feature.exs | 15 +
.../20250817001342_create_siem_tables.exs | 201 +
...1916_create_threat_intelligence_tables.exs | 273 +
.../20250817210439_add_cybersecurity_news.exs | 90 +
...22_add_underground_threat_intelligence.exs | 305 +
.../20250902065912_create_subscriptions.exs | 19 +
...3172225_create_user_profiles_and_links.exs | 50 +
...04070740_add_location_to_user_profiles.exs | 9 +
...1431_add_favicon_and_title_to_profiles.exs | 10 +
...add_advanced_customization_to_profiles.exs | 17 +
...0250905060932_add_ip_tracking_to_users.exs | 17 +
...20250906055118_create_platform_updates.exs | 19 +
.../20250906081641_create_audit_logs.exs | 25 +
.../20250907053034_create_announcements.exs | 23 +
...7074626_create_announcement_dismissals.exs | 17 +
...250907193037_add_display_name_to_users.exs | 9 +
...0907232526_add_recovery_email_to_users.exs | 13 +
.../20250909041315_add_locale_to_users.exs | 9 +
...163235_remove_temporary_mailbox_system.exs | 52 +
...20250911070737_create_username_history.exs | 21 +
...5836_add_storage_tracking_to_mailboxes.exs | 14 +
...0912173431_update_storage_limit_to_5mb.exs | 8 +
...250913160643_remove_bluesky_from_users.exs | 18 +
...163916_remove_custom_css_from_profiles.exs | 13 +
...03149_add_privacy_settings_to_profiles.exs | 10 +
...20250914005156_create_messaging_system.exs | 85 +
.../20250914031911_add_hidden_messages.exs | 18 +
...250914073452_add_hash_to_conversations.exs | 11 +
...50914073519_add_hash_to_email_messages.exs | 11 +
...0250914074416_populate_existing_hashes.exs | 28 +
...14081434_remove_username_change_fields.exs | 9 +
...250914091730_add_username_to_mailboxes.exs | 26 +
.../20250914172837_create_user_blocks.exs | 17 +
...0911_add_last_password_change_to_users.exs | 9 +
...1003_add_index_to_last_password_change.exs | 7 +
.../20250915070107_add_moderation_system.exs | 36 +
...16052139_update_storage_limit_to_500mb.exs | 8 +
.../20250916171831_create_follows.exs | 16 +
.../20250918055140_create_reports.exs | 52 +
...20250918063443_add_suspension_to_users.exs | 15 +
...165958_add_social_features_to_messages.exs | 45 +
...23_remove_duplicate_user_follows_table.exs | 8 +
.../20250919043723_add_link_previews.exs | 30 +
.../20250919050445_add_hashtag_system.exs | 37 +
...919124426_add_discussion_voting_system.exs | 30 +
...802_add_cross_context_promotion_fields.exs | 31 +
.../20250919132803_add_titles_to_messages.exs | 13 +
...d_unique_constraint_to_community_names.exs | 11 +
...313_add_promoted_from_community_fields.exs | 10 +
...ead_message_id_to_conversation_members.exs | 11 +
.../20250923055335_create_notifications.exs | 45 +
...se_insensitive_unique_index_to_aliases.exs | 9 +
...case_insensitive_unique_index_to_users.exs | 11 +
...000_fix_duplicate_aliases_before_index.exs | 33 +
...924000001_add_social_identity_to_users.exs | 62 +
...0250924000002_grandfather_user_handles.exs | 73 +
...350_regenerate_missing_handles_and_ids.exs | 115 +
...151252_migrate_email_attachments_to_s3.exs | 20 +
...26045456_add_privacy_settings_to_users.exs | 29 +
...926052516_set_default_privacy_settings.exs | 23 +
...me_email_notification_fields_to_notify.exs | 11 +
.../20250927050233_add_community_flairs.exs | 28 +
.../20250927065000_update_reply_counts.exs | 27 +
.../20250927070000_add_vote_indexes.exs | 13 +
.../20250927071000_add_community_rules.exs | 9 +
.../20250927153135_add_pinned_to_messages.exs | 13 +
.../20250927153154_create_community_bans.exs | 19 +
...20250927161505_rename_email_categories.exs | 33 +
.../20250927195300_rename_feed_to_digest.exs | 17 +
.../20250930065342_create_app_passwords.exs | 20 +
...30145634_add_flagged_to_email_messages.exs | 9 +
...153632_allow_null_to_in_email_messages.exs | 9 +
...30163613_add_deleted_to_email_messages.exs | 12 +
...0171320_add_answered_to_email_messages.exs | 11 +
.../20251004205738_create_profile_views.exs | 23 +
...251005051754_add_user_storage_tracking.exs | 14 +
...05052144_remove_mailbox_storage_fields.exs | 10 +
...01_update_user_storage_limits_to_500mb.exs | 13 +
...5145316_add_media_metadata_to_messages.exs | 9 +
..._file_sizes_to_avatars_and_backgrounds.exs | 15 +
...move_animated_title_from_user_profiles.exs | 9 +
...4404_add_case_insensitive_handle_index.exs | 11 +
..._case_insensitive_community_name_index.exs | 17 +
.../20251008000912_add_timezone_to_users.exs | 9 +
...0251008012454_add_time_format_to_users.exs | 9 +
...move_notify_on_group_invite_from_users.exs | 9 +
...51008150636_add_encryption_to_messages.exs | 12 +
...50659_add_encryption_to_email_messages.exs | 13 +
...1008181136_add_email_protocol_tracking.exs | 14 +
.../20251009063632_add_user_status.exs | 13 +
.../20251009190614_add_last_seen_to_users.exs | 11 +
...31206_add_username_effects_to_profiles.exs | 16 +
.../20251010035439_create_user_badges.exs | 21 +
...5441_add_typewriter_effect_to_profiles.exs | 10 +
.../20251010035442_create_profile_widgets.exs | 22 +
...50458_add_typewriter_title_to_profiles.exs | 9 +
...46_add_link_display_style_and_sections.exs | 13 +
...9_add_container_background_to_profiles.exs | 10 +
...0017_add_container_pattern_to_profiles.exs | 10 +
...0645_add_pattern_animation_to_profiles.exs | 10 +
...611_add_visibility_toggles_to_profiles.exs | 11 +
...134141_add_text_background_to_profiles.exs | 9 +
...10134855_update_default_font_to_system.exs | 13 +
...51010135407_add_visible_to_user_badges.exs | 9 +
...8_add_pattern_opacity_to_user_profiles.exs | 9 +
.../20251010150620_add_verified_to_users.exs | 9 +
...40_rename_early_adopter_to_beta_tester.exs | 11 +
...51010204959_remove_font_family_default.exs | 13 +
...11035521_add_avatar_effect_to_profiles.exs | 9 +
...51011050154_add_tick_color_to_profiles.exs | 9 +
...738_add_comprehensive_privacy_controls.exs | 19 +
...23_set_defaults_for_new_privacy_fields.exs | 22 +
...251011212159_create_forwarded_messages.exs | 23 +
...add_hide_share_button_to_user_profiles.exs | 9 +
...541_add_extend_layout_to_user_profiles.exs | 9 +
..._hide_community_posts_to_user_profiles.exs | 9 +
...link_highlight_effect_to_user_profiles.exs | 9 +
...756_add_thumbnail_url_to_profile_links.exs | 9 +
...551_add_display_style_to_profile_links.exs | 9 +
..._add_highlight_effect_to_profile_links.exs | 9 +
...rcular_links_position_to_user_profiles.exs | 9 +
...17051314_consolidate_conversation_type.exs | 54 +
...3439_add_show_qr_code_to_user_profiles.exs | 9 +
...remove_show_qr_code_from_user_profiles.exs | 9 +
...1017160525_drop_approved_senders_table.exs | 9 +
...0251017160604_cleanup_orphaned_indexes.exs | 21 +
.../20251018050726_add_post_types_support.exs | 68 +
..._add_comprehensive_moderation_features.exs | 99 +
.../20251018162421_add_onboarding_fields.exs | 20 +
.../20251018234059_add_contacts_system.exs | 35 +
...019041333_add_email_signature_to_users.exs | 9 +
...251021165630_create_email_unsubscribes.exs | 22 +
.../20251025131913_create_vpn_tables.exs | 82 +
...dd_bandwidth_quota_to_vpn_user_configs.exs | 16 +
...738_add_rate_limit_to_vpn_user_configs.exs | 10 +
.../20251026025047_add_trust_level_system.exs | 69 +
...26043133_remove_siem_intel_news_tables.exs | 44 +
...052454_remove_github_fields_from_users.exs | 12 +
...add_minimum_trust_level_to_vpn_servers.exs | 11 +
...ange_vpn_server_foreign_key_to_cascade.exs | 23 +
...027022325_add_notification_preferences.exs | 11 +
...n_and_comment_notification_preferences.exs | 10 +
...28160556_add_email_performance_indexes.exs | 77 +
...00_add_gallery_to_post_type_constraint.exs | 25 +
.../20251102020000_create_post_views.exs | 22 +
...20251102040000_add_performance_indexes.exs | 81 +
...00_add_preferred_email_domain_to_users.exs | 9 +
.../20251106000000_create_trusted_devices.exs | 21 +
...251108000001_create_activitypub_tables.exs | 154 +
...te_activitypub_keys_for_existing_users.exs | 14 +
...251108000003_make_follower_id_nullable.exs | 12 +
...onversation_id_nullable_for_federation.exs | 12 +
...251108000005_make_followed_id_nullable.exs | 10 +
.../20251108000006_create_post_boosts.exs | 19 +
...251108000007_create_activitypub_relays.exs | 26 +
...anually_approve_followers_default_true.exs | 22 +
.../20251109200000_drop_relays_table.exs | 24 +
.../20251109203000_create_federated_likes.exs | 17 +
.../20251110000001_create_lists.exs | 45 +
...add_published_at_to_activitypub_actors.exs | 17 +
.../20251111120000_create_custom_emojis.exs | 21 +
...000001_add_content_warning_to_messages.exs | 13 +
...02_add_federation_to_message_reactions.exs | 20 +
...0_add_community_support_to_activitypub.exs | 48 +
..._add_federated_source_to_conversations.exs | 19 +
..._regenerate_activitypub_keys_for_lemmy.exs | 21 +
...0001_change_activitypub_fields_to_text.exs | 25 +
...ake_message_reactions_user_id_nullable.exs | 11 +
.../20251203150755_add_federated_dislikes.exs | 23 +
.../20251203150938_add_federated_boosts.exs | 17 +
.../20251206171650_create_group_follows.exs | 18 +
...000000_add_missing_performance_indexes.exs | 48 +
...1213071259_extend_email_message_fields.exs | 16 +
...21_add_activitypub_performance_indexes.exs | 41 +
...20251216160458_drop_old_username_index.exs | 7 +
...070133_enhance_recommendation_tracking.exs | 64 +
...1218053438_add_processed_to_activities.exs | 22 +
...251218055225_add_background_job_tables.exs | 51 +
.../20251218172906_add_oban_jobs_table.exs | 11 +
...223045423_add_email_restriction_fields.exs | 18 +
...61418_add_unique_actor_username_domain.exs | 130 +
.../20251225205440_create_device_tokens.exs | 28 +
.../20251228000001_fix_duplicate_handles.exs | 39 +
.../20251228000002_add_calls_table.exs | 27 +
...251228000003_add_friend_requests_table.exs | 21 +
.../20251229015026_create_chat_messages.exs | 87 +
...51229020637_migrate_chat_messages_data.exs | 119 +
.../20251230180851_create_calendars.exs | 86 +
...1230181000_extend_contacts_for_carddav.exs | 77 +
.../20251231174204_add_pgp_support.exs | 49 +
...02051253_add_quote_support_to_messages.exs | 28 +
.../20260110225401_create_email_threads.exs | 15 +
...0110225402_add_jmap_fields_to_messages.exs | 16 +
...60110225403_create_jmap_state_tracking.exs | 15 +
...0260110225404_create_email_submissions.exs | 21 +
...3859_add_profile_mode_to_user_profiles.exs | 26 +
.../20260112200000_add_email_features.exs | 172 +
...60113024544_create_rss_and_saved_items.exs | 97 +
...0000_add_registered_via_onion_to_users.exs | 11 +
...0260117120000_create_user_integrations.exs | 23 +
...5247_add_composite_performance_indexes.exs | 65 +
...0260125082934_add_category_to_messages.exs | 12 +
.../20260125085508_create_api_tokens.exs | 23 +
.../20260125090103_create_data_exports.exs | 29 +
...125232349_add_draft_status_to_messages.exs | 15 +
.../20260126072102_add_lemmy_counts_cache.exs | 20 +
.../20260127034957_create_signing_keys.exs | 31 +
..._migrate_existing_keys_to_signing_keys.exs | 12 +
...27090905_add_mrf_policies_to_instances.exs | 30 +
...60127091426_create_relay_subscriptions.exs | 35 +
...60127100000_create_remote_interactions.exs | 36 +
...127100100_add_voters_tracking_to_polls.exs | 16 +
...200_add_emoji_url_to_message_reactions.exs | 13 +
...27100300_add_reachability_to_instances.exs | 15 +
.../20260127100400_create_hashtag_follows.exs | 15 +
.../20260127120000_create_oauth_tables.exs | 56 +
...260128142101_add_nodeinfo_to_instances.exs | 11 +
...0260128144518_drop_subscriptions_table.exs | 23 +
...60129145416_create_passkey_credentials.exs | 36 +
...203150001_create_billing_subscriptions.exs | 32 +
...203160001_create_subscription_products.exs | 27 +
...260205100001_add_pgp_wkd_hash_to_users.exs | 12 +
...260209020000_remove_ai_pentest_feature.exs | 66 +
...15110000_create_password_vault_entries.exs | 19 +
.../20260215123000_create_user_mutes.exs | 17 +
...260215124000_create_developer_webhooks.exs | 22 +
...0260215130000_create_messaging_servers.exs | 45 +
...federation_fields_to_messaging_servers.exs | 37 +
...ssaging_federation_event_stream_tables.exs | 44 +
...essaging_federation_outbox_and_archive.exs | 65 +
...216190000_create_api_token_revocations.exs | 16 +
...260218090000_create_email_suppressions.exs | 23 +
...260218123000_add_bluesky_bridge_fields.exs | 25 +
...218133000_add_bluesky_inbound_tracking.exs | 25 +
...20000_create_chat_user_hidden_messages.exs | 20 +
...ate_messaging_federation_peer_policies.exs | 23 +
...000_add_arblarg_replay_and_idempotency.exs | 104 +
...000_backfill_server_channel_visibility.exs | 17 +
...3101500_create_password_vault_settings.exs | 14 +
...260223143000_add_arbp_chat_projections.exs | 99 +
...260224083500_create_imap_subscriptions.exs | 15 +
..._messages_activitypub_url_lookup_index.exs | 21 +
...00_add_hot_path_feed_and_email_indexes.exs | 62 +
..._activitypub_actor_type_hot_path_index.exs | 17 +
...00_create_developer_webhook_deliveries.exs | 28 +
...0000_create_email_category_preferences.exs | 45 +
...8100000_add_arbp_fast_path_projections.exs | 34 +
..._messaging_federation_discovered_peers.exs | 31 +
..._presence_expiry_and_membership_states.exs | 40 +
..._messaging_federation_discovered_peers.exs | 17 +
...0308133000_create_email_custom_domains.exs | 24 +
...dd_dkim_fields_to_email_custom_domains.exs | 13 +
...im_constraints_on_email_custom_domains.exs | 11 +
...ate_messaging_federation_invite_states.exs | 33 +
...ssaging_federation_event_ids_by_origin.exs | 25 +
.../20260310120000_hash_recovery_tokens.exs | 62 +
...0310153000_add_private_mailbox_storage.exs | 16 +
...int_host_and_client_mtu_to_vpn_servers.exs | 11 +
...canonical_activitypub_refs_to_messages.exs | 54 +
...ll_messages_canonical_activitypub_refs.exs | 25 +
...314120000_create_jmap_email_tombstones.exs | 15 +
...260314133000_create_jmap_email_changes.exs | 17 +
...malize_invite_codes_case_insensitively.exs | 38 +
...170000_add_stripe_customer_id_to_users.exs | 34 +
..._time_support_to_subscription_products.exs | 13 +
...14195000_create_registration_checkouts.exs | 24 +
...ormalize_post_views_for_trust_tracking.exs | 65 +
...ing_federation_account_presence_states.exs | 34 +
...add_federated_fields_to_community_bans.exs | 20 +
...saging_federation_room_presence_states.exs | 35 +
...ate_messaging_federation_call_sessions.exs | 41 +
...16143000_create_profile_custom_domains.exs | 24 +
...20260320074000_add_db_hot_path_indexes.exs | 62 +
...ption_fields_to_registration_checkouts.exs | 13 +
...322194500_create_dns_zones_and_records.exs | 49 +
...20260322200000_add_dns_zone_soa_fields.exs | 14 +
...2203000_remove_dns_verification_fields.exs | 10 +
...ub_instances_and_enforce_ci_uniqueness.exs | 49 +
...dd_oidc_fields_to_oauth_authorizations.exs | 13 +
...0260323123000_create_oidc_signing_keys.exs | 18 +
...124000_add_oidc_fields_to_oauth_tokens.exs | 10 +
...143000_add_admin_email_message_indexes.exs | 17 +
...0260327121000_add_managed_dns_services.exs | 40 +
...d_mail_hardening_fields_to_dns_records.exs | 15 +
.../20260331110000_create_personal_files.exs | 33 +
...31123000_add_expires_at_to_file_shares.exs | 11 +
.../20260331150000_create_stored_folders.exs | 15 +
...add_access_and_password_to_file_shares.exs | 12 +
...402110000_add_theme_overrides_to_users.exs | 9 +
...04121000_hash_oauth_secrets_and_tokens.exs | 68 +
...04133000_add_auth_valid_after_to_users.exs | 9 +
...00_hash_export_and_registration_tokens.exs | 51 +
.../20260404180000_create_dns_query_stats.exs | 26 +
...0000_add_link_preview_to_chat_messages.exs | 11 +
...60405143000_create_profile_site_visits.exs | 23 +
...407120000_add_force_https_to_dns_zones.exs | 9 +
..._chat_conversations_from_social_spaces.exs | 149 +
...hat_foreign_keys_to_chat_conversations.exs | 71 +
...at_moderation_tables_and_safety_checks.exs | 168 +
...54500_delete_legacy_chat_conversations.exs | 88 +
...0260411120000_add_mastodon_poll_fields.exs | 12 +
...messages_reply_to_inserted_at_id_index.exs | 14 +
...d_federation_and_imap_hot_path_indexes.exs | 28 +
...0_add_built_in_subdomain_mode_to_users.exs | 9 +
.../20260420110000_create_notes.exs | 18 +
.../20260420121500_create_note_shares.exs | 19 +
...0421103000_add_protocol_to_vpn_servers.exs | 11 +
...w_multiple_vpn_protocols_per_public_ip.exs | 8 +
.../20260422163800_rename_drive_tables.exs | 121 +
...2165500_rename_social_messaging_tables.exs | 19 +
...70500_restore_conversations_table_name.exs | 13 +
..._conversations_to_social_conversations.exs | 13 +
.../20260422174500_rename_email_tables.exs | 93 +
...add_email_mailboxes_username_ci_unique.exs | 10 +
...0_add_encrypted_payload_to_note_shares.exs | 9 +
...00_add_pastebin_options_to_note_shares.exs | 12 +
...00_add_burn_after_read_to_drive_shares.exs | 9 +
...60425133000_add_hourly_dns_query_stats.exs | 53 +
...25143000_add_alias_rules_engine_fields.exs | 22 +
..._global_email_unsubscribe_unique_index.exs | 25 +
..._community_bans_for_chat_conversations.exs | 11 +
...vitypub_ref_indexes_to_social_messages.exs | 23 +
...literal_legacy_activitypub_ref_indexes.exs | 30 +
...90000_add_hot_path_social_feed_indexes.exs | 73 +
...000_add_broader_social_surface_indexes.exs | 145 +
...20260428120000_create_site_page_visits.exs | 23 +
.../20260428121000_add_site_sessions.exs | 36 +
.../20260430031000_create_atomine_proofs.exs | 36 +
...000_add_claim_fields_to_atomine_proofs.exs | 11 +
...033000_add_live_mode_to_atomine_proofs.exs | 18 +
...01090000_create_atomine_trust_sessions.exs | 31 +
...260501091000_create_connected_accounts.exs | 30 +
...0502120000_create_atomine_attestations.exs | 34 +
...121000_add_global_dns_proof_uniqueness.exs | 10 +
.../20260503000000_update_oban_to_v14.exs | 11 +
.../20260503120000_create_atomine_credits.exs | 66 +
...000_add_chat_messages_search_index_gin.exs | 17 +
...60503134000_add_chat_client_encryption.exs | 67 +
...000_add_chat_remote_encryption_devices.exs | 29 +
...170000_add_activitypub_request_replays.exs | 32 +
..._add_encrypted_password_vault_metadata.exs | 9 +
...dd_chat_encryption_device_trust_fields.exs | 20 +
...0_add_mailbox_category_filter_settings.exs | 10 +
...0000_backfill_chat_conversation_hashes.exs | 15 +
...tivitypub_actor_profile_fields_to_text.exs | 50 +
...0_ensure_fast_federated_timeline_index.exs | 23 +
.../20260510120000_encrypt_notes_content.exs | 72 +
...11500_create_external_email_deliveries.exs | 29 +
...13000_expand_external_email_deliveries.exs | 48 +
...20000_create_external_email_ops_tables.exs | 36 +
...21000_create_internal_email_deliveries.exs | 53 +
...or_site_analytics_and_oban_queue_stats.exs | 29 +
...6110000_rename_password_vault_to_nerve.exs | 109 +
...e_session_rollup_indexes_for_analytics.exs | 27 +
...global_atomine_proof_claim_grant_index.exs | 13 +
...0061000_create_static_site_deployments.exs | 21 +
...hook_fields_to_static_site_deployments.exs | 21 +
...atomine_shield_to_gate_in_dns_metadata.exs | 29 +
...efault_built_in_subdomain_mode_to_path.exs | 15 +
...e_existing_platform_profile_subdomains.exs | 16 +
...0_add_spam_filter_enabled_to_mailboxes.exs | 9 +
...00_add_auto_reply_enabled_to_mailboxes.exs | 9 +
...add_oban_and_timeline_hot_path_indexes.exs | 82 +
...00_add_failing_since_to_custom_domains.exs | 13 +
...0000_backfill_orphaned_thread_messages.exs | 47 +
.../20260621000000_create_uptime_monitors.exs | 36 +
.../20260621000100_create_uptime_checks.exs | 17 +
...20260621000200_create_uptime_incidents.exs | 21 +
...00_add_domain_identity_to_oauth_grants.exs | 20 +
...100_create_profile_per_site_identities.exs | 28 +
...23000200_hash_and_encrypt_share_tokens.exs | 122 +
...20260623000300_encrypt_webhook_secrets.exs | 88 +
...23000400_hash_email_unsubscribe_tokens.exs | 45 +
...3000500_hash_and_encrypt_device_tokens.exs | 89 +
.../20260628000000_create_kairo_tables.exs | 44 +
...60628120000_create_account_master_keys.exs | 18 +
...0628130000_add_kairo_source_encryption.exs | 10 +
...260629120000_add_kairo_content_at_rest.exs | 9 +
.../20260629140000_drop_notes_tables.exs | 43 +
...e_engagement_counts_to_social_messages.exs | 74 +
...0630001000_create_social_message_stats.exs | 67 +
..._guard_boosts_against_deleted_messages.exs | 46 +
...30001200_create_activitypub_tombstones.exs | 22 +
...0_create_activitypub_object_deliveries.exs | 23 +
...60701000100_create_social_thread_mutes.exs | 19 +
...1000200_add_group_key_to_notifications.exs | 11 +
.../20260701000300_create_api_markers.exs | 17 +
.../20260701000400_create_social_filters.exs | 20 +
...701000500_add_expiration_to_user_mutes.exs | 11 +
.../20260701000600_create_account_notes.exs | 28 +
...20260701000700_create_bookmark_folders.exs | 22 +
...01000800_create_web_push_subscriptions.exs | 25 +
...00_create_suggested_account_dismissals.exs | 18 +
...0701001000_create_account_endorsements.exs | 38 +
...701001100_create_account_subscriptions.exs | 36 +
...60701001200_create_client_app_settings.exs | 16 +
...20260701001300_create_social_scrobbles.exs | 20 +
...701001400_add_birthday_fields_to_users.exs | 25 +
...dd_account_migration_metadata_to_users.exs | 10 +
...notification_privacy_settings_to_users.exs | 10 +
...account_list_privacy_settings_to_users.exs | 11 +
...01_create_messaging_channel_categories.exs | 22 +
...0260704100002_create_chat_message_pins.exs | 17 +
.../20260704120001_create_chat_threads.exs | 28 +
...4120002_add_thread_id_to_chat_messages.exs | 11 +
.../20260704130001_create_chat_webhooks.exs | 20 +
...130002_add_webhook_id_to_chat_messages.exs | 11 +
.../20260704130003_add_is_bot_to_users.exs | 9 +
...4140000_kairo_dedup_and_project_nilify.exs | 39 +
...ile_editing_deploy_link_image_features.exs | 67 +
...60704160000_create_search_domain_rules.exs | 15 +
.../20260704170000_create_user_sessions.exs | 24 +
...ages_community_actor_uri_id_desc_index.exs | 26 +
...000000_add_nameserver_set_to_dns_zones.exs | 9 +
...000_create_social_recommendation_items.exs | 34 +
...e_session_referrer_index_for_analytics.exs | 19 +
...0_create_public_site_analytics_rollups.exs | 47 +
...000_dedupe_hashtags_by_normalized_name.exs | 88 +
...ocial_messages_remote_actor_feed_index.exs | 26 +
...00_add_federated_timeline_view_indexes.exs | 37 +
.../20260710010000_create_web_index.exs | 62 +
...encrypted_raw_source_to_email_messages.exs | 9 +
...20260712090000_add_theme_mode_to_users.exs | 17 +
.../20260725120000_create_link_archives.exs | 40 +
.../20260725130000_create_booking_pages.exs | 65 +
.../priv/repo/seed_fediverse_posts.exs | 279 +
.../repo/seed_platform_demo_screenshots.exs | 808 +
apps/elektrine/priv/repo/seed_test_emails.exs | 221 +
apps/elektrine/priv/repo/seeds.exs | 2630 +++
.../priv/repo/seeds_invite_codes.exs | 41 +
.../priv/repo/test_recategorizer.exs | 209 +
apps/elektrine/priv/static/favicon.ico | Bin 0 -> 152 bytes
.../priv/static/fonts/Geist-Black.ttf | Bin 0 -> 90780 bytes
.../priv/static/fonts/Geist-BlackItalic.ttf | Bin 0 -> 92616 bytes
.../priv/static/fonts/Geist-Bold.ttf | Bin 0 -> 90852 bytes
.../priv/static/fonts/Geist-BoldItalic.ttf | Bin 0 -> 92628 bytes
.../priv/static/fonts/Geist-ExtraBold.ttf | Bin 0 -> 90796 bytes
.../static/fonts/Geist-ExtraBoldItalic.ttf | Bin 0 -> 92688 bytes
.../priv/static/fonts/Geist-ExtraLight.ttf | Bin 0 -> 90772 bytes
.../static/fonts/Geist-ExtraLightItalic.ttf | Bin 0 -> 92644 bytes
.../fonts/Geist-Italic-cyrillic-ext.woff2 | Bin 0 -> 7800 bytes
.../static/fonts/Geist-Italic-cyrillic.woff2 | Bin 0 -> 15960 bytes
.../static/fonts/Geist-Italic-latin-ext.woff2 | Bin 0 -> 17892 bytes
.../static/fonts/Geist-Italic-latin.woff2 | Bin 0 -> 31008 bytes
.../fonts/Geist-Italic-vietnamese.woff2 | Bin 0 -> 8696 bytes
.../priv/static/fonts/Geist-Italic.ttf | Bin 0 -> 92492 bytes
.../priv/static/fonts/Geist-Light.ttf | Bin 0 -> 90752 bytes
.../priv/static/fonts/Geist-LightItalic.ttf | Bin 0 -> 92616 bytes
.../priv/static/fonts/Geist-Medium.ttf | Bin 0 -> 90832 bytes
.../priv/static/fonts/Geist-MediumItalic.ttf | Bin 0 -> 92608 bytes
.../priv/static/fonts/Geist-Regular.ttf | Bin 0 -> 90768 bytes
.../priv/static/fonts/Geist-SemiBold.ttf | Bin 0 -> 90896 bytes
.../static/fonts/Geist-SemiBoldItalic.ttf | Bin 0 -> 92636 bytes
.../priv/static/fonts/Geist-Thin.ttf | Bin 0 -> 90660 bytes
.../priv/static/fonts/Geist-ThinItalic.ttf | Bin 0 -> 92648 bytes
.../static/fonts/Geist-cyrillic-ext.woff2 | Bin 0 -> 7420 bytes
.../priv/static/fonts/Geist-cyrillic.woff2 | Bin 0 -> 15084 bytes
.../priv/static/fonts/Geist-latin-ext.woff2 | Bin 0 -> 16512 bytes
.../priv/static/fonts/Geist-latin.woff2 | Bin 0 -> 29400 bytes
.../priv/static/fonts/Geist-vietnamese.woff2 | Bin 0 -> 8004 bytes
.../priv/static/fonts/GeistMono-Black.ttf | Bin 0 -> 101444 bytes
.../static/fonts/GeistMono-BlackItalic.ttf | Bin 0 -> 104716 bytes
.../priv/static/fonts/GeistMono-Bold.ttf | Bin 0 -> 101380 bytes
.../static/fonts/GeistMono-BoldItalic.ttf | Bin 0 -> 104528 bytes
.../priv/static/fonts/GeistMono-ExtraBold.ttf | Bin 0 -> 101348 bytes
.../fonts/GeistMono-ExtraBoldItalic.ttf | Bin 0 -> 104504 bytes
.../static/fonts/GeistMono-ExtraLight.ttf | Bin 0 -> 101632 bytes
.../fonts/GeistMono-ExtraLightItalic.ttf | Bin 0 -> 104712 bytes
.../fonts/GeistMono-Italic-cyrillic-ext.woff2 | Bin 0 -> 6616 bytes
.../fonts/GeistMono-Italic-cyrillic.woff2 | Bin 0 -> 14116 bytes
.../fonts/GeistMono-Italic-latin-ext.woff2 | Bin 0 -> 16440 bytes
.../static/fonts/GeistMono-Italic-latin.woff2 | Bin 0 -> 25516 bytes
.../fonts/GeistMono-Italic-symbols2.woff2 | Bin 0 -> 5884 bytes
.../fonts/GeistMono-Italic-vietnamese.woff2 | Bin 0 -> 8620 bytes
.../priv/static/fonts/GeistMono-Italic.ttf | Bin 0 -> 104520 bytes
.../priv/static/fonts/GeistMono-Light.ttf | Bin 0 -> 101500 bytes
.../static/fonts/GeistMono-LightItalic.ttf | Bin 0 -> 104740 bytes
.../priv/static/fonts/GeistMono-Medium.ttf | Bin 0 -> 101552 bytes
.../static/fonts/GeistMono-MediumItalic.ttf | Bin 0 -> 104696 bytes
.../priv/static/fonts/GeistMono-Regular.ttf | Bin 0 -> 101464 bytes
.../priv/static/fonts/GeistMono-SemiBold.ttf | Bin 0 -> 101452 bytes
.../static/fonts/GeistMono-SemiBoldItalic.ttf | Bin 0 -> 104608 bytes
.../priv/static/fonts/GeistMono-Thin.ttf | Bin 0 -> 101624 bytes
.../static/fonts/GeistMono-ThinItalic.ttf | Bin 0 -> 104732 bytes
.../static/fonts/GeistMono-cyrillic-ext.woff2 | Bin 0 -> 6176 bytes
.../static/fonts/GeistMono-cyrillic.woff2 | Bin 0 -> 12940 bytes
.../static/fonts/GeistMono-latin-ext.woff2 | Bin 0 -> 14696 bytes
.../priv/static/fonts/GeistMono-latin.woff2 | Bin 0 -> 23128 bytes
.../static/fonts/GeistMono-symbols2.woff2 | Bin 0 -> 5812 bytes
.../static/fonts/GeistMono-vietnamese.woff2 | Bin 0 -> 7696 bytes
.../GeistPixel-Regular-VariableFont_ELSH.ttf | Bin 0 -> 3656708 bytes
.../static/fonts/GeistPixel-latin-ext.woff2 | Bin 0 -> 9168 bytes
.../priv/static/fonts/GeistPixel-latin.woff2 | Bin 0 -> 16736 bytes
.../static/images/android-chrome-192x192.png | Bin 0 -> 312 bytes
.../priv/static/images/favicon-16x16.png | Bin 0 -> 296 bytes
.../priv/static/images/favicon-32x32.png | Bin 0 -> 296 bytes
apps/elektrine/priv/static/images/favicon.ico | Bin 0 -> 4286 bytes
.../priv/static/images/home/.gitkeep | 0
.../priv/static/images/home/buttons/.gitkeep | 0
.../static/images/home/buttons/button-01.gif | Bin 0 -> 504 bytes
.../static/images/home/buttons/button-02.gif | Bin 0 -> 69834 bytes
.../static/images/home/buttons/button-03.gif | Bin 0 -> 427 bytes
.../static/images/home/buttons/button-04.gif | Bin 0 -> 3500 bytes
.../static/images/home/buttons/button-05.gif | Bin 0 -> 1063 bytes
.../static/images/home/buttons/button-06.gif | Bin 0 -> 21009 bytes
.../static/images/home/buttons/button-07.gif | Bin 0 -> 5439 bytes
.../static/images/home/buttons/button-08.gif | Bin 0 -> 12472 bytes
.../priv/static/images/home/poster-01.jpg | Bin 0 -> 1266086 bytes
apps/elektrine/priv/static/images/mark.png | Bin 0 -> 2170 bytes
.../elektrine/priv/static/openapi/ext-v1.yaml | 1186 ++
apps/elektrine/priv/static/robots.txt | 5 +
apps/elektrine/priv/static/sw.js | 74 +
apps/elektrine/rel/env.sh.eex | 13 +
apps/elektrine/rel/overlays/bin/migrate | 5 +
apps/elektrine/rel/overlays/bin/migrate.bat | 1 +
apps/elektrine/rel/overlays/bin/server | 5 +
apps/elektrine/rel/overlays/bin/server.bat | 2 +
.../elektrine/account_identifiers_test.exs | 18 +
.../accounts/account_import_worker_test.exs | 115 +
.../elektrine/accounts/account_notes_test.exs | 47 +
.../elektrine/accounts/app_password_test.exs | 297 +
.../elektrine/accounts/capabilities_test.exs | 108 +
.../accounts/connected_account_test.exs | 57 +
.../elektrine/accounts/domain_blocks_test.exs | 53 +
.../elektrine/accounts/invite_codes_test.exs | 344 +
.../test/elektrine/accounts/muting_test.exs | 93 +
.../test/elektrine/accounts/passkeys_test.exs | 186 +
.../recovery_email_verification_test.exs | 353 +
.../test/elektrine/accounts/storage_test.exs | 45 +
.../elektrine/accounts/trust_level_test.exs | 178 +
.../elektrine/accounts/two_factor_test.exs | 222 +
.../test/elektrine/accounts/user_test.exs | 265 +
.../test/elektrine/accounts_privacy_test.exs | 238 +
.../test/elektrine/accounts_status_test.exs | 144 +
.../test/elektrine/accounts_test.exs | 292 +
.../acme/wildcard_renewal_worker_test.exs | 73 +
.../activity_delivery_worker_test.exs | 21 +
.../activitypub/actor_secrets_test.exs | 48 +
.../activitypub/actor_url_security_test.exs | 71 +
.../activitypub/boundary_guardrails_test.exs | 40 +
.../activitypub/collection_fetcher_test.exs | 219 +
.../activitypub/community_fetcher_test.exs | 139 +
.../activitypub/domain_migration_test.exs | 101 +
.../fetch_remote_post_replies_test.exs | 38 +
.../fetch_remote_user_timeline_test.exs | 96 +
.../elektrine/activitypub/fetcher_test.exs | 597 +
.../activitypub/get_or_fetch_actor_test.exs | 125 +
.../elektrine/activitypub/handler_test.exs | 303 +
.../handlers/announce_handler_test.exs | 270 +
.../handlers/create_handler_test.exs | 526 +
.../handlers/delete_handler_test.exs | 128 +
.../handlers/flag_handler_test.exs | 133 +
.../handlers/follow_handler_test.exs | 509 +
.../handlers/like_handler_test.exs | 400 +
.../handlers/move_handler_test.exs | 145 +
.../handlers/update_handler_test.exs | 338 +
.../elektrine/activitypub/helpers_test.exs | 108 +
.../activitypub/http_signature_test.exs | 85 +
.../elektrine/activitypub/instances_test.exs | 35 +
.../elektrine/activitypub/lemmy_api_test.exs | 88 +
.../lemmy_comment_backfill_test.exs | 109 +
.../activitypub/mastodon_api_test.exs | 312 +
.../elektrine/activitypub/mentions_test.exs | 48 +
.../mrf/anti_followbot_policy_test.exs | 105 +
.../mrf/hellthread_policy_test.exs | 211 +
.../activitypub/mrf/keyword_policy_test.exs | 229 +
.../mrf/pleroma_policy_batch_test.exs | 329 +
.../activitypub/mrf/simple_policy_test.exs | 421 +
.../nodeinfo_fetcher_worker_security_test.exs | 30 +
.../elektrine/activitypub/normalizer_test.exs | 414 +
.../activitypub/object_validator_test.exs | 214 +
.../elektrine/activitypub/outbox_test.exs | 834 +
.../elektrine/activitypub/pipeline_test.exs | 19 +
.../process_activity_worker_test.exs | 263 +
.../elektrine/activitypub/publisher_test.exs | 92 +
.../refresh_counts_worker_test.exs | 222 +
.../test/elektrine/activitypub/relay_test.exs | 281 +
.../activitypub/replies_fetcher_test.exs | 154 +
.../replies_ingest_worker_test.exs | 28 +
.../activitypub/reply_fetch_policy_test.exs | 38 +
.../signature_retry_worker_test.exs | 125 +
.../signature_verification_test.exs | 226 +
.../thread_backfill_worker_test.exs | 25 +
.../activitypub/undo_resolver_test.exs | 85 +
apps/elektrine/test/elektrine/admin_test.exs | 216 +
.../elektrine/atomine_proof_graph_test.exs | 87 +
.../test/elektrine/bluesky_inbound_test.exs | 307 +
.../test/elektrine/bluesky_managed_test.exs | 546 +
.../elektrine/test/elektrine/bluesky_test.exs | 1038 ++
.../elektrine/booking_confirmation_test.exs | 223 +
.../elektrine/test/elektrine/booking_test.exs | 334 +
.../elektrine/calendar/availability_test.exs | 345 +
.../calendar/availability_zone_test.exs | 183 +
.../elektrine/calendar/busy_blocks_test.exs | 115 +
.../test/elektrine/calendar/calendar_test.exs | 382 +
.../elektrine/calendar/icalendar_test.exs | 382 +
.../test/elektrine/calls/transport_test.exs | 44 +
apps/elektrine/test/elektrine/calls_test.exs | 150 +
.../elektrine/test/elektrine/captcha_test.exs | 17 +
.../test/elektrine/db/write_guard_test.exs | 60 +
.../exports/account_exporter_test.exs | 173 +
.../webhook_delivery_worker_test.exs | 10 +
.../test/elektrine/developer_exports_test.exs | 185 +
.../test/elektrine/developer_tokens_test.exs | 165 +
.../elektrine/developer_webhooks_test.exs | 179 +
.../elektrine/test/elektrine/discord_test.exs | 68 +
.../elektrine/test/elektrine/domains_test.exs | 124 +
apps/elektrine/test/elektrine/drive_test.exs | 242 +
apps/elektrine/test/elektrine/emojis_test.exs | 89 +
.../test/elektrine/encrypted_search_test.exs | 498 +
.../test/elektrine/encryption_test.exs | 262 +
.../elektrine/test/elektrine/friends_test.exs | 165 +
.../test/elektrine/home_blog_test.exs | 50 +
.../test/elektrine/http/safe_fetch_test.exs | 30 +
apps/elektrine/test/elektrine/input_test.exs | 14 +
.../test/elektrine/job_queue_test.exs | 75 +
.../test/elektrine/link_archive_test.exs | 303 +
.../elektrine/mail_client_settings_test.exs | 31 +
.../elektrine/mailer/delivery_worker_test.exs | 49 +
.../elektrine/test/elektrine/markers_test.exs | 104 +
.../messaging/activitypub_ref_lookup_test.exs | 200 +
.../arblarg_extension_conformance_test.exs | 91 +
.../messaging/arblarg_golden_vectors_test.exs | 86 +
.../messaging/arblarg_profiles_test.exs | 79 +
.../elektrine/messaging/arblarg_sdk_test.exs | 486 +
.../messaging/channel_categories_test.exs | 274 +
.../chat_conversations_credits_test.exs | 53 +
.../messaging/chat_conversations_test.exs | 30 +
.../messaging/chat_message_pins_test.exs | 199 +
.../messaging/chat_messages_test.exs | 599 +
.../elektrine/messaging/chat_threads_test.exs | 258 +
.../messaging/chat_webhooks_test.exs | 289 +
.../messaging/custom_domain_aliases_test.exs | 140 +
.../federated_message_normalization_test.exs | 179 +
.../messaging/federation/config_test.exs | 29 +
.../messaging/federation_dm_test.exs | 405 +
.../messaging/federation_errors_test.exs | 16 +
.../federation_ingress_rate_limiter_test.exs | 184 +
.../federation_outbox_worker_test.exs | 10 +
.../federation_peer_discovery_test.exs | 535 +
.../messaging/federation_peer_policy_test.exs | 146 +
.../messaging/federation_pins_test.exs | 296 +
.../messaging/federation_protocol_test.exs | 23 +
.../messaging/federation_remote_join_test.exs | 129 +
.../federation_room_participation_test.exs | 210 +
.../federation_session_websock_test.exs | 118 +
.../elektrine/messaging/federation_test.exs | 3882 ++++
.../messaging/federation_threads_test.exs | 464 +
.../messaging/messages_mentions_test.exs | 66 +
.../elektrine/messaging/messages_test.exs | 87 +
.../messaging/moderation_tools_test.exs | 769 +
.../messaging/reference_peer_interop_test.exs | 316 +
.../test/elektrine/messaging/servers_test.exs | 241 +
.../messaging/slash_commands_test.exs | 44 +
.../social_message_security_test.exs | 82 +
.../messaging/voice_channels_test.exs | 206 +
.../elektrine/messaging_encryption_test.exs | 320 +
.../federation_notifications_test.exs | 54 +
.../test/elektrine/notifications_test.exs | 413 +
.../test/elektrine/oauth/app_test.exs | 56 +
apps/elektrine/test/elektrine/paths_test.exs | 28 +
.../test/elektrine/platform/modules_test.exs | 78 +
.../runtime_config_validator_test.exs | 250 +
.../elektrine/test/elektrine/privacy_test.exs | 228 +
.../elektrine/profile_custom_domains_test.exs | 243 +
.../profile_per_site_identities_test.exs | 88 +
.../profiles/profile_widget_test.exs | 36 +
.../profiles/static_site_deployment_test.exs | 62 +
.../test/elektrine/profiles_test.exs | 885 +
.../elektrine/push/web_push_client_test.exs | 88 +
apps/elektrine/test/elektrine/push_test.exs | 428 +
.../elektrine/test/elektrine/reports_test.exs | 210 +
.../elektrine/rss/fetch_feed_worker_test.exs | 10 +
.../test/elektrine/rss/parser_test.exs | 674 +
apps/elektrine/test/elektrine/rss_test.exs | 518 +
.../test/elektrine/runtime_env_test.exs | 64 +
.../elektrine/search/domain_rules_test.exs | 94 +
.../search/paige_rate_limiter_test.exs | 31 +
apps/elektrine/test/elektrine/search_test.exs | 89 +
.../test/elektrine/secrets/backfill_test.exs | 171 +
.../secrets/encrypted_string_test.exs | 40 +
.../security/safe_external_url_test.exs | 38 +
.../elektrine/security/url_validator_test.exs | 44 +
.../test/elektrine/security_alerts_test.exs | 111 +
.../social/engagement_counts_test.exs | 18 +
.../expired_data_cleanup_worker_test.exs | 87 +
.../github_deploy_worker_security_test.exs | 58 +
.../test/elektrine/static_sites_test.exs | 606 +
.../elektrine/test/elektrine/strings_test.exs | 17 +
.../test/elektrine/subscriptions_test.exs | 427 +
apps/elektrine/test/elektrine/theme_test.exs | 42 +
.../elektrine/test/elektrine/uploads_test.exs | 241 +
apps/elektrine/test/elektrine/vault_test.exs | 79 +
.../elektrine/web_index/extractor_test.exs | 43 +
.../test/elektrine/web_index/fetcher_test.exs | 27 +
.../test/elektrine/web_index/robots_test.exs | 33 +
.../test/elektrine/web_index_test.exs | 150 +
apps/elektrine/test/support/channel_case.ex | 28 +
apps/elektrine/test/support/conn_case.ex | 38 +
apps/elektrine/test/support/data_case.ex | 58 +
apps/elektrine/test/support/feature_case.ex | 147 +
.../support/fixtures/accounts_fixtures.ex | 58 +
.../test/support/fixtures/email_fixtures.ex | 55 +
.../test/support/fixtures/social_fixtures.ex | 150 +
apps/elektrine/test/test_helper.exs | 30 +
apps/elektrine_dns/README.md | 21 +
apps/elektrine_dns/lib/elektrine/dns.ex | 2293 +++
.../lib/elektrine/dns/application.ex | 36 +
.../lib/elektrine/dns/authority.ex | 26 +
.../lib/elektrine/dns/domain_health.ex | 352 +
.../lib/elektrine/dns/generators/bluesky.ex | 40 +
.../lib/elektrine/dns/generators/mail.ex | 242 +
.../lib/elektrine/dns/generators/turn.ex | 40 +
.../lib/elektrine/dns/generators/vpn.ex | 80 +
.../lib/elektrine/dns/generators/web.ex | 27 +
.../lib/elektrine/dns/health_monitor.ex | 214 +
.../lib/elektrine/dns/mail_security.ex | 201 +
.../lib/elektrine/dns/managed_records.ex | 744 +
.../elektrine_dns/lib/elektrine/dns/packet.ex | 451 +
.../dns/profile_wildcard_bootstrap.ex | 33 +
apps/elektrine_dns/lib/elektrine/dns/query.ex | 580 +
.../lib/elektrine/dns/query_stat.ex | 46 +
.../lib/elektrine/dns/query_stats_buffer.ex | 93 +
.../elektrine_dns/lib/elektrine/dns/record.ex | 650 +
.../lib/elektrine/dns/recursive.ex | 667 +
.../lib/elektrine/dns/recursive_cache.ex | 97 +
.../lib/elektrine/dns/recursive_transport.ex | 43 +
.../lib/elektrine/dns/request_guard.ex | 112 +
.../lib/elektrine/dns/service_binding.ex | 245 +
.../lib/elektrine/dns/tcp_server.ex | 145 +
.../lib/elektrine/dns/udp_server.ex | 85 +
apps/elektrine_dns/lib/elektrine/dns/zone.ex | 98 +
.../lib/elektrine/dns/zone_cache.ex | 124 +
.../lib/elektrine/dns/zone_change_listener.ex | 124 +
.../lib/elektrine/dns/zone_service_config.ex | 60 +
apps/elektrine_dns/lib/elektrine_dns_web.ex | 69 +
.../components/platform/e_nav.ex | 48 +
.../controllers/api/dns_controller.ex | 420 +
.../elektrine_dns_web/live/dns_live/index.ex | 2644 +++
.../elektrine.dns.ensure_profile_wildcard.ex | 30 +
apps/elektrine_dns/mix.exs | 43 +
.../elektrine/dns/authority_cache_test.exs | 223 +
.../elektrine/dns/health_monitor_test.exs | 93 +
.../elektrine/dns/managed_records_test.exs | 609 +
.../test/elektrine/dns/packet_test.exs | 170 +
.../elektrine/dns/profile_wildcard_test.exs | 168 +
.../elektrine/dns/query_stats_buffer_test.exs | 58 +
.../test/elektrine/dns/query_test.exs | 663 +
.../dns/record_health_check_test.exs | 89 +
.../test/elektrine/dns/recursive_test.exs | 418 +
.../test/elektrine/dns/request_guard_test.exs | 78 +
.../dns/zone_change_listener_test.exs | 17 +
.../elektrine/dns/zone_verification_test.exs | 213 +
.../test/elektrine/dns_analytics_test.exs | 142 +
.../test/elektrine/dns_context_test.exs | 612 +
apps/elektrine_dns/test/test_helper.exs | 1 +
apps/elektrine_email/.gitignore | 22 +
apps/elektrine_email/README.md | 34 +
apps/elektrine_email/lib/elektrine/email.ex | 595 +
.../lib/elektrine/email/alias.ex | 482 +
.../lib/elektrine/email/aliases.ex | 263 +
.../lib/elektrine/email/attachment_storage.ex | 420 +
.../lib/elektrine/email/auto_replies.ex | 189 +
.../lib/elektrine/email/auto_reply.ex | 162 +
.../lib/elektrine/email/blocked_sender.ex | 71 +
.../lib/elektrine/email/blocked_senders.ex | 137 +
.../lib/elektrine/email/cache.ex | 253 +
.../lib/elektrine/email/cache_hooks.ex | 42 +
.../lib/elektrine/email/cached.ex | 370 +
.../lib/elektrine/email/categorizer.ex | 791 +
.../elektrine/email/category_preference.ex | 87 +
.../elektrine/email/category_preferences.ex | 191 +
.../lib/elektrine/email/contact.ex | 184 +
.../lib/elektrine/email/contact_group.ex | 23 +
.../lib/elektrine/email/contacts.ex | 256 +
.../lib/elektrine/email/custom_domain.ex | 84 +
.../email/custom_domain_health_worker.ex | 25 +
.../lib/elektrine/email/custom_domains.ex | 582 +
.../lib/elektrine/email/custom_folders.ex | 205 +
.../lib/elektrine/email/deliverability.ex | 123 +
.../lib/elektrine/email/dkim.ex | 507 +
.../lib/elektrine/email/export.ex | 84 +
.../lib/elektrine/email/exports.ex | 320 +
.../lib/elektrine/email/external_delivery.ex | 588 +
.../email/external_delivery_alerts.ex | 65 +
.../email/external_delivery_attempt.ex | 39 +
.../email/external_delivery_control.ex | 123 +
.../external_delivery_maintenance_worker.ex | 21 +
.../external_delivery_metric_snapshot.ex | 74 +
.../email/external_delivery_worker.ex | 138 +
.../email/external_domain_throttle.ex | 92 +
.../lib/elektrine/email/filter.ex | 238 +
.../lib/elektrine/email/filters.ex | 193 +
.../lib/elektrine/email/folder.ex | 52 +
.../lib/elektrine/email/folders.ex | 1076 ++
.../lib/elektrine/email/forwarded_message.ex | 39 +
.../lib/elektrine/email/haraka_adapter.ex | 95 +
.../lib/elektrine/email/haraka_admin.ex | 245 +
.../lib/elektrine/email/haraka_client.ex | 640 +
.../lib/elektrine/email/header_decoder.ex | 38 +
.../lib/elektrine/email/header_sanitizer.ex | 403 +
.../lib/elektrine/email/imap_subscription.ex | 24 +
.../lib/elektrine/email/imap_subscriptions.ex | 125 +
.../elektrine/email/inbound_authentication.ex | 81 +
.../lib/elektrine/email/inbound_routing.ex | 378 +
.../lib/elektrine/email/internal_delivery.ex | 254 +
.../email/internal_delivery_attempt.ex | 35 +
.../email/internal_delivery_worker.ex | 110 +
.../lib/elektrine/email/internal_origin.ex | 103 +
.../lib/elektrine/email/label.ex | 33 +
.../lib/elektrine/email/labels.ex | 190 +
.../lib/elektrine/email/list_types.ex | 206 +
.../lib/elektrine/email/mailbox.ex | 499 +
.../lib/elektrine/email/mailbox_adapter.ex | 232 +
.../lib/elektrine/email/mailbox_encryption.ex | 429 +
.../lib/elektrine/email/mailboxes.ex | 410 +
.../lib/elektrine/email/message.ex | 490 +
.../lib/elektrine/email/messages.ex | 1776 ++
.../elektrine/email/mime_body_extractor.ex | 105 +
.../lib/elektrine/email/payload_sanitizer.ex | 26 +
.../lib/elektrine/email/pgp.ex | 1050 ++
.../lib/elektrine/email/pgp_key_cache.ex | 45 +
.../lib/elektrine/email/processing.ex | 192 +
.../lib/elektrine/email/rate_limiter.ex | 463 +
.../lib/elektrine/email/receiver.ex | 1006 +
.../lib/elektrine/email/safe_sender.ex | 70 +
.../lib/elektrine/email/safe_senders.ex | 135 +
.../lib/elektrine/email/sanitizer.ex | 479 +
.../lib/elektrine/email/search.ex | 244 +
.../lib/elektrine/email/send_email_worker.ex | 241 +
.../lib/elektrine/email/sender.ex | 2539 +++
.../lib/elektrine/email/sender_class.ex | 192 +
.../lib/elektrine/email/suppression.ex | 59 +
.../lib/elektrine/email/suppressions.ex | 264 +
.../lib/elektrine/email/system_delivery.ex | 116 +
.../elektrine/email/system_delivery_worker.ex | 23 +
.../lib/elektrine/email/template.ex | 33 +
.../lib/elektrine/email/templates.ex | 99 +
.../lib/elektrine/email/unsubscribe.ex | 38 +
.../lib/elektrine/email/unsubscribes.ex | 214 +
.../lib/elektrine/email/vcard.ex | 595 +
.../lib/elektrine/email_config.ex | 89 +
.../lib/elektrine/imap/append_parser.ex | 272 +
.../lib/elektrine/imap/commands.ex | 313 +
.../lib/elektrine/imap/commands/append.ex | 317 +
.../lib/elektrine/imap/commands/auth.ex | 393 +
.../lib/elektrine/imap/commands/idle.ex | 150 +
.../lib/elektrine/imap/commands/mailbox.ex | 464 +
.../lib/elektrine/imap/commands/message.ex | 690 +
.../lib/elektrine/imap/commands/search.ex | 178 +
.../lib/elektrine/imap/commands/shared.ex | 40 +
.../lib/elektrine/imap/folders.ex | 187 +
.../lib/elektrine/imap/helpers.ex | 1240 ++
.../lib/elektrine/imap/idle_tracker.ex | 157 +
.../lib/elektrine/imap/rate_limiter.ex | 38 +
.../lib/elektrine/imap/recent_state.ex | 84 +
.../lib/elektrine/imap/recent_tracker.ex | 109 +
.../lib/elektrine/imap/response.ex | 759 +
.../lib/elektrine/imap/server.ex | 673 +
.../lib/elektrine/imap/supervisor.ex | 76 +
.../lib/elektrine/jmap/email_change.ex | 89 +
.../lib/elektrine/jmap/email_submission.ex | 198 +
.../lib/elektrine/jmap/email_tombstone.ex | 45 +
.../lib/elektrine/jmap/jmap.ex | 955 +
.../lib/elektrine/jmap/state.ex | 123 +
.../lib/elektrine/jmap/thread.ex | 296 +
.../lib/elektrine/mail/socket.ex | 161 +
.../lib/elektrine/pop3/connection_tracker.ex | 170 +
.../lib/elektrine/pop3/rate_limiter.ex | 38 +
.../lib/elektrine/pop3/server.ex | 1223 ++
.../lib/elektrine/pop3/supervisor.ex | 76 +
.../lib/elektrine/smtp/rate_limiter.ex | 39 +
.../lib/elektrine/smtp/send_rate_limiter.ex | 162 +
.../lib/elektrine/smtp/server.ex | 1433 ++
.../lib/elektrine/smtp/supervisor.ex | 68 +
.../elektrine_email/haraka_inbound_worker.ex | 160 +
.../lib/elektrine_email/mcp_tools.ex | 548 +
.../lib/elektrine_email/platform.ex | 27 +
.../lib/elektrine_email_web.ex | 95 +
.../components/email/display.ex | 379 +
.../components/email/sidebar.ex | 452 +
.../components/platform/elektrine_nav.ex | 50 +
.../controllers/admin/aliases_controller.ex | 273 +
.../controllers/admin/aliases_html.ex | 9 +
.../admin/aliases_html/aliases.html.heex | 273 +
.../aliases_html/forwarded_messages.html.heex | 496 +
.../admin/custom_domains_controller.ex | 100 +
.../controllers/admin/custom_domains_html.ex | 107 +
.../custom_domains.html.heex | 693 +
.../controllers/admin/haraka_html.ex | 0
.../controllers/admin/mailboxes_controller.ex | 224 +
.../controllers/admin/mailboxes_html.ex | 9 +
.../admin/mailboxes_html/mailboxes.html.heex | 373 +
.../controllers/admin/messages_controller.ex | 695 +
.../controllers/admin/messages_html.ex | 27 +
.../admin/messages_html/messages.html.heex | 947 +
.../messages_html/user_messages.html.heex | 286 +
.../messages_html/view_message.html.heex | 186 +
.../messages_html/view_user_message.html.heex | 166 +
.../admin/system_emails_controller.ex | 56 +
.../controllers/admin/system_emails_html.ex | 7 +
.../admin/system_emails_html/new.html.heex | 104 +
.../controllers/api/alias_controller.ex | 223 +
.../controllers/api/email_controller.ex | 709 +
.../api/ext_contacts_controller.ex | 178 +
.../controllers/api/ext_email_controller.ex | 546 +
.../controllers/api/mailbox_controller.ex | 130 +
.../controllers/attachment_controller.ex | 224 +
.../dav/address_book_controller.ex | 432 +
.../controllers/email_controller.ex | 611 +
.../controllers/email_html.ex | 28 +
.../controllers/email_html/print.html.heex | 312 +
.../controllers/haraka_webhook_controller.ex | 2112 +++
.../controllers/jmap/api_controller.ex | 1099 ++
.../controllers/jmap/blob_controller.ex | 325 +
.../jmap/event_source_controller.ex | 128 +
.../controllers/jmap/session_controller.ex | 102 +
.../controllers/unsubscribe_controller.ex | 111 +
.../controllers/unsubscribe_html.ex | 10 +
.../unsubscribe_html/confirmed.html.heex | 34 +
.../unsubscribe_html/resubscribed.html.heex | 30 +
.../unsubscribe_html/show.html.heex | 17 +
.../controllers/wkd_controller.ex | 96 +
.../email_preference_helpers.ex | 11 +
.../live/contacts_live/index.ex | 145 +
.../live/contacts_live/index.html.heex | 682 +
.../operations/contact_operations.ex | 332 +
.../live/email_live/compose.ex | 2313 +++
.../live/email_live/compose.html.heex | 627 +
.../live/email_live/email_helpers.ex | 719 +
.../live/email_live/index.ex | 745 +
.../live/email_live/index.html.heex | 1893 ++
.../email_live/operations/alias_operations.ex | 215 +
.../email_live/operations/bulk_operations.ex | 435 +
.../operations/compose_operations.ex | 157 +
.../operations/contact_operations.ex | 148 +
.../operations/message_operations.ex | 373 +
.../operations/navigation_operations.ex | 60 +
.../operations/reply_later_operations.ex | 115 +
.../operations/search_operations.ex | 41 +
.../operations/selection_operations.ex | 86 +
.../live/email_live/operations/tab_content.ex | 382 +
.../live/email_live/raw.ex | 90 +
.../live/email_live/raw.html.heex | 117 +
.../live/email_live/router.ex | 196 +
.../live/email_live/search.ex | 166 +
.../live/email_live/search.html.heex | 287 +
.../live/email_live/settings.ex | 378 +
.../email_live/settings/content_settings.ex | 625 +
.../email_live/settings/domain_settings.ex | 757 +
.../email_live/settings/filter_settings.ex | 888 +
.../live/email_live/settings/helpers.ex | 24 +
.../email_live/settings/sender_settings.ex | 263 +
.../live/email_live/show.ex | 488 +
.../live/email_live/show.html.heex | 698 +
.../live/unsubscribe_live/confirmed.ex | 59 +
.../live/unsubscribe_live/show.ex | 74 +
.../live/user_settings_email.ex | 1119 ++
.../plugs/email_ownership_guard.ex | 128 +
.../elektrine_email_web/plugs/jmap_auth.ex | 148 +
.../lib/elektrine_email_web/storage_email.ex | 108 +
.../elektrine_email_web/user_auth_email.ex | 19 +
.../elektrine_email_web/user_error_helpers.ex | 96 +
.../user_settings_email_controller.ex | 55 +
apps/elektrine_email/mix.exs | 59 +
.../test/elektrine/email/alias_test.exs | 303 +
.../email/attachment_storage_test.exs | 121 +
.../elektrine/email/auto_replies_test.exs | 54 +
.../test/elektrine/email/categorizer_test.exs | 491 +
.../email/category_preferences_test.exs | 80 +
.../elektrine/email/custom_domains_test.exs | 394 +
.../test/elektrine/email/dkim_test.exs | 29 +
.../test/elektrine/email/exports_test.exs | 34 +
.../test/elektrine/email/filters_test.exs | 971 +
.../elektrine/email/haraka_adapter_test.exs | 37 +
.../elektrine/email/haraka_client_test.exs | 215 +
.../elektrine/email/header_decoder_test.exs | 24 +
.../elektrine/email/header_sanitizer_test.exs | 370 +
.../email/inbound_authentication_test.exs | 75 +
.../elektrine/email/inbound_routing_test.exs | 319 +
.../elektrine/email/mailbox_adapter_test.exs | 50 +
.../email/mailbox_private_storage_test.exs | 463 +
.../test/elektrine/email/mailbox_test.exs | 22 +
.../email/message_operations_test.exs | 1025 +
.../email/mime_body_extractor_test.exs | 57 +
.../test/elektrine/email/pgp_receive_test.exs | 447 +
.../test/elektrine/email/pgp_test.exs | 627 +
.../email/platform_email_headers_test.exs | 98 +
.../elektrine/email/plus_addressing_test.exs | 42 +
.../elektrine/email/rate_limiter_test.exs | 327 +
.../test/elektrine/email/sanitizer_test.exs | 723 +
.../email/send_email_worker_test.exs | 65 +
.../elektrine/email/sender_class_test.exs | 184 +
.../email/sender_credit_gate_test.exs | 129 +
.../elektrine/email/sender_pipeline_test.exs | 617 +
.../email/sender_rate_limit_test.exs | 49 +
.../email/sender_validation_test.exs | 240 +
.../elektrine/email/suppressions_test.exs | 70 +
.../elektrine/email/system_delivery_test.exs | 53 +
.../elektrine/email/unsubscribes_test.exs | 89 +
.../test/elektrine/email/vcard_test.exs | 314 +
.../elektrine/email_basic_functions_test.exs | 505 +
.../test/elektrine/email_encryption_test.exs | 540 +
.../test/elektrine/email_test.exs | 311 +
.../test/elektrine/imap/commands_test.exs | 875 +
.../test/elektrine/imap/helpers_test.exs | 175 +
.../elektrine/imap/response_security_test.exs | 88 +
.../mail/protocol_auth_rate_limiter_test.exs | 46 +
.../mail/protocol_transcript_test.exs | 1162 ++
.../elektrine/mail_auth/rate_limiter_test.exs | 82 +
.../pop3/connection_tracker_test.exs | 57 +
.../components/email/display_test.exs | 96 +
.../live/email_live/email_helpers_test.exs | 82 +
.../user_error_helpers_test.exs | 48 +
.../fixtures/email/css_preamble_plain.txt | 7 +
.../fixtures/imap_client_transcripts.json | 32 +
.../elektrine_email/test/support/data_case.ex | 58 +
.../test/support/fixtures/README.md | 2 +
.../support/fixtures/accounts_fixtures.ex | 58 +
.../test/support/fixtures/email_fixtures.ex | 56 +
.../test/support/fixtures/pop3-test-cert.pem | 19 +
.../test/support/fixtures/pop3-test-key.pem | 28 +
.../test/support/fixtures/social_fixtures.ex | 150 +
apps/elektrine_email/test/test_helper.exs | 6 +
apps/elektrine_nerve/.gitignore | 22 +
apps/elektrine_nerve/README.md | 29 +
apps/elektrine_nerve/lib/elektrine/nerve.ex | 124 +
.../lib/elektrine/nerve/nerve_entry.ex | 179 +
.../lib/elektrine/nerve/nerve_settings.ex | 85 +
.../lib/elektrine/nerve/payloads.ex | 50 +
.../lib/elektrine_nerve_web.ex | 64 +
.../api/nerve_controller.ex | 196 +
.../lib/elektrine_nerve_web/api/response.ex | 61 +
.../components/platform/e_nav.ex | 46 +
.../lib/elektrine_nerve_web/nerve_live.ex | 577 +
apps/elektrine_nerve/mix.exs | 54 +
.../test/elektrine/nerve_test.exs | 253 +
.../elektrine_nerve/test/support/data_case.ex | 36 +
.../support/fixtures/accounts_fixtures.ex | 53 +
apps/elektrine_nerve/test/test_helper.exs | 10 +
apps/elektrine_social/.gitignore | 22 +
apps/elektrine_social/README.md | 24 +
apps/elektrine_social/lib/elektrine/social.ex | 1864 ++
.../lib/elektrine/social/bookmark_folder.ex | 39 +
.../lib/elektrine/social/bookmark_folders.ex | 62 +
.../lib/elektrine/social/bookmarks.ex | 440 +
.../lib/elektrine/social/boosts.ex | 383 +
.../elektrine/social/creator_satisfaction.ex | 93 +
.../lib/elektrine/social/cross_posting.ex | 371 +
.../lib/elektrine/social/discussions.ex | 363 +
.../lib/elektrine/social/drafts.ex | 386 +
.../lib/elektrine/social/federated_feeds.ex | 403 +
.../lib/elektrine/social/feed_policy.ex | 193 +
.../lib/elektrine/social/feed_query.ex | 270 +
.../social/fetch_link_preview_worker.ex | 142 +
.../lib/elektrine/social/filter.ex | 75 +
.../lib/elektrine/social/filters.ex | 214 +
.../lib/elektrine/social/hashtag_extractor.ex | 315 +
.../lib/elektrine/social/hashtag_follow.ex | 27 +
.../lib/elektrine/social/hashtag_follows.ex | 118 +
.../lib/elektrine/social/hashtags.ex | 169 +
.../lib/elektrine/social/home_feed.ex | 165 +
.../lib/elektrine/social/home_feed_cache.ex | 94 +
.../social/home_feed_fanout_worker.ex | 51 +
.../social/home_feed_invalidation_worker.ex | 75 +
.../lib/elektrine/social/likes.ex | 402 +
.../elektrine/social/link_preview_fetcher.ex | 238 +
.../lib/elektrine/social/link_preview_job.ex | 66 +
.../social/link_preview_refresh_worker.ex | 93 +
.../elektrine/social/link_preview_worker.ex | 141 +
.../lib/elektrine/social/list.ex | 27 +
.../lib/elektrine/social/list_member.ex | 39 +
.../lib/elektrine/social/lists.ex | 498 +
.../lib/elektrine/social/media_attachments.ex | 415 +
.../lib/elektrine/social/message_policy.ex | 86 +
.../lib/elektrine/social/message_vote.ex | 24 +
.../elektrine/social/notification_policy.ex | 206 +
.../lib/elektrine/social/oembed.ex | 280 +
.../lib/elektrine/social/polls.ex | 462 +
.../lib/elektrine/social/post_boost.ex | 22 +
.../lib/elektrine/social/post_dismissal.ex | 36 +
.../lib/elektrine/social/post_like.ex | 21 +
.../lib/elektrine/social/post_view.ex | 52 +
.../elektrine/social/recommendation_item.ex | 53 +
.../social/recommendation_refresh_worker.ex | 59 +
.../lib/elektrine/social/recommendations.ex | 1718 ++
.../lib/elektrine/social/saved_item.ex | 46 +
.../social/scheduled_post_publisher_worker.ex | 20 +
.../lib/elektrine/social/status_reactions.ex | 113 +
.../social/suggested_account_dismissal.ex | 30 +
.../lib/elektrine/social/suggested_follows.ex | 160 +
.../lib/elektrine/social/thread_mute.ex | 22 +
.../lib/elektrine/social/thread_mutes.ex | 62 +
.../social/timeline_load_verifier.ex | 264 +
.../elektrine/social/timeline_pagination.ex | 67 +
.../social/timeline_relationships.ex | 267 +
.../lib/elektrine/social/views.ex | 327 +
.../lib/elektrine/social/votes.ex | 580 +
.../moderation/iftas_blocklist.ex | 311 +
.../moderation/iftas_blocklist_worker.ex | 50 +
.../lib/elektrine_social/platform.ex | 20 +
.../elektrine_social/remote_user/metrics.ex | 246 +
.../remote_user/metrics_worker.ex | 39 +
.../remote_user/outbox_sync.ex | 332 +
.../remote_user/outbox_sync_worker.ex | 35 +
.../lib/elektrine_social_web.ex | 99 +
.../components/platform/e_nav.ex | 48 +
.../components/social/content_journey.ex | 63 +
.../components/social/embedded_post.ex | 363 +
.../components/social/fediverse_follow.ex | 286 +
.../components/social/follow_button.ex | 87 +
.../components/social/poll.ex | 123 +
.../components/social/poll_display.ex | 551 +
.../components/social/post_actions.ex | 1013 +
.../components/social/post_reactions.ex | 283 +
.../components/social/post_utilities.ex | 1129 ++
.../components/social/remote_post_shared.ex | 426 +
.../components/social/reply_item.ex | 656 +
.../components/social/rss_item.ex | 219 +
.../components/social/timeline_post.ex | 2668 +++
.../social/timeline_post_ancestors.ex | 767 +
.../components/social/timeline_post_boost.ex | 117 +
.../components/social/timeline_post_card.ex | 58 +
.../social/timeline_post_compact.ex | 153 +
.../components/social/timeline_post_footer.ex | 236 +
.../components/social/timeline_post_media.ex | 440 +
.../components/social/timeline_stream_post.ex | 425 +
.../components/ui/image_modal.ex | 378 +
.../components/user/hover_card.ex | 415 +
.../controllers/activitypub/actor_request.ex | 145 +
.../activitypub/signature_actor_verifier.ex | 192 +
.../controllers/activitypub_controller.ex | 1121 ++
.../controllers/api/ext_social_controller.ex | 328 +
.../controllers/api/social_controller.ex | 1210 ++
.../external_interaction_controller.ex | 116 +
.../controllers/media_proxy_controller.ex | 214 +
.../controllers/nodeinfo_controller.ex | 305 +
.../controllers/webfinger_controller.ex | 454 +
.../live/discussions_live/community.ex | 1640 ++
.../live/discussions_live/community.html.heex | 2175 +++
.../live/discussions_live/index.ex | 3729 ++++
.../live/discussions_live/index.html.heex | 1937 ++
.../operations/flair_operations.ex | 172 +
.../operations/member_operations.ex | 217 +
.../operations/moderation_operations.ex | 610 +
.../operations/post_operations.ex | 1309 ++
.../operations/sort_helpers.ex | 61 +
.../operations/ui_operations.ex | 302 +
.../operations/voting_operations.ex | 272 +
.../live/discussions_live/post.ex | 1316 ++
.../live/discussions_live/post.html.heex | 1277 ++
.../post_operations/moderation_operations.ex | 632 +
.../post_operations/reply_operations.ex | 335 +
.../post_operations/ui_operations.ex | 241 +
.../post_operations/voting_operations.ex | 320 +
.../live/discussions_live/post_router.ex | 190 +
.../live/discussions_live/router.ex | 307 +
.../live/discussions_live/session_context.ex | 246 +
.../live/discussions_live/settings.ex | 182 +
.../live/discussions_live/settings.html.heex | 302 +
.../live/filters_live/index.ex | 270 +
.../live/filters_live/index.html.heex | 239 +
.../live/gallery_live/index.ex | 1695 ++
.../live/gallery_live/index.html.heex | 772 +
.../live/global_feed_page.ex | 25 +
.../live/hashtag_live/show.ex | 986 +
.../live/hashtag_live/show.html.heex | 350 +
.../live/list_live/index.ex | 327 +
.../live/list_live/index.html.heex | 380 +
.../live/list_live/show.ex | 926 +
.../live/list_live/show.html.heex | 659 +
.../live/remote_post_live/access_policy.ex | 94 +
.../ancestor_context_components.ex | 222 +
.../remote_post_live/cached_post_fields.ex | 343 +
.../live/remote_post_live/counts.ex | 146 +
.../remote_post_live/detail_components.ex | 153 +
.../live/remote_post_live/detail_state.ex | 332 +
.../remote_post_live/discussion_source.ex | 166 +
.../live/remote_post_live/display_counts.ex | 136 +
.../live/remote_post_live/interactions.ex | 767 +
.../live/remote_post_live/navigation.ex | 106 +
.../live/remote_post_live/polls.ex | 81 +
.../quick_reply_components.ex | 316 +
.../reply_author_components.ex | 256 +
.../live/remote_post_live/show.ex | 5893 ++++++
.../live/remote_post_live/show.html.heex | 1840 ++
.../live/remote_post_live/submitted_links.ex | 289 +
.../live/remote_post_live/surface_helpers.ex | 626 +
.../threaded_comment_components.ex | 906 +
.../live/remote_post_live/threading.ex | 289 +
.../live/remote_user_live/actor_lookup.ex | 78 +
.../community_post_operations.ex | 179 +
.../live/remote_user_live/counts.ex | 290 +
.../remote_user_live/follow_operations.ex | 99 +
.../live/remote_user_live/media_modal.ex | 59 +
.../post_interaction_operations.ex | 1092 ++
.../live/remote_user_live/post_sorting.ex | 228 +
.../live/remote_user_live/post_state.ex | 433 +
.../remote_user_live/reaction_surfaces.ex | 167 +
.../live/remote_user_live/reply_operations.ex | 162 +
.../live/remote_user_live/show.ex | 580 +
.../live/remote_user_live/show.html.heex | 2083 +++
.../live/remote_user_live/timeline_loader.ex | 505 +
.../live/timeline_live/index.ex | 2494 +++
.../live/timeline_live/index.html.heex | 1721 ++
.../operations/bookmark_folder_operations.ex | 203 +
.../live/timeline_live/operations/helpers.ex | 1192 ++
.../operations/image_operations.ex | 240 +
.../operations/navigation_operations.ex | 145 +
.../operations/post_operations.ex | 1751 ++
.../operations/reply_operations.ex | 425 +
.../operations/social_operations.ex | 479 +
.../operations/tracking_operations.ex | 186 +
.../timeline_live/operations/ui_operations.ex | 98 +
.../operations/voting_operations.ex | 951 +
.../live/timeline_live/post.ex | 1319 ++
.../live/timeline_live/post.html.heex | 858 +
.../timeline_live/reply_context_previews.ex | 296 +
.../live/timeline_live/router.ex | 168 +
.../live/videos_live/index.ex | 907 +
.../live/videos_live/index.html.heex | 343 +
.../mix/tasks/social.verify_timeline_load.ex | 67 +
apps/elektrine_social/mix.exs | 51 +
...ccount_subscription_notifications_test.exs | 90 +
.../social/blocked_instances_test.exs | 219 +
.../social/bookmark_folders_test.exs | 36 +
.../test/elektrine/social/bookmarks_test.exs | 311 +
.../test/elektrine/social/boosts_test.exs | 266 +
.../test/elektrine/social/drafts_test.exs | 248 +
.../test/elektrine/social/filters_test.exs | 138 +
.../social/hashtag_extractor_test.exs | 39 +
.../test/elektrine/social/hashtags_test.exs | 33 +
.../home_feed_cache_invalidation_test.exs | 274 +
.../test/elektrine/social/likes_test.exs | 276 +
.../link_preview_refresh_worker_test.exs | 40 +
.../elektrine/social/link_preview_test.exs | 118 +
.../elektrine/social/media_metadata_test.exs | 114 +
.../social/notification_policy_test.exs | 271 +
.../test/elektrine/social/poll_test.exs | 294 +
.../recommendation_refresh_worker_test.exs | 43 +
.../elektrine/social/recommendations_test.exs | 342 +
.../social/remote_user/outbox_sync_test.exs | 75 +
.../social/remote_user_metrics_test.exs | 68 +
.../elektrine/social/reply_counts_test.exs | 42 +
.../elektrine/social/status_pins_test.exs | 88 +
.../social/timeline_load_verifier_test.exs | 47 +
.../test/elektrine/social/views_test.exs | 169 +
.../test/elektrine/social/votes_test.exs | 314 +
.../moderation/iftas_blocklist_test.exs | 84 +
.../components/social/poll_display_test.exs | 68 +
.../components/social/post_reactions_test.exs | 85 +
.../flair_operations_test.exs | 61 +
.../feed_display_toggles_test.exs | 66 +
.../timeline_live/post_operations_test.exs | 132 +
.../test/support/data_case.ex | 58 +
.../support/fixtures/accounts_fixtures.ex | 58 +
.../test/support/fixtures/email_fixtures.ex | 55 +
.../test/support/fixtures/social_fixtures.ex | 150 +
apps/elektrine_social/test/test_helper.exs | 6 +
apps/elektrine_uptime/README.md | 21 +
apps/elektrine_uptime/lib/elektrine/uptime.ex | 346 +
.../lib/elektrine/uptime/application.ex | 12 +
.../lib/elektrine/uptime/check.ex | 30 +
.../uptime/check_scheduler_worker.ex | 35 +
.../lib/elektrine/uptime/check_worker.ex | 52 +
.../lib/elektrine/uptime/checker.ex | 234 +
.../lib/elektrine/uptime/checker/behaviour.ex | 17 +
.../lib/elektrine/uptime/email.ex | 165 +
.../lib/elektrine/uptime/incident.ex | 27 +
.../lib/elektrine/uptime/monitor.ex | 161 +
.../lib/elektrine/uptime/notifier.ex | 105 +
.../lib/elektrine_uptime_web.ex | 69 +
.../live/uptime_live/index.ex | 335 +
.../live/uptime_live/index.html.heex | 422 +
apps/elektrine_uptime/mix.exs | 43 +
.../elektrine/uptime/check_worker_test.exs | 65 +
.../test/elektrine/uptime/checker_test.exs | 188 +
.../test/elektrine/uptime/notifier_test.exs | 159 +
.../test/elektrine/uptime/uptime_test.exs | 188 +
.../live/uptime_live_test.exs | 117 +
apps/elektrine_uptime/test/test_helper.exs | 13 +
.../test_support/data_case.ex | 58 +
apps/elektrine_vpn/.gitignore | 22 +
apps/elektrine_vpn/README.md | 22 +
apps/elektrine_vpn/lib/elektrine/vpn.ex | 1549 ++
.../lib/elektrine/vpn/connection_log.ex | 36 +
.../lib/elektrine/vpn/health_monitor.ex | 135 +
.../lib/elektrine/vpn/peer_cache.ex | 73 +
.../elektrine/vpn/self_hosted_reconciler.ex | 171 +
.../lib/elektrine/vpn/self_hosted_server.ex | 37 +
.../vpn/self_hosted_shadowsocks_server.ex | 137 +
.../elektrine_vpn/lib/elektrine/vpn/server.ex | 146 +
.../lib/elektrine/vpn/shadowsocks_adapter.ex | 236 +
.../elektrine/vpn/stale_node_reaper_worker.ex | 55 +
.../lib/elektrine/vpn/stats_aggregator.ex | 155 +
.../lib/elektrine/vpn/user_config.ex | 69 +
.../lib/elektrine/vpn/wire_guard_adapter.ex | 98 +
.../lib/elektrine_vpn/platform.ex | 34 +
apps/elektrine_vpn/lib/elektrine_vpn_web.ex | 96 +
.../components/platform/elektrine_nav.ex | 46 +
.../controllers/admin/vpn_controller.ex | 311 +
.../controllers/admin/vpn_html.ex | 31 +
.../confirm_delete_vpn_server.html.heex | 160 +
.../admin/vpn_html/edit_vpn_server.html.heex | 401 +
.../vpn_html/edit_vpn_user_config.html.heex | 143 +
.../admin/vpn_html/new_vpn_server.html.heex | 432 +
.../admin/vpn_html/vpn_dashboard.html.heex | 312 +
.../admin/vpn_html/vpn_users.html.heex | 257 +
.../controllers/api/vpn_controller.ex | 290 +
.../controllers/vpn_api_controller.ex | 467 +
.../live/page_live/vpn_policy.ex | 196 +
.../elektrine_vpn_web/live/vpn_live/index.ex | 269 +
.../live/vpn_live/index.html.heex | 434 +
.../tasks/elektrine.vpn.reap_stale_nodes.ex | 122 +
apps/elektrine_vpn/mix.exs | 55 +
.../vpn/shadowsocks_adapter_test.exs | 144 +
.../vpn/stale_node_reaper_worker_test.exs | 72 +
.../elektrine/vpn/wire_guard_adapter_test.exs | 25 +
.../elektrine_vpn/test/elektrine/vpn_test.exs | 497 +
apps/elektrine_vpn/test/support/data_case.ex | 58 +
apps/elektrine_vpn/test/test_helper.exs | 6 +
apps/elektrine_web/.gitignore | 22 +
apps/elektrine_web/README.md | 20 +
apps/elektrine_web/lib/elektrine_web.ex | 128 +
.../lib/elektrine_web/account_settings.ex | 67 +
.../lib/elektrine_web/admin_security.ex | 656 +
.../lib/elektrine_web/atomine_gate.ex | 558 +
.../lib/elektrine_web/atomine_pow.ex | 70 +
.../lib/elektrine_web/canonical_url.ex | 50 +
.../elektrine_web/channels/call_channel.ex | 437 +
.../elektrine_web/channels/mobile_channel.ex | 1046 ++
.../lib/elektrine_web/channels/presence.ex | 212 +
.../lib/elektrine_web/channels/user_socket.ex | 110 +
.../elektrine_web/channels/voice_channel.ex | 189 +
.../lib/elektrine_web/client_ip.ex | 365 +
.../components/core_components.ex | 616 +
.../components/datetime/local_time.ex | 147 +
.../elektrine_web/components/emoji_picker.ex | 259 +
.../components/layout/announcement.ex | 113 +
.../elektrine_web/components/layout/header.ex | 31 +
.../components/layout/navigation.ex | 35 +
.../lib/elektrine_web/components/layouts.ex | 664 +
.../components/layouts/admin.html.heex | 203 +
.../components/layouts/app.html.heex | 812 +
.../components/layouts/profile.html.heex | 284 +
.../components/layouts/root.html.heex | 78 +
.../components/notification/bell.ex | 48 +
.../components/platform/e_nav.ex | 96 +
.../components/platform/elektrine_nav.ex | 24 +
.../components/presence/helpers.ex | 51 +
.../components/profile/account_extras.ex | 170 +
.../components/profile/containers.ex | 226 +
.../components/profile/modals.ex | 140 +
.../components/social/fediverse_follow.ex | 21 +
.../components/social/post_actions.ex | 33 +
.../components/social/post_reactions.ex | 13 +
.../components/social/post_utilities.ex | 216 +
.../components/social/youtube_preview.ex | 144 +
.../components/ui/action_toolbar.ex | 18 +
.../lib/elektrine_web/components/ui/badge.ex | 234 +
.../elektrine_web/components/ui/brand_icon.ex | 229 +
.../lib/elektrine_web/components/ui/button.ex | 172 +
.../lib/elektrine_web/components/ui/card.ex | 166 +
.../elektrine_web/components/ui/dropdown.ex | 75 +
.../components/ui/empty_state.ex | 130 +
.../components/ui/floating_panel.ex | 19 +
.../lib/elektrine_web/components/ui/form.ex | 266 +
.../lib/elektrine_web/components/ui/icon.ex | 57 +
.../components/ui/image_modal.ex | 13 +
.../elektrine_web/components/ui/loading.ex | 242 +
.../lib/elektrine_web/components/ui/modal.ex | 189 +
.../components/ui/pill_switcher.ex | 48 +
.../components/ui/section_header.ex | 75 +
.../elektrine_web/components/ui/stats_row.ex | 26 +
.../lib/elektrine_web/components/ui/table.ex | 112 +
.../components/ui/uplot_chart.ex | 170 +
.../components/user/hover_card.ex | 13 +
.../components/user/username_effects.ex | 4 +
.../components/user/verification_badge.ex | 4 +
.../admin/announcements_controller.ex | 74 +
.../controllers/admin/announcements_html.ex | 12 +
.../admin/communities_controller.ex | 421 +
.../controllers/admin/communities_html.ex | 11 +
.../admin/deletion_requests_controller.ex | 225 +
.../admin/deletion_requests_html.ex | 11 +
.../admin/email_delivery_controller.ex | 124 +
.../controllers/admin/email_delivery_html.ex | 22 +
.../admin/email_delivery_html/index.html.heex | 339 +
.../controllers/admin/haraka_controller.ex | 15 +
.../controllers/admin/haraka_html.ex | 43 +
.../admin/haraka_html/index.html.heex | 339 +
.../admin/invite_codes_controller.ex | 150 +
.../controllers/admin/invite_codes_html.ex | 12 +
.../admin/moderation_controller.ex | 216 +
.../controllers/admin/moderation_html.ex | 11 +
.../admin/monitoring_controller.ex | 559 +
.../controllers/admin/monitoring_html.ex | 14 +
.../controllers/admin/security_controller.ex | 194 +
.../controllers/admin/security_html.ex | 9 +
.../admin/subscriptions_controller.ex | 152 +
.../controllers/admin/subscriptions_html.ex | 12 +
.../controllers/admin/users_controller.ex | 1001 +
.../controllers/admin/users_html.ex | 14 +
.../admin_audit_logs_controller.ex | 45 +
.../controllers/admin_audit_logs_html.ex | 8 +
.../admin_audit_logs_html/index.html.heex | 176 +
.../controllers/admin_controller.ex | 467 +
.../elektrine_web/controllers/admin_html.ex | 490 +
.../admin_html/account_lookup.html.heex | 306 +
.../admin_html/active_users.html.heex | 197 +
.../admin_html/announcements.html.heex | 186 +
.../controllers/admin_html/ban.html.heex | 89 +
.../admin_html/communities.html.heex | 398 +
.../admin_html/content_moderation.html.heex | 449 +
.../admin_html/dashboard.html.heex | 918 +
.../admin_html/deletion_requests.html.heex | 223 +
.../controllers/admin_html/edit.html.heex | 737 +
.../admin_html/edit_announcement.html.heex | 135 +
.../admin_html/edit_invite_code.html.heex | 277 +
.../admin_html/edit_product.html.heex | 244 +
.../controllers/admin_html/elevate.html.heex | 88 +
.../admin_html/imap_users.html.heex | 183 +
.../admin_html/invite_codes.html.heex | 338 +
.../controllers/admin_html/new.html.heex | 100 +
.../admin_html/new_announcement.html.heex | 134 +
.../admin_html/new_invite_code.html.heex | 147 +
.../admin_html/new_product.html.heex | 241 +
.../admin_html/operations.html.heex | 407 +
.../admin_html/pop3_users.html.heex | 183 +
.../admin_html/show_community.html.heex | 405 +
.../show_deletion_request.html.heex | 208 +
.../admin_html/subscriptions.html.heex | 310 +
.../admin_html/two_factor_status.html.heex | 229 +
.../admin_html/unsubscribe_stats.html.heex | 186 +
.../controllers/admin_html/users.html.heex | 579 +
.../api/account_birthday_controller.ex | 103 +
.../api/account_credential_controller.ex | 181 +
.../api/account_directory_controller.ex | 143 +
.../controllers/api/account_json.ex | 296 +
.../api/account_note_controller.ex | 48 +
.../api/account_registration_controller.ex | 156 +
.../api/account_relationship_controller.ex | 638 +
.../api/account_search_controller.ex | 277 +
.../api/account_status_controller.ex | 124 +
.../api/announcement_controller.ex | 106 +
.../controllers/api/app_controller.ex | 158 +
.../api/atomine_attestation_controller.ex | 136 +
.../controllers/api/auth_controller.ex | 243 +
.../controllers/api/backup_controller.ex | 132 +
.../controllers/api/bookmark_controller.ex | 61 +
.../api/bookmark_folder_controller.ex | 75 +
.../controllers/api/calendar_controller.ex | 321 +
.../controllers/api/chat_compat_controller.ex | 282 +
.../api/custom_emoji_controller.ex | 36 +
.../controllers/api/device_controller.ex | 124 +
.../api/direct_conversation_controller.ex | 294 +
.../api/domain_block_controller.ex | 49 +
.../controllers/api/export_controller.ex | 274 +
.../controllers/api/favourite_controller.ex | 60 +
.../controllers/api/filter_controller.ex | 148 +
.../api/follow_request_controller.ex | 81 +
.../api/global_search_controller.ex | 146 +
.../controllers/api/instance_controller.ex | 335 +
.../controllers/api/kairo_controller.ex | 304 +
.../controllers/api/list_controller.ex | 223 +
.../controllers/api/marker_controller.ex | 42 +
.../controllers/api/mcp_controller.ex | 136 +
.../api/media_attachment_controller.ex | 201 +
.../controllers/api/meta_controller.ex | 531 +
.../api/notification_controller.ex | 541 +
.../api/outgoing_follow_request_controller.ex | 56 +
.../controllers/api/pagination_headers.ex | 60 +
.../api/password_reset_controller.ex | 88 +
.../controllers/api/poll_controller.ex | 90 +
.../controllers/api/poll_json.ex | 41 +
.../controllers/api/preference_controller.ex | 37 +
.../controllers/api/preferences_controller.ex | 27 +
.../controllers/api/proof_controller.ex | 310 +
.../api/push_subscription_controller.ex | 102 +
.../api/relationship_import_controller.ex | 198 +
.../controllers/api/relationship_json.ex | 77 +
.../controllers/api/report_controller.ex | 338 +
.../elektrine_web/controllers/api/response.ex | 67 +
.../api/scheduled_status_controller.ex | 208 +
.../controllers/api/scrobble_controller.ex | 82 +
.../controllers/api/search_controller.ex | 204 +
.../controllers/api/settings_controller.ex | 498 +
.../controllers/api/static_site_controller.ex | 364 +
.../api/status_action_controller.ex | 396 +
.../controllers/api/status_json.ex | 650 +
.../controllers/api/status_pin_controller.ex | 68 +
.../api/status_reaction_controller.ex | 152 +
.../controllers/api/status_read_controller.ex | 333 +
.../controllers/api/suggestion_controller.ex | 91 +
.../controllers/api/tag_controller.ex | 172 +
.../controllers/api/timeline_controller.ex | 96 +
.../controllers/api/trend_controller.ex | 210 +
.../two_factor_authentication_controller.ex | 171 +
.../controllers/api/user_controller.ex | 108 +
.../controllers/api/utility_controller.ex | 306 +
.../controllers/api/webhook_controller.ex | 318 +
.../controllers/autoconfig_controller.ex | 253 +
.../bluesky_identity_controller.ex | 42 +
.../controllers/caddy_tls_controller.ex | 162 +
.../controllers/captcha_controller.ex | 20 +
.../controllers/changeset_json.ex | 13 +
.../connected_account_controller.ex | 248 +
.../controllers/dav/calendar_controller.ex | 558 +
.../controllers/dav/drive_controller.ex | 379 +
.../controllers/dav/principal_controller.ex | 37 +
.../controllers/dav_controller.ex | 74 +
.../controllers/drive_controller.ex | 55 +
.../controllers/drive_share_controller.ex | 155 +
.../elektrine_web/controllers/error_html.ex | 17 +
.../controllers/error_html/403.html.heex | 52 +
.../controllers/error_html/404.html.heex | 52 +
.../controllers/error_html/413.html.heex | 61 +
.../controllers/error_html/500.html.heex | 52 +
.../elektrine_web/controllers/error_json.ex | 26 +
.../controllers/fallback_controller.ex | 83 +
.../controllers/health_controller.ex | 12 +
.../internal_acme_dns_controller.ex | 134 +
.../controllers/internal_dns_controller.ex | 14 +
.../internal_edge_proxy_controller.ex | 23 +
.../controllers/link_controller.ex | 106 +
.../controllers/locale_controller.ex | 62 +
.../controllers/mail_security_controller.ex | 69 +
.../messaging_federation_controller.ex | 779 +
.../controllers/nerve_extension_controller.ex | 392 +
.../controllers/oidc_client_controller.ex | 188 +
.../controllers/oidc_client_html.ex | 131 +
.../oidc_client_html/edit.html.heex | 69 +
.../oidc_client_html/index.html.heex | 133 +
.../oidc_client_html/new.html.heex | 29 +
.../controllers/oidc_controller.ex | 624 +
.../controllers/oidc_grant_controller.ex | 33 +
.../controllers/oidc_grant_html.ex | 5 +
.../oidc_grant_html/index.html.heex | 79 +
.../elektrine_web/controllers/oidc_html.ex | 31 +
.../controllers/oidc_html/authorize.html.heex | 59 +
.../controllers/own_root_controller.ex | 102 +
.../controllers/page_controller.ex | 100 +
.../elektrine_web/controllers/page_html.ex | 8 +
.../controllers/page_html/canary.html.heex | 88 +
.../controllers/passkey_controller.ex | 141 +
.../controllers/password_reset_controller.ex | 264 +
.../controllers/password_reset_html.ex | 10 +
.../password_reset_html/edit.html.heex | 37 +
.../password_reset_html/new.html.heex | 44 +
.../private_attachment_controller.ex | 85 +
.../controllers/profile_controller.ex | 973 +
.../elektrine_web/controllers/profile_html.ex | 31 +
.../controllers/recovery_email_controller.ex | 51 +
.../controllers/recovery_email_html.ex | 10 +
.../recovery_email_html/error.html.heex | 39 +
.../recovery_email_html/success.html.heex | 40 +
.../registration_payment_controller.ex | 57 +
.../controllers/registration_payment_html.ex | 9 +
.../registration_payment_html/show.html.heex | 60 +
.../controllers/sitemap_controller.ex | 175 +
.../elektrine_web/controllers/sitemap_html.ex | 5 +
.../controllers/sitemap_xml/index.xml.eex | 11 +
.../controllers/stripe_webhook_controller.ex | 91 +
.../controllers/two_factor_controller.ex | 222 +
.../controllers/two_factor_html.ex | 8 +
.../controllers/two_factor_html/new.html.heex | 64 +
.../user_registration_controller.ex | 246 +
.../controllers/user_session_controller.ex | 282 +
.../controllers/user_settings_controller.ex | 668 +
.../controllers/user_settings_html.ex | 5 +
.../user_settings_html/delete.html.heex | 55 +
.../user_settings_html/edit.html.heex | 267 +
.../edit_password.html.heex | 111 +
.../two_factor_manage.html.heex | 157 +
.../two_factor_new_codes.html.heex | 46 +
.../two_factor_setup.html.heex | 222 +
.../lib/elektrine_web/dav/properties.ex | 164 +
.../lib/elektrine_web/dav/response_helpers.ex | 356 +
.../lib/elektrine_web/endpoint.ex | 108 +
.../lib/elektrine_web/gettext.ex | 25 +
.../lib/elektrine_web/github_oidc.ex | 76 +
.../lib/elektrine_web/github_webhooks.ex | 45 +
.../lib/elektrine_web/html_helpers.ex | 1102 ++
.../lib/elektrine_web/json_log_formatter.ex | 64 +
.../live/admin_live/badge_management.ex | 176 +
.../admin_live/badge_management.html.heex | 290 +
.../live/admin_live/bluesky_bridge.ex | 585 +
.../elektrine_web/live/admin_live/emojis.ex | 278 +
.../live/admin_live/emojis.html.heex | 334 +
.../live/admin_live/federation.ex | 1517 ++
.../live/admin_live/messaging_federation.ex | 614 +
.../elektrine_web/live/admin_live/relays.ex | 803 +
.../live/admin_live/reports_dashboard.ex | 1297 ++
.../elektrine_web/live/announcement_cache.ex | 20 +
.../live/atomine_proofs_live/show.ex | 115 +
.../live/atomine_proofs_live/show.html.heex | 305 +
.../lib/elektrine_web/live/auth_hooks.ex | 550 +
.../lib/elektrine_web/live/auth_live/login.ex | 129 +
.../live/auth_live/password_reset.ex | 122 +
.../live/auth_live/password_reset_edit.ex | 104 +
.../elektrine_web/live/auth_live/register.ex | 434 +
.../live/auth_live/two_factor.ex | 84 +
.../elektrine_web/live/booking_live/cancel.ex | 71 +
.../elektrine_web/live/booking_live/manage.ex | 360 +
.../elektrine_web/live/booking_live/show.ex | 282 +
.../elektrine_web/live/calendar_live/index.ex | 228 +
.../live/calendar_live/index.html.heex | 543 +
.../operations/calendar_operations.ex | 353 +
.../live/components/report_modal.ex | 253 +
.../lib/elektrine_web/live/drive_live.ex | 880 +
.../elektrine_web/live/drive_live.html.heex | 1058 ++
.../lib/elektrine_web/live/e_nav_live.ex | 82 +
.../lib/elektrine_web/live/friends_live.ex | 669 +
.../elektrine_web/live/friends_live.html.heex | 682 +
.../live/helpers/post_state_helpers.ex | 330 +
.../live/hooks/current_path_hook.ex | 22 +
.../live/hooks/notification_count_hook.ex | 152 +
.../live/hooks/platform_module_hook.ex | 21 +
.../elektrine_web/live/hooks/presence_hook.ex | 271 +
.../elektrine_web/live/hooks/timezone_hook.ex | 44 +
.../elektrine_web/live/kairo_live/index.ex | 1706 ++
.../live/link_archive_live/index.ex | 153 +
.../lib/elektrine_web/live/nav_badge_live.ex | 87 +
.../live/notification_helpers.ex | 254 +
.../elektrine_web/live/notifications_live.ex | 782 +
.../live/notifications_live.html.heex | 442 +
.../lib/elektrine_web/live/onboarding_live.ex | 498 +
.../lib/elektrine_web/live/page_live/about.ex | 122 +
.../elektrine_web/live/page_live/contact.ex | 56 +
.../live/page_live/dev_flash_test.ex | 72 +
.../lib/elektrine_web/live/page_live/faq.ex | 128 +
.../lib/elektrine_web/live/page_live/home.ex | 668 +
.../lib/elektrine_web/live/page_live/legal.ex | 125 +
.../elektrine_web/live/page_live/privacy.ex | 286 +
.../lib/elektrine_web/live/page_live/terms.ex | 167 +
.../elektrine_web/live/post_interactions.ex | 122 +
.../live/profile_live/analytics.ex | 32 +
.../live/profile_live/analytics.html.heex | 259 +
.../live/profile_live/design_sections.ex | 472 +
.../profile_live/design_theme_sections.ex | 290 +
.../live/profile_live/domain_analytics.ex | 664 +
.../profile_live/domain_analytics.html.heex | 522 +
.../live/profile_live/domains.ex | 1217 ++
.../elektrine_web/live/profile_live/edit.ex | 2398 +++
.../live/profile_live/edit.html.heex | 2407 +++
.../live/profile_live/edit_sections.ex | 452 +
.../live/profile_live/effects_sections.ex | 374 +
.../elektrine_web/live/profile_live/show.ex | 945 +
.../live/profile_live/show.html.heex | 2385 +++
.../lib/elektrine_web/live/proofs_live.ex | 953 +
.../live/recovery_email_live/error.ex | 60 +
.../live/recovery_email_live/success.ex | 62 +
.../lib/elektrine_web/live/search_live.ex | 2110 +++
.../live/settings_live/app_passwords.ex | 465 +
.../live/settings_live/delete_account.ex | 72 +
.../live/settings_live/edit_password.ex | 133 +
.../live/settings_live/master_password.ex | 393 +
.../live/settings_live/passkey_manage.ex | 405 +
.../elektrine_web/live/settings_live/rss.ex | 272 +
.../live/settings_live/two_factor_manage.ex | 216 +
.../live/settings_live/two_factor_setup.ex | 318 +
.../lib/elektrine_web/live/storage_live.ex | 515 +
.../elektrine_web/live/storage_live.html.heex | 456 +
.../lib/elektrine_web/live/subscribe_live.ex | 552 +
.../elektrine_web/live/user_settings_live.ex | 2124 +++
.../live/user_settings_live.html.heex | 3280 ++++
.../lib/elektrine_web/mail_autoconfig.ex | 17 +
.../lib/elektrine_web/mcp/protocol.ex | 173 +
.../lib/elektrine_web/mcp/tool_registry.ex | 350 +
.../lib/elektrine_web/mcp/tools.ex | 357 +
.../lib/elektrine_web/optional_module.ex | 14 +
.../elektrine_web/platform/integrations.ex | 718 +
.../platform/module_delegates.ex | 9 +
.../lib/elektrine_web/platform_access.ex | 324 +
.../elektrine_web/plugs/activitypub_accept.ex | 14 +
.../plugs/activitypub_rate_limit.ex | 43 +
.../plugs/activitypub_request_guard.ex | 43 +
.../lib/elektrine_web/plugs/api_auth.ex | 266 +
.../lib/elektrine_web/plugs/api_rate_limit.ex | 176 +
.../plugs/block_private_upload_paths.ex | 38 +
.../lib/elektrine_web/plugs/cache_raw_body.ex | 89 +
.../plugs/conditional_live_reloader.ex | 13 +
.../elektrine_web/plugs/csrf_error_handler.ex | 85 +
.../lib/elektrine_web/plugs/dav_auth.ex | 163 +
.../lib/elektrine_web/plugs/dav_rate_limit.ex | 55 +
.../lib/elektrine_web/plugs/dns_edge_proxy.ex | 180 +
.../lib/elektrine_web/plugs/enforce_https.ex | 52 +
.../plugs/ensure_http_signature_plug.ex | 118 +
.../plugs/http_signature_plug.ex | 43 +
.../elektrine_web/plugs/internal_api_auth.ex | 147 +
.../lib/elektrine_web/plugs/locale.ex | 127 +
.../plugs/messaging_federation_auth.ex | 142 +
.../elektrine_web/plugs/notification_count.ex | 19 +
.../elektrine_web/plugs/optional_delegate.ex | 30 +
.../lib/elektrine_web/plugs/pat_auth.ex | 259 +
.../lib/elektrine_web/plugs/profile_csp.ex | 42 +
.../plugs/profile_custom_domain.ex | 142 +
.../elektrine_web/plugs/profile_subdomain.ex | 246 +
.../plugs/receive_only_email_domain_host.ex | 34 +
.../elektrine_web/plugs/request_telemetry.ex | 49 +
.../plugs/require_module_access.ex | 23 +
.../plugs/require_platform_module.ex | 21 +
.../elektrine_web/plugs/runtime_session.ex | 20 +
.../elektrine_web/plugs/security_headers.ex | 175 +
.../lib/elektrine_web/plugs/site_challenge.ex | 91 +
.../elektrine_web/plugs/site_page_tracking.ex | 116 +
.../elektrine_web/plugs/static_site_plug.ex | 363 +
.../lib/elektrine_web/plugs/timezone_plug.ex | 20 +
.../lib/elektrine_web/plugs/tor_aware.ex | 21 +
.../plugs/webdav_method_override.ex | 45 +
.../lib/elektrine_web/post_navigation.ex | 33 +
.../lib/elektrine_web/profile_navigation.ex | 64 +
.../lib/elektrine_web/profile_theme.ex | 193 +
.../elektrine_web/lib/elektrine_web/router.ex | 1962 ++
.../lib/elektrine_web/routes/chat.ex | 209 +
.../lib/elektrine_web/routes/dns.ex | 47 +
.../lib/elektrine_web/routes/email.ex | 250 +
.../lib/elektrine_web/routes/nerve.ex | 30 +
.../lib/elektrine_web/routes/social.ex | 252 +
.../lib/elektrine_web/routes/uptime.ex | 11 +
.../lib/elektrine_web/routes/vpn.ex | 65 +
.../lib/elektrine_web/safe_live_navigation.ex | 80 +
.../elektrine_web/scrubbers/remote_content.ex | 58 +
.../lib/elektrine_web/session_config.ex | 112 +
.../lib/elektrine_web/telemetry.ex | 138 +
.../elektrine_web/telemetry_measurements.ex | 63 +
.../lib/elektrine_web/url_helpers.ex | 61 +
.../lib/elektrine_web/user_auth.ex | 756 +
.../lib/elektrine_web/web_search.ex | 188 +
apps/elektrine_web/mix.exs | 142 +
.../priv/canary/canary-public-key.asc | 13 +
apps/elektrine_web/priv/canary/current.md | 42 +
apps/elektrine_web/priv/canary/current.md.asc | 7 +
.../priv/nerve-extension/background.js | 1064 ++
.../priv/nerve-extension/content.js | 1793 ++
.../priv/nerve-extension/launcher.js | 261 +
.../priv/nerve-extension/lib/api.js | 199 +
.../priv/nerve-extension/lib/crypto.js | 214 +
.../priv/nerve-extension/lib/kairo_capture.js | 58 +
.../priv/nerve-extension/lib/storage.js | 179 +
.../priv/nerve-extension/lib/theme.js | 64 +
.../priv/nerve-extension/manager.html | 107 +
.../priv/nerve-extension/manifest.json | 41 +
.../priv/nerve-extension/options.html | 60 +
.../priv/nerve-extension/options.js | 395 +
.../priv/nerve-extension/package-lock.json | 10 +
.../priv/nerve-extension/package.json | 5 +
.../priv/nerve-extension/popup.html | 41 +
.../priv/nerve-extension/popup.js | 722 +
.../priv/nerve-extension/styles.css | 693 +
.../elektrine/components/user/avatar_test.exs | 53 +
.../test/elektrine_web/atomine_pow_test.exs | 61 +
.../channels/call_channel_test.exs | 235 +
.../channels/voice_channel_test.exs | 139 +
.../test/elektrine_web/client_ip_test.exs | 266 +
.../components/emoji_picker_test.exs | 51 +
.../elektrine_web/components/layouts_test.exs | 272 +
.../components/presence_helpers_test.exs | 91 +
.../components/social/post_actions_test.exs | 170 +
.../components/social/post_utilities_test.exs | 373 +
.../social/youtube_preview_test.exs | 36 +
.../components/ui/image_modal_test.exs | 60 +
.../components/ui/uplot_chart_test.exs | 40 +
.../components/user/hover_card_test.exs | 109 +
.../components/user/username_effects_test.exs | 69 +
.../controllers/activitypub_test.exs | 2379 +++
.../admin/announcements_controller_test.exs | 73 +
.../admin/chat_messages_controller_test.exs | 151 +
.../admin/communities_controller_test.exs | 61 +
.../admin/custom_domains_controller_test.exs | 116 +
.../deletion_requests_controller_test.exs | 180 +
.../controllers/admin/elevate_page_test.exs | 82 +
.../admin/email_delivery_controller_test.exs | 303 +
.../admin/haraka_controller_test.exs | 213 +
.../admin/invite_codes_controller_test.exs | 201 +
.../admin/messages_controller_test.exs | 268 +
.../admin/moderation_controller_test.exs | 61 +
.../admin/monitoring_controller_test.exs | 266 +
.../admin/users_controller_test.exs | 156 +
.../controllers/admin/vpn_controller_test.exs | 48 +
.../api/account_birthday_controller_test.exs | 70 +
.../account_credential_controller_test.exs | 330 +
.../api/account_directory_controller_test.exs | 122 +
.../account_registration_controller_test.exs | 137 +
.../account_relationship_controller_test.exs | 616 +
...nt_relationship_follow_controller_test.exs | 133 +
.../api/account_search_controller_test.exs | 423 +
.../api/account_status_controller_test.exs | 265 +
.../api/announcement_controller_test.exs | 115 +
.../controllers/api/app_controller_test.exs | 191 +
.../atomine_attestation_controller_test.exs | 87 +
.../controllers/api/auth_controller_test.exs | 297 +
.../api/backup_controller_test.exs | 204 +
.../api/bookmark_controller_test.exs | 89 +
.../api/chat_compat_controller_test.exs | 138 +
.../api/conversation_controller_test.exs | 323 +
.../api/custom_emoji_controller_test.exs | 52 +
.../direct_conversation_controller_test.exs | 267 +
.../controllers/api/dns_controller_test.exs | 196 +
.../api/domain_block_controller_test.exs | 66 +
.../controllers/api/email_controller_test.exs | 148 +
.../api/export_controller_test.exs | 147 +
.../api/ext_v1_controller_test.exs | 937 +
.../api/favourite_controller_test.exs | 84 +
.../api/filter_controller_test.exs | 59 +
.../api/instance_controller_test.exs | 174 +
.../controllers/api/kairo_controller_test.exs | 363 +
.../controllers/api/list_controller_test.exs | 237 +
.../controllers/api/mcp_controller_test.exs | 539 +
.../api/media_attachment_controller_test.exs | 221 +
.../api/notification_controller_test.exs | 684 +
.../api/password_reset_controller_test.exs | 192 +
.../controllers/api/poll_controller_test.exs | 150 +
.../api/preference_controller_test.exs | 48 +
.../api/preferences_controller_test.exs | 59 +
.../api/push_subscription_controller_test.exs | 90 +
.../relationship_import_controller_test.exs | 232 +
.../api/report_controller_test.exs | 505 +
.../api/scheduled_status_controller_test.exs | 122 +
.../api/scrobble_controller_test.exs | 95 +
.../api/search_controller_test.exs | 130 +
.../api/server_controller_test.exs | 149 +
.../api/settings_controller_test.exs | 303 +
.../api/social_controller_test.exs | 355 +
.../api/status_action_controller_test.exs | 341 +
.../api/status_pin_controller_test.exs | 71 +
.../api/status_reaction_controller_test.exs | 153 +
.../api/status_read_controller_test.exs | 523 +
.../api/suggestion_controller_test.exs | 101 +
.../controllers/api/tag_controller_test.exs | 177 +
.../api/timeline_controller_test.exs | 243 +
.../controllers/api/trend_controller_test.exs | 106 +
..._factor_authentication_controller_test.exs | 129 +
.../api/utility_controller_test.exs | 354 +
.../controllers/api/vpn_controller_test.exs | 84 +
.../api/vpn_registration_controller_test.exs | 72 +
.../api/webhook_controller_test.exs | 234 +
.../controllers/arblarg_conformance_test.exs | 520 +
.../autoconfig_controller_test.exs | 113 +
.../controllers/caddy_tls_controller_test.exs | 303 +
.../connected_account_controller_test.exs | 38 +
.../dav/address_book_controller_test.exs | 443 +
.../dav/calendar_controller_test.exs | 657 +
.../controllers/dav/drive_controller_test.exs | 127 +
.../controllers/drive_controller_test.exs | 135 +
.../drive_share_controller_test.exs | 219 +
.../controllers/email_controller_test.exs | 419 +
.../controllers/email_delivery_smoke_test.exs | 289 +
.../controllers/error_html_test.exs | 18 +
.../controllers/error_json_test.exs | 12 +
.../external_interaction_controller_test.exs | 80 +
.../haraka_webhook_controller_test.exs | 1535 ++
.../controllers/health_controller_test.exs | 17 +
.../impersonation_controller_test.exs | 64 +
.../internal_acme_dns_controller_test.exs | 104 +
.../internal_dns_controller_test.exs | 49 +
.../controllers/jmap_controller_test.exs | 670 +
.../controllers/link_controller_test.exs | 77 +
.../mail_security_controller_test.exs | 51 +
.../media_proxy_controller_test.exs | 68 +
.../messaging_federation_controller_test.exs | 1261 ++
.../messaging_federation_rate_limit_test.exs | 196 +
.../nerve_extension_controller_test.exs | 211 +
.../oidc_client_controller_test.exs | 124 +
.../controllers/oidc_controller_test.exs | 363 +
.../oidc_grant_controller_test.exs | 53 +
.../controllers/own_root_controller_test.exs | 304 +
.../controllers/page_controller_test.exs | 64 +
.../password_reset_controller_test.exs | 348 +
.../private_attachment_controller_test.exs | 207 +
.../controllers/profile_controller_test.exs | 494 +
.../recovery_email_controller_test.exs | 156 +
.../registration_payment_controller_test.exs | 137 +
.../unsubscribe_controller_test.exs | 17 +
.../user_registration_controller_test.exs | 242 +
.../user_session_controller_test.exs | 67 +
.../user_settings_controller_test.exs | 121 +
.../controllers/user_settings_html_test.exs | 17 +
.../controllers/vpn_api_controller_test.exs | 97 +
.../controllers/wkd_controller_test.exs | 130 +
.../test/elektrine_web/features/auth_test.exs | 35 +
.../test/elektrine_web/features/chat_test.exs | 19 +
.../elektrine_web/features/email_test.exs | 19 +
.../elektrine_web/features/home_page_test.exs | 35 +
.../elektrine_web/features/profile_test.exs | 84 +
.../elektrine_web/features/timeline_test.exs | 41 +
.../elektrine_web/html_helpers_actor_test.exs | 330 +
.../live/admin_live/badge_management_test.exs | 43 +
.../live/admin_live/emojis_test.exs | 68 +
.../live/admin_live/federation_test.exs | 157 +
.../live/admin_live/relays_test.exs | 50 +
.../admin_live/reports_dashboard_test.exs | 149 +
.../live/app_passwords_live_test.exs | 149 +
.../live/atomine_proofs_live/show_test.exs | 78 +
.../live/auth_live/register_security_test.exs | 18 +
.../elektrine_web/live/auth_pow_copy_test.exs | 87 +
.../live/authenticated_routes_live_test.exs | 95 +
.../elektrine_web/live/booking_live_test.exs | 176 +
.../live/booking_manage_live_test.exs | 228 +
.../operations/calendar_operations_test.exs | 59 +
.../live/chat_live/index_test.exs | 33 +
.../operations/call_info_operations_test.exs | 36 +
.../chat_live/operations/helpers_test.exs | 77 +
.../message_info_operations_test.exs | 116 +
.../live/chat_live/router_test.exs | 58 +
.../live/chat_live_index_test.exs | 687 +
.../live/chat_live_threads_test.exs | 175 +
.../live/community_post_test.exs | 474 +
.../live/discussions_index_live_test.exs | 650 +
.../operations/member_operations_test.exs | 27 +
.../live/discussions_settings_test.exs | 58 +
.../elektrine_web/live/drive_live_test.exs | 152 +
.../live/email_compose_live_test.exs | 117 +
.../compose_paragraph_spacing_test.exs | 66 +
.../live/email_live/compose_test.exs | 252 +
.../live/email_live/email_helpers_test.exs | 33 +
.../live/email_live/index_test.exs | 318 +
.../live/email_live/search_test.exs | 50 +
.../email_live/settings_operations_test.exs | 174 +
.../email_live/show_private_mailbox_test.exs | 90 +
.../live/email_live/show_test.exs | 180 +
.../email_live/show_thread_context_test.exs | 213 +
.../elektrine_web/live/friends_live_test.exs | 112 +
.../elektrine_web/live/gallery_live_test.exs | 361 +
.../live/hashtag_live_show_test.exs | 101 +
.../live/helpers/post_state_helpers_test.exs | 37 +
.../elektrine_web/live/kairo_live_test.exs | 225 +
.../live/link_archive_live_test.exs | 108 +
.../elektrine_web/live/list_live_test.exs | 252 +
.../elektrine_web/live/locale_live_test.exs | 35 +
.../live/master_password_live_test.exs | 137 +
.../elektrine_web/live/nerve_live_test.exs | 174 +
.../live/notifications_live_test.exs | 324 +
.../live/page_live/home_test.exs | 29 +
.../live/page_live/static_pages_test.exs | 55 +
.../elektrine_web/live/paige_live_test.exs | 490 +
.../live/passkey_manage_live_test.exs | 70 +
.../live/profile_live/edit_security_test.exs | 183 +
.../live/profile_live/show_security_test.exs | 22 +
.../profile_live_domain_analytics_test.exs | 128 +
.../live/profile_live_domains_test.exs | 86 +
.../elektrine_web/live/proofs_live_test.exs | 170 +
.../live/remote_post_live/seo_test.exs | 45 +
.../live/remote_post_live_show_test.exs | 2317 +++
.../remote_user_live/follow_button_test.exs | 238 +
.../live/remote_user_live/seo_test.exs | 69 +
.../live/remote_user_live/show_test.exs | 262 +
.../elektrine_web/live/storage_live_test.exs | 54 +
.../live/subscribe_live_test.exs | 73 +
.../live/timeline_filters_test.exs | 1873 ++
.../operations/image_operations_test.exs | 61 +
.../operations/reply_operations_test.exs | 18 +
.../operations/social_operations_test.exs | 51 +
.../reply_context_previews_test.exs | 85 +
.../live/timeline_post_detail_test.exs | 639 +
.../live/user_invite_codes_live_test.exs | 128 +
.../live/user_settings_live_test.exs | 404 +
.../live/user_settings_pgp_test.exs | 299 +
.../elektrine_web/live/videos_live_test.exs | 312 +
.../platform/admin_subset_test.exs | 118 +
.../platform/shared_routes_subset_test.exs | 88 +
.../elektrine_web/platform_access_test.exs | 51 +
.../plugs/api_rate_limit_test.exs | 147 +
.../elektrine_web/plugs/dav_auth_test.exs | 319 +
.../plugs/dns_edge_proxy_test.exs | 183 +
.../plugs/email_ownership_guard_test.exs | 37 +
.../plugs/enforce_https_test.exs | 43 +
.../plugs/http_signature_plug_test.exs | 493 +
.../plugs/optional_delegate_test.exs | 46 +
.../plugs/profile_custom_domain_test.exs | 85 +
.../plugs/profile_subdomain_test.exs | 374 +
.../receive_only_email_domain_host_test.exs | 34 +
.../plugs/require_platform_module_test.exs | 59 +
.../plugs/security_headers_test.exs | 28 +
.../plugs/site_challenge_test.exs | 75 +
.../plugs/static_site_plug_test.exs | 451 +
.../test/elektrine_web/presence_test.exs | 255 +
.../elektrine_web/profile_navigation_test.exs | 55 +
.../safe_live_navigation_test.exs | 47 +
.../test/elektrine_web/user_auth_test.exs | 500 +
.../test/elektrine_web/web_search_test.exs | 231 +
.../test/support/channel_case.ex | 23 +
apps/elektrine_web/test/support/conn_case.ex | 38 +
apps/elektrine_web/test/support/data_case.ex | 58 +
.../support/fixtures/accounts_fixtures.ex | 59 +
.../test/support/fixtures/email_fixtures.ex | 55 +
.../test/support/fixtures/social_fixtures.ex | 150 +
apps/elektrine_web/test/test_helper.exs | 14 +
apps/kairo/lib/kairo.ex | 721 +
apps/kairo/lib/kairo/project.ex | 73 +
apps/kairo/lib/kairo/source.ex | 278 +
apps/kairo/lib/kairo/url_fetch_worker.ex | 471 +
apps/kairo/mix.exs | 54 +
.../test/kairo/url_fetch_worker_test.exs | 409 +
apps/kairo/test/kairo_test.exs | 665 +
apps/kairo/test/support/accounts_fixtures.ex | 28 +
apps/kairo/test/support/data_case.ex | 36 +
apps/kairo/test/test_helper.exs | 2 +
apps/paige/lib/paige.ex | 509 +
apps/paige/lib/paige/application.ex | 11 +
apps/paige/lib/paige/http.ex | 93 +
apps/paige/lib/paige/kind.ex | 25 +
apps/paige/lib/paige/provider.ex | 10 +
apps/paige/lib/paige/providers/brave.ex | 237 +
.../paige/lib/paige/providers/duck_duck_go.ex | 191 +
apps/paige/lib/paige/providers/github.ex | 112 +
apps/paige/lib/paige/providers/hacker_news.ex | 112 +
apps/paige/lib/paige/providers/wiby.ex | 126 +
apps/paige/lib/paige/providers/wikipedia.ex | 101 +
apps/paige/lib/paige/result.ex | 21 +
apps/paige/lib/paige/scraper_throttle.ex | 60 +
apps/paige/mix.exs | 33 +
apps/paige/test/paige/http_test.exs | 71 +
apps/paige/test/paige/providers_test.exs | 367 +
.../test/paige/scraper_providers_test.exs | 115 +
.../test/paige/scraper_throttle_test.exs | 17 +
apps/paige/test/paige_test.exs | 326 +
apps/paige/test/test_helper.exs | 1 +
clients/README.md | 8 +
clients/nerve-extension/background.js | 1064 ++
clients/nerve-extension/content.js | 1793 ++
clients/nerve-extension/launcher.js | 261 +
clients/nerve-extension/lib/api.js | 215 +
clients/nerve-extension/lib/crypto.js | 214 +
clients/nerve-extension/lib/kairo_capture.js | 58 +
clients/nerve-extension/lib/storage.js | 179 +
clients/nerve-extension/lib/theme.js | 68 +
clients/nerve-extension/manager.html | 107 +
clients/nerve-extension/manifest.json | 41 +
clients/nerve-extension/options.html | 60 +
clients/nerve-extension/options.js | 395 +
clients/nerve-extension/package-lock.json | 10 +
clients/nerve-extension/package.json | 5 +
clients/nerve-extension/popup.html | 41 +
clients/nerve-extension/popup.js | 722 +
clients/nerve-extension/styles.css | 706 +
config/config.exs | 522 +
config/dev.exs | 277 +
config/prod.exs | 25 +
config/runtime.exs | 1147 ++
config/runtime/bluesky.exs | 58 +
config/runtime/dns.exs | 231 +
config/runtime/mail_protocols.exs | 72 +
config/runtime/messaging_federation.exs | 121 +
config/runtime/stripe.exs | 7 +
config/runtime/uploads.exs | 84 +
config/runtime/webrtc.exs | 111 +
config/test.exs | 172 +
deploy/caddy/Caddyfile | 80 +
deploy/caddy/Caddyfile.baremetal | 84 +
.../caddy/Caddyfile.baremetal.external-certs | 96 +
.../Caddyfile.baremetal.wildcard-external | 96 +
deploy/caddy/Caddyfile.external-certs | 99 +
deploy/caddy/Caddyfile.wildcard-external | 92 +
deploy/caddy/Dockerfile | 1 +
deploy/docker/Dockerfile | 245 +
deploy/docker/compose.core.yml | 101 +
deploy/docker/compose.full.yml | 394 +
deploy/docker/compose.magpie-network.yml | 17 +
deploy/docker/docker-entrypoint.sh | 127 +
deploy/docker/elektrine-compose.service | 16 +
deploy/docker/initdb/010-extensions.sql | 2 +
deploy/docker/start.sh | 347 +
deploy/generated/.gitkeep | 1 +
deploy/onion/torrc | 9 +
deploy/terraform/vpn-fleet/.gitignore | 13 +
deploy/terraform/vpn-fleet/README.md | 138 +
.../examples/digitalocean/.terraform.lock.hcl | 26 +
.../vpn-fleet/examples/digitalocean/main.tf | 33 +
.../examples/digitalocean/variables.tf | 41 +
.../examples/hetzner/.terraform.lock.hcl | 23 +
.../vpn-fleet/examples/hetzner/main.tf | 33 +
.../vpn-fleet/examples/hetzner/variables.tf | 39 +
.../multi-provider/.terraform.lock.hcl | 70 +
.../vpn-fleet/examples/multi-provider/main.tf | 74 +
.../multi-provider/terraform.tfvars.example | 28 +
.../examples/multi-provider/variables.tf | 37 +
.../vpn-fleet/modules/digitalocean/main.tf | 73 +
.../vpn-fleet/modules/digitalocean/outputs.tf | 4 +
.../modules/digitalocean/variables.tf | 42 +
.../vpn-fleet/modules/hetzner/main.tf | 61 +
.../vpn-fleet/modules/hetzner/outputs.tf | 4 +
.../vpn-fleet/modules/hetzner/variables.tf | 42 +
.../vpn-fleet/modules/user-data/main.tf | 36 +
.../vpn-fleet/modules/user-data/outputs.tf | 5 +
.../user-data/templates/cloud-init.yaml.tftpl | 26 +
.../vpn-fleet/modules/user-data/variables.tf | 50 +
.../terraform/vpn-fleet/modules/vultr/main.tf | 59 +
.../vpn-fleet/modules/vultr/outputs.tf | 4 +
.../vpn-fleet/modules/vultr/variables.tf | 37 +
docs/addons/onion.md | 43 +
docs/api/external-api.md | 264 +
docs/arblarg-1.0.md | 1101 ++
docs/architecture/composable-platform.md | 34 +
docs/architecture/dns-module.md | 76 +
docs/clients/mcp.md | 57 +
docs/clients/nerve-extension.md | 12 +
docs/clients/static-site-github-deploys.md | 17 +
docs/security/warrant-canary.md | 38 +
docs/self-hosting/README.md | 58 +
docs/self-hosting/baremetal.md | 4 +
docs/self-hosting/caddy.md | 130 +
docs/self-hosting/core.md | 48 +
docs/self-hosting/deploy-runbook.md | 127 +
docs/self-hosting/docker.md | 457 +
docs/self-hosting/mail.md | 350 +
docs/self-hosting/turn.md | 60 +
docs/self-hosting/vpn.md | 137 +
elixir_buildpack.config | 2 +
env/caddy.env.example | 44 +
env/core.env.example | 162 +
env/dev.env.example | 232 +
env/mail.env.example | 63 +
env/onion.env.example | 17 +
env/presets/bluesky.env | 10 +
env/presets/dns.env | 15 +
env/presets/mail.env | 20 +
env/presets/s3.env | 15 +
env/presets/tor.env | 13 +
env/presets/turn.env | 13 +
env/presets/vpn.env | 16 +
env/presets/wildcard-tls.env | 13 +
env/vpn.env.example | 20 +
external/arblarg/README.md | 16 +
external/arblarg/benchmarks/latest.json | 26 +
external/arblarg/interop/smoke.json | 22 +
external/arblarg/schemas/v1/ban.upsert.json | 172 +
.../arblarg/schemas/v1/dm.call.accept.json | 171 +
external/arblarg/schemas/v1/dm.call.end.json | 181 +
.../arblarg/schemas/v1/dm.call.invite.json | 192 +
.../arblarg/schemas/v1/dm.call.reject.json | 174 +
.../arblarg/schemas/v1/dm.call.signal.json | 193 +
.../arblarg/schemas/v1/dm.message.create.json | 262 +
external/arblarg/schemas/v1/envelope.json | 110 +
.../arblarg/schemas/v1/invite.upsert.json | 178 +
.../arblarg/schemas/v1/membership.upsert.json | 132 +
.../arblarg/schemas/v1/message.create.json | 194 +
.../arblarg/schemas/v1/message.delete.json | 55 +
.../arblarg/schemas/v1/message.update.json | 190 +
.../v1/moderation.action.recorded.json | 104 +
.../v1/permission.overwrite.upsert.json | 111 +
external/arblarg/schemas/v1/pin.upsert.json | 92 +
.../arblarg/schemas/v1/presence.update.json | 109 +
external/arblarg/schemas/v1/reaction.add.json | 109 +
.../arblarg/schemas/v1/reaction.remove.json | 109 +
external/arblarg/schemas/v1/read.cursor.json | 105 +
.../schemas/v1/role.assignment.upsert.json | 105 +
external/arblarg/schemas/v1/role.upsert.json | 102 +
.../arblarg/schemas/v1/server.upsert.json | 18 +
.../arblarg/schemas/v1/thread.archive.json | 76 +
.../arblarg/schemas/v1/thread.upsert.json | 103 +
external/arblarg/schemas/v1/typing.start.json | 101 +
external/arblarg/schemas/v1/typing.stop.json | 96 +
.../arblarg/test_vectors/v1/error_cases.json | 55 +
.../test_vectors/v1/event_message_create.json | 47 +
.../test_vectors/v1/extension_events.json | 343 +
.../test_vectors/v1/request_signature.json | 17 +
external/haraka-elektrine | 1 +
mise.toml | 4 +
mix.exs | 70 +
mix.lock | 98 +
phoenix_static_buildpack.config | 1 +
priv/repo/seeds.exs | 8 +
release_builder/README.md | 67 +
release_builder/config/config.exs | 72 +
release_builder/config/runtime.exs | 4 +
.../lib/elektrine_release_builder.ex | 3 +
release_builder/mix.exs | 57 +
release_builder/module_selection.exs | 89 +
scripts/acme/dns_elektrine.sh | 268 +
scripts/acme/issue_elektrine_wildcard_cert.sh | 239 +
scripts/acme/setup_haraka_mail_tls.sh | 523 +
scripts/check_design_consistency.sh | 100 +
scripts/check_legacy_marker_budget.sh | 27 +
scripts/check_maintainability_budgets.sh | 168 +
scripts/check_tracked_generated_artifacts.sh | 24 +
scripts/deploy/build_and_push_image.sh | 64 +
scripts/deploy/configure_docker_source_ips.sh | 177 +
.../deploy/configure_haraka_wildcard_tls.sh | 613 +
scripts/deploy/deploy_pushed_image.sh | 101 +
scripts/deploy/docker_deploy.sh | 1026 +
scripts/deploy/doctor.sh | 583 +
scripts/deploy/explain_deploy.sh | 103 +
scripts/deploy/generate_env.sh | 239 +
scripts/deploy/prune_old_images.sh | 179 +
scripts/deploy/render_docker_compose.sh | 172 +
scripts/deploy/self_host.sh | 321 +
scripts/lib/deploy_simplify.sh | 157 +
scripts/lib/module_selection.sh | 125 +
scripts/onion/sync-onion-key-secrets.sh | 29 +
scripts/release/deploy_release.sh | 91 +
scripts/vpn/README.md | 155 +
scripts/vpn/bootstrap-node.sh | 273 +
scripts/vpn/elektrine-vpn-agent.env.example | 35 +
scripts/vpn/elektrine-vpn-agent.service | 19 +
scripts/vpn/elektrine-vpn-agent.sh | 366 +
scripts/vpn/gen-cloud-init.sh | 106 +
4228 files changed, 650609 insertions(+)
create mode 100644 .credo.exs
create mode 100644 .dockerignore
create mode 100644 .env.example
create mode 100644 .forgejo/README.md
create mode 100644 .forgejo/workflows/ci.yml
create mode 100644 .forgejo/workflows/docker-deploy.yml
create mode 100644 .forgejo/workflows/smoke.yml
create mode 100644 .formatter.exs
create mode 100644 .github/ISSUE_TEMPLATE/bug_report.yml
create mode 100644 .github/ISSUE_TEMPLATE/config.yml
create mode 100644 .github/ISSUE_TEMPLATE/feature_request.yml
create mode 100644 .github/dependabot.yml
create mode 100644 .github/pull_request_template.md
create mode 100644 .github/workflows/ci.yml
create mode 100644 .github/workflows/docker-deploy.yml
create mode 100644 .gitignore
create mode 100644 AGENTS.md
create mode 100644 CODE_OF_CONDUCT.md
create mode 100644 CONTRIBUTING.md
create mode 100644 LICENSE
create mode 100644 README.md
create mode 100644 SECURITY.md
create mode 100644 apps/README.md
create mode 100644 apps/arblarg/.formatter.exs
create mode 100644 apps/arblarg/.gitignore
create mode 100644 apps/arblarg/README.md
create mode 100644 apps/arblarg/lib/arblarg.ex
create mode 100644 apps/arblarg/lib/arblarg/application.ex
create mode 100644 apps/arblarg/lib/arblarg_web.ex
create mode 100644 apps/arblarg/lib/arblarg_web/components/chat/call.ex
create mode 100644 apps/arblarg/lib/arblarg_web/components/chat/sender_badge.ex
create mode 100644 apps/arblarg/lib/arblarg_web/components/platform/e_nav.ex
create mode 100644 apps/arblarg/lib/arblarg_web/components/social/content_journey.ex
create mode 100644 apps/arblarg/lib/arblarg_web/components/social/embedded_post.ex
create mode 100644 apps/arblarg/lib/arblarg_web/components/ui/image_modal.ex
create mode 100644 apps/arblarg/lib/arblarg_web/controllers/admin/chat_messages_controller.ex
create mode 100644 apps/arblarg/lib/arblarg_web/controllers/admin/chat_messages_html.ex
create mode 100644 apps/arblarg/lib/arblarg_web/controllers/admin/chat_messages_html/chat_messages.html.heex
create mode 100644 apps/arblarg/lib/arblarg_web/controllers/admin/chat_messages_html/view_chat_message.html.heex
create mode 100644 apps/arblarg/lib/arblarg_web/controllers/api/conversation_controller.ex
create mode 100644 apps/arblarg/lib/arblarg_web/controllers/api/ext_chat_controller.ex
create mode 100644 apps/arblarg/lib/arblarg_web/controllers/api/message_controller.ex
create mode 100644 apps/arblarg/lib/arblarg_web/controllers/api/server_controller.ex
create mode 100644 apps/arblarg/lib/arblarg_web/controllers/api/webhook_controller.ex
create mode 100644 apps/arblarg/lib/arblarg_web/live/chat_live/bootstrap.ex
create mode 100644 apps/arblarg/lib/arblarg_web/live/chat_live/components/channel_modal.ex
create mode 100644 apps/arblarg/lib/arblarg_web/live/chat_live/components/group_modal.ex
create mode 100644 apps/arblarg/lib/arblarg_web/live/chat_live/handle_formatter.ex
create mode 100644 apps/arblarg/lib/arblarg_web/live/chat_live/index.ex
create mode 100644 apps/arblarg/lib/arblarg_web/live/chat_live/index.html.heex
create mode 100644 apps/arblarg/lib/arblarg_web/live/chat_live/operations/call_info_operations.ex
create mode 100644 apps/arblarg/lib/arblarg_web/live/chat_live/operations/call_operations.ex
create mode 100644 apps/arblarg/lib/arblarg_web/live/chat_live/operations/context_menu_operations.ex
create mode 100644 apps/arblarg/lib/arblarg_web/live/chat_live/operations/conversation_operations.ex
create mode 100644 apps/arblarg/lib/arblarg_web/live/chat_live/operations/direct_message_operations.ex
create mode 100644 apps/arblarg/lib/arblarg_web/live/chat_live/operations/emoji_gif_operations.ex
create mode 100644 apps/arblarg/lib/arblarg_web/live/chat_live/operations/group_channel_operations.ex
create mode 100644 apps/arblarg/lib/arblarg_web/live/chat_live/operations/helpers.ex
create mode 100644 apps/arblarg/lib/arblarg_web/live/chat_live/operations/member_operations.ex
create mode 100644 apps/arblarg/lib/arblarg_web/live/chat_live/operations/message_info_operations.ex
create mode 100644 apps/arblarg/lib/arblarg_web/live/chat_live/operations/message_operations.ex
create mode 100644 apps/arblarg/lib/arblarg_web/live/chat_live/operations/thread_operations.ex
create mode 100644 apps/arblarg/lib/arblarg_web/live/chat_live/operations/ui_operations.ex
create mode 100644 apps/arblarg/lib/arblarg_web/live/chat_live/operations/voice_channel_operations.ex
create mode 100644 apps/arblarg/lib/arblarg_web/live/chat_live/router.ex
create mode 100644 apps/arblarg/lib/arblarg_web/live/chat_live/state.ex
create mode 100644 apps/arblarg/lib/arblarg_web/optional_module.ex
create mode 100644 apps/arblarg/mix.exs
create mode 100644 apps/arblarg/test/arblarg_test.exs
create mode 100644 apps/arblarg/test/arblarg_web/live/chat_live/operations/emoji_gif_operations_test.exs
create mode 100644 apps/arblarg/test/test_helper.exs
create mode 100644 apps/atomine/lib/atomine.ex
create mode 100644 apps/atomine/lib/atomine/attestation.ex
create mode 100644 apps/atomine/lib/atomine/attestations.ex
create mode 100644 apps/atomine/lib/atomine/credit_account.ex
create mode 100644 apps/atomine/lib/atomine/credit_earning_policy.ex
create mode 100644 apps/atomine/lib/atomine/credit_ledger_entry.ex
create mode 100644 apps/atomine/lib/atomine/credit_policy.ex
create mode 100644 apps/atomine/lib/atomine/credit_spend.ex
create mode 100644 apps/atomine/lib/atomine/credits.ex
create mode 100644 apps/atomine/lib/atomine/live_proof_recheck_worker.ex
create mode 100644 apps/atomine/lib/atomine/personhood.ex
create mode 100644 apps/atomine/lib/atomine/proof.ex
create mode 100644 apps/atomine/lib/atomine/scoring.ex
create mode 100644 apps/atomine/lib/atomine/trust_session.ex
create mode 100644 apps/atomine/mix.exs
create mode 100644 apps/atomine/test/atomine/attestations_test.exs
create mode 100644 apps/atomine/test/atomine/credits_test.exs
create mode 100644 apps/atomine/test/atomine/personhood_test.exs
create mode 100644 apps/atomine/test/test_helper.exs
create mode 100644 apps/elektrine/.gitignore
create mode 100644 apps/elektrine/README.md
create mode 100644 apps/elektrine/assets/css/animations.css
create mode 100644 apps/elektrine/assets/css/app.css
create mode 100644 apps/elektrine/assets/css/base.css
create mode 100644 apps/elektrine/assets/css/components.css
create mode 100644 apps/elektrine/assets/css/effects.css
create mode 100644 apps/elektrine/assets/css/email.css
create mode 100644 apps/elektrine/assets/css/utilities.css
create mode 100644 apps/elektrine/assets/js/admin_security.js
create mode 100644 apps/elektrine/assets/js/app.js
create mode 100644 apps/elektrine/assets/js/blinkenlights.js
create mode 100644 apps/elektrine/assets/js/cursor_glow.js
create mode 100644 apps/elektrine/assets/js/e_nav.js
create mode 100644 apps/elektrine/assets/js/email_raw.js
create mode 100644 apps/elektrine/assets/js/error_page.js
create mode 100644 apps/elektrine/assets/js/form_helpers.js
create mode 100644 apps/elektrine/assets/js/hashtag_links.js
create mode 100644 apps/elektrine/assets/js/hooks/analytics_hooks.js
create mode 100644 apps/elektrine/assets/js/hooks/backup_codes_printer.js
create mode 100644 apps/elektrine/assets/js/hooks/call_hooks.js
create mode 100644 apps/elektrine/assets/js/hooks/chat_context_menu_hooks.js
create mode 100644 apps/elektrine/assets/js/hooks/chat_e2ee_crypto.js
create mode 100644 apps/elektrine/assets/js/hooks/chat_e2ee_hook.js
create mode 100644 apps/elektrine/assets/js/hooks/chat_e2ee_messages.js
create mode 100644 apps/elektrine/assets/js/hooks/chat_hooks.js
create mode 100644 apps/elektrine/assets/js/hooks/chat_voice_recorder_hook.js
create mode 100644 apps/elektrine/assets/js/hooks/email_compose_shortcuts_hook.js
create mode 100644 apps/elektrine/assets/js/hooks/email_hooks.js
create mode 100644 apps/elektrine/assets/js/hooks/email_iframe_resize_hook.js
create mode 100644 apps/elektrine/assets/js/hooks/email_shortcut_helpers.js
create mode 100644 apps/elektrine/assets/js/hooks/file_explorer_hook.js
create mode 100644 apps/elektrine/assets/js/hooks/form_hooks.js
create mode 100644 apps/elektrine/assets/js/hooks/index.js
create mode 100644 apps/elektrine/assets/js/hooks/kairo_graph_hook.js
create mode 100644 apps/elektrine/assets/js/hooks/kairo_hooks.js
create mode 100644 apps/elektrine/assets/js/hooks/mailbox_private_auth_forms.js
create mode 100644 apps/elektrine/assets/js/hooks/mailbox_private_auth_payloads.js
create mode 100644 apps/elektrine/assets/js/hooks/mailbox_private_compose_hook.js
create mode 100644 apps/elektrine/assets/js/hooks/mailbox_private_content.js
create mode 100644 apps/elektrine/assets/js/hooks/mailbox_private_crypto.js
create mode 100644 apps/elektrine/assets/js/hooks/mailbox_private_messages_hook.js
create mode 100644 apps/elektrine/assets/js/hooks/mailbox_private_storage_hooks.js
create mode 100644 apps/elektrine/assets/js/hooks/markdown_hooks.js
create mode 100644 apps/elektrine/assets/js/hooks/nerve_hooks.js
create mode 100644 apps/elektrine/assets/js/hooks/notification_hooks.js
create mode 100644 apps/elektrine/assets/js/hooks/notification_visibility.js
create mode 100644 apps/elektrine/assets/js/hooks/paige_search_hook.js
create mode 100644 apps/elektrine/assets/js/hooks/passkey_hooks.js
create mode 100644 apps/elektrine/assets/js/hooks/portal_dropdowns.js
create mode 100644 apps/elektrine/assets/js/hooks/presence_hooks.js
create mode 100644 apps/elektrine/assets/js/hooks/profile_hooks.js
create mode 100644 apps/elektrine/assets/js/hooks/profile_link_reorder_hook.js
create mode 100644 apps/elektrine/assets/js/hooks/proof_graph_dom.js
create mode 100644 apps/elektrine/assets/js/hooks/proof_graph_hook.js
create mode 100644 apps/elektrine/assets/js/hooks/proof_graph_paints.js
create mode 100644 apps/elektrine/assets/js/hooks/proof_graph_styles.js
create mode 100644 apps/elektrine/assets/js/hooks/static_site_hooks.js
create mode 100644 apps/elektrine/assets/js/hooks/timeline_hooks.js
create mode 100644 apps/elektrine/assets/js/hooks/timeline_media_hooks.js
create mode 100644 apps/elektrine/assets/js/hooks/timeline_preservation_hooks.js
create mode 100644 apps/elektrine/assets/js/hooks/timeline_session_continuity.js
create mode 100644 apps/elektrine/assets/js/hooks/timeline_status_hooks.js
create mode 100644 apps/elektrine/assets/js/hooks/ui_hooks.js
create mode 100644 apps/elektrine/assets/js/hooks/vault_crypto.js
create mode 100644 apps/elektrine/assets/js/hooks/vault_hooks.js
create mode 100644 apps/elektrine/assets/js/hooks/vault_session.js
create mode 100644 apps/elektrine/assets/js/hooks/voice_channel_hook.js
create mode 100644 apps/elektrine/assets/js/hooks/web_push_hook.js
create mode 100644 apps/elektrine/assets/js/ip_lookup.js
create mode 100644 apps/elektrine/assets/js/live_clock.js
create mode 100644 apps/elektrine/assets/js/markdown_editor.js
create mode 100644 apps/elektrine/assets/js/markdown_helpers.js
create mode 100644 apps/elektrine/assets/js/markdown_toolbar.js
create mode 100644 apps/elektrine/assets/js/modal_controls.js
create mode 100644 apps/elektrine/assets/js/notification_system.js
create mode 100644 apps/elektrine/assets/js/profile_static.js
create mode 100644 apps/elektrine/assets/js/ringtone_player.js
create mode 100644 apps/elektrine/assets/js/tagline_cycler.js
create mode 100644 apps/elektrine/assets/js/theme.js
create mode 100644 apps/elektrine/assets/js/utils/clipboard.js
create mode 100644 apps/elektrine/assets/js/utils/dropdown.js
create mode 100644 apps/elektrine/assets/js/utils/email_selection.js
create mode 100644 apps/elektrine/assets/js/utils/form_submission.js
create mode 100644 apps/elektrine/assets/js/utils/index.js
create mode 100644 apps/elektrine/assets/js/utils/overlay_portal.js
create mode 100644 apps/elektrine/assets/js/utils/spinner.js
create mode 100644 apps/elektrine/assets/js/utils/user_hover_card_scroll.js
create mode 100644 apps/elektrine/assets/js/webrtc_client.js
create mode 100644 apps/elektrine/assets/package-lock.json
create mode 100644 apps/elektrine/assets/package.json
create mode 100644 apps/elektrine/assets/scripts/check_js.mjs
create mode 100644 apps/elektrine/assets/tailwind.config.mjs
create mode 100644 apps/elektrine/assets/vendor/heroicons/LICENSE
create mode 100644 apps/elektrine/assets/vendor/heroicons/SOURCE.md
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/academic-cap.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/adjustments-horizontal.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/adjustments-vertical.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/archive-box-arrow-down.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/archive-box-x-mark.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/archive-box.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-down-circle.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-down-left.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-down-on-square-stack.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-down-on-square.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-down-right.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-down-tray.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-down.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-left-circle.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-left-end-on-rectangle.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-left-start-on-rectangle.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-left.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-long-down.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-long-left.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-long-right.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-long-up.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-path-rounded-square.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-path.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-right-circle.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-right-end-on-rectangle.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-right-start-on-rectangle.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-right.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-top-right-on-square.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-trending-down.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-trending-up.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-turn-down-left.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-turn-down-right.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-turn-left-down.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-turn-left-up.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-turn-right-down.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-turn-right-up.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-turn-up-left.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-turn-up-right.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-up-circle.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-up-left.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-up-on-square-stack.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-up-on-square.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-up-right.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-up-tray.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-up.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-uturn-down.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-uturn-left.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-uturn-right.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-uturn-up.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrows-pointing-in.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrows-pointing-out.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrows-right-left.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrows-up-down.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/at-symbol.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/backspace.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/backward.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/banknotes.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/bars-2.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/bars-3-bottom-left.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/bars-3-bottom-right.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/bars-3-center-left.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/bars-3.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/bars-4.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/bars-arrow-down.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/bars-arrow-up.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/battery-0.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/battery-100.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/battery-50.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/beaker.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/bell-alert.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/bell-slash.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/bell-snooze.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/bell.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/bold.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/bolt-slash.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/bolt.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/book-open.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/bookmark-slash.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/bookmark-square.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/bookmark.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/briefcase.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/bug-ant.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/building-library.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/building-office-2.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/building-office.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/building-storefront.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/cake.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/calculator.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/calendar-date-range.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/calendar-days.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/calendar.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/camera.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/chart-bar-square.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/chart-bar.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/chart-pie.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/chat-bubble-bottom-center-text.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/chat-bubble-bottom-center.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/chat-bubble-left-ellipsis.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/chat-bubble-left-right.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/chat-bubble-left.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/chat-bubble-oval-left-ellipsis.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/chat-bubble-oval-left.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/check-badge.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/check-circle.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/check.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/chevron-double-down.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/chevron-double-left.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/chevron-double-right.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/chevron-double-up.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/chevron-down.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/chevron-left.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/chevron-right.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/chevron-up-down.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/chevron-up.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/circle-stack.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/clipboard-document-check.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/clipboard-document-list.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/clipboard-document.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/clipboard.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/clock.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/cloud-arrow-down.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/cloud-arrow-up.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/cloud.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/code-bracket-square.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/code-bracket.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/cog-6-tooth.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/cog-8-tooth.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/cog.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/command-line.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/computer-desktop.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/cpu-chip.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/credit-card.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/cube-transparent.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/cube.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/currency-bangladeshi.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/currency-dollar.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/currency-euro.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/currency-pound.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/currency-rupee.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/currency-yen.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/cursor-arrow-rays.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/cursor-arrow-ripple.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/device-phone-mobile.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/device-tablet.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/divide.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/document-arrow-down.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/document-arrow-up.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/document-chart-bar.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/document-check.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/document-currency-bangladeshi.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/document-currency-dollar.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/document-currency-euro.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/document-currency-pound.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/document-currency-rupee.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/document-currency-yen.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/document-duplicate.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/document-magnifying-glass.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/document-minus.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/document-plus.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/document-text.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/document.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/ellipsis-horizontal-circle.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/ellipsis-horizontal.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/ellipsis-vertical.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/envelope-open.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/envelope.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/equals.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/exclamation-circle.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/exclamation-triangle.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/eye-dropper.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/eye-slash.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/eye.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/face-frown.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/face-smile.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/film.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/finger-print.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/fire.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/flag.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/folder-arrow-down.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/folder-minus.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/folder-open.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/folder-plus.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/folder.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/forward.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/funnel.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/gif.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/gift-top.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/gift.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/globe-alt.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/globe-americas.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/globe-asia-australia.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/globe-europe-africa.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/h1.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/h2.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/h3.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/hand-raised.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/hand-thumb-down.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/hand-thumb-up.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/hashtag.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/heart.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/home-modern.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/home.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/identification.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/inbox-arrow-down.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/inbox-stack.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/inbox.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/information-circle.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/italic.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/key.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/language.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/lifebuoy.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/light-bulb.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/link-slash.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/link.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/list-bullet.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/lock-closed.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/lock-open.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/magnifying-glass-circle.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/magnifying-glass-minus.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/magnifying-glass-plus.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/magnifying-glass.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/map-pin.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/map.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/megaphone.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/microphone.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/minus-circle.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/minus.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/moon.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/musical-note.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/newspaper.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/no-symbol.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/numbered-list.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/paint-brush.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/paper-airplane.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/paper-clip.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/pause-circle.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/pause.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/pencil-square.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/pencil.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/percent-badge.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/phone-arrow-down-left.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/phone-arrow-up-right.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/phone-x-mark.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/phone.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/photo.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/play-circle.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/play-pause.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/play.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/plus-circle.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/plus.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/power.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/presentation-chart-bar.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/presentation-chart-line.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/printer.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/puzzle-piece.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/qr-code.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/question-mark-circle.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/queue-list.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/radio.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/receipt-percent.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/receipt-refund.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/rectangle-group.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/rectangle-stack.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/rocket-launch.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/rss.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/scale.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/scissors.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/server-stack.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/server.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/share.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/shield-check.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/shield-exclamation.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/shopping-bag.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/shopping-cart.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/signal-slash.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/signal.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/slash.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/sparkles.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/speaker-wave.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/speaker-x-mark.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/square-2-stack.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/square-3-stack-3d.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/squares-2x2.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/squares-plus.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/star.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/stop-circle.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/stop.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/strikethrough.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/sun.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/swatch.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/table-cells.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/tag.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/ticket.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/trash.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/trophy.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/truck.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/tv.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/underline.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/user-circle.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/user-group.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/user-minus.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/user-plus.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/user.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/users.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/variable.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/video-camera-slash.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/video-camera.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/view-columns.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/viewfinder-circle.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/wallet.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/wifi.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/window.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/wrench-screwdriver.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/wrench.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/x-circle.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/16/solid/x-mark.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/academic-cap.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/adjustments-horizontal.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/adjustments-vertical.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/archive-box-arrow-down.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/archive-box-x-mark.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/archive-box.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-down-circle.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-down-left.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-down-on-square-stack.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-down-on-square.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-down-right.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-down-tray.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-down.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-left-circle.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-left-end-on-rectangle.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-left-on-rectangle.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-left-start-on-rectangle.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-left.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-long-down.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-long-left.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-long-right.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-long-up.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-path-rounded-square.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-path.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-right-circle.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-right-end-on-rectangle.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-right-on-rectangle.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-right-start-on-rectangle.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-right.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-small-down.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-small-left.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-small-right.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-small-up.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-top-right-on-square.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-trending-down.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-trending-up.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-turn-down-left.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-turn-down-right.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-turn-left-down.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-turn-left-up.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-turn-right-down.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-turn-right-up.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-turn-up-left.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-turn-up-right.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-up-circle.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-up-left.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-up-on-square-stack.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-up-on-square.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-up-right.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-up-tray.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-up.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-uturn-down.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-uturn-left.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-uturn-right.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-uturn-up.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrows-pointing-in.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrows-pointing-out.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrows-right-left.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrows-up-down.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/at-symbol.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/backspace.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/backward.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/banknotes.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/bars-2.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/bars-3-bottom-left.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/bars-3-bottom-right.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/bars-3-center-left.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/bars-3.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/bars-4.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/bars-arrow-down.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/bars-arrow-up.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/battery-0.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/battery-100.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/battery-50.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/beaker.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/bell-alert.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/bell-slash.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/bell-snooze.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/bell.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/bold.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/bolt-slash.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/bolt.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/book-open.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/bookmark-slash.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/bookmark-square.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/bookmark.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/briefcase.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/bug-ant.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/building-library.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/building-office-2.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/building-office.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/building-storefront.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/cake.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/calculator.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/calendar-date-range.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/calendar-days.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/calendar.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/camera.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/chart-bar-square.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/chart-bar.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/chart-pie.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/chat-bubble-bottom-center-text.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/chat-bubble-bottom-center.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/chat-bubble-left-ellipsis.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/chat-bubble-left-right.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/chat-bubble-left.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/chat-bubble-oval-left-ellipsis.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/chat-bubble-oval-left.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/check-badge.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/check-circle.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/check.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/chevron-double-down.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/chevron-double-left.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/chevron-double-right.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/chevron-double-up.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/chevron-down.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/chevron-left.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/chevron-right.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/chevron-up-down.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/chevron-up.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/circle-stack.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/clipboard-document-check.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/clipboard-document-list.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/clipboard-document.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/clipboard.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/clock.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/cloud-arrow-down.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/cloud-arrow-up.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/cloud.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/code-bracket-square.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/code-bracket.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/cog-6-tooth.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/cog-8-tooth.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/cog.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/command-line.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/computer-desktop.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/cpu-chip.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/credit-card.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/cube-transparent.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/cube.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/currency-bangladeshi.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/currency-dollar.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/currency-euro.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/currency-pound.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/currency-rupee.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/currency-yen.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/cursor-arrow-rays.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/cursor-arrow-ripple.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/device-phone-mobile.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/device-tablet.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/divide.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/document-arrow-down.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/document-arrow-up.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/document-chart-bar.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/document-check.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/document-currency-bangladeshi.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/document-currency-dollar.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/document-currency-euro.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/document-currency-pound.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/document-currency-rupee.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/document-currency-yen.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/document-duplicate.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/document-magnifying-glass.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/document-minus.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/document-plus.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/document-text.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/document.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/ellipsis-horizontal-circle.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/ellipsis-horizontal.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/ellipsis-vertical.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/envelope-open.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/envelope.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/equals.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/exclamation-circle.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/exclamation-triangle.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/eye-dropper.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/eye-slash.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/eye.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/face-frown.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/face-smile.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/film.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/finger-print.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/fire.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/flag.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/folder-arrow-down.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/folder-minus.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/folder-open.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/folder-plus.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/folder.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/forward.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/funnel.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/gif.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/gift-top.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/gift.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/globe-alt.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/globe-americas.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/globe-asia-australia.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/globe-europe-africa.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/h1.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/h2.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/h3.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/hand-raised.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/hand-thumb-down.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/hand-thumb-up.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/hashtag.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/heart.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/home-modern.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/home.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/identification.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/inbox-arrow-down.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/inbox-stack.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/inbox.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/information-circle.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/italic.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/key.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/language.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/lifebuoy.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/light-bulb.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/link-slash.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/link.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/list-bullet.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/lock-closed.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/lock-open.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/magnifying-glass-circle.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/magnifying-glass-minus.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/magnifying-glass-plus.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/magnifying-glass.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/map-pin.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/map.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/megaphone.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/microphone.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/minus-circle.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/minus-small.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/minus.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/moon.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/musical-note.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/newspaper.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/no-symbol.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/numbered-list.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/paint-brush.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/paper-airplane.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/paper-clip.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/pause-circle.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/pause.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/pencil-square.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/pencil.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/percent-badge.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/phone-arrow-down-left.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/phone-arrow-up-right.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/phone-x-mark.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/phone.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/photo.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/play-circle.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/play-pause.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/play.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/plus-circle.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/plus-small.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/plus.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/power.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/presentation-chart-bar.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/presentation-chart-line.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/printer.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/puzzle-piece.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/qr-code.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/question-mark-circle.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/queue-list.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/radio.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/receipt-percent.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/receipt-refund.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/rectangle-group.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/rectangle-stack.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/rocket-launch.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/rss.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/scale.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/scissors.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/server-stack.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/server.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/share.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/shield-check.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/shield-exclamation.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/shopping-bag.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/shopping-cart.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/signal-slash.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/signal.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/slash.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/sparkles.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/speaker-wave.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/speaker-x-mark.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/square-2-stack.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/square-3-stack-3d.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/squares-2x2.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/squares-plus.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/star.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/stop-circle.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/stop.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/strikethrough.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/sun.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/swatch.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/table-cells.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/tag.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/ticket.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/trash.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/trophy.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/truck.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/tv.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/underline.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/user-circle.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/user-group.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/user-minus.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/user-plus.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/user.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/users.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/variable.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/video-camera-slash.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/video-camera.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/view-columns.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/viewfinder-circle.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/wallet.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/wifi.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/window.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/wrench-screwdriver.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/wrench.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/x-circle.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/20/solid/x-mark.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/academic-cap.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/adjustments-horizontal.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/adjustments-vertical.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/archive-box-arrow-down.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/archive-box-x-mark.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/archive-box.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-down-circle.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-down-left.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-down-on-square-stack.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-down-on-square.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-down-right.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-down-tray.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-down.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-left-circle.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-left-end-on-rectangle.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-left-on-rectangle.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-left-start-on-rectangle.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-left.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-long-down.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-long-left.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-long-right.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-long-up.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-path-rounded-square.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-path.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-right-circle.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-right-end-on-rectangle.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-right-on-rectangle.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-right-start-on-rectangle.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-right.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-small-down.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-small-left.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-small-right.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-small-up.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-top-right-on-square.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-trending-down.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-trending-up.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-turn-down-left.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-turn-down-right.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-turn-left-down.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-turn-left-up.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-turn-right-down.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-turn-right-up.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-turn-up-left.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-turn-up-right.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-up-circle.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-up-left.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-up-on-square-stack.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-up-on-square.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-up-right.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-up-tray.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-up.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-uturn-down.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-uturn-left.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-uturn-right.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-uturn-up.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrows-pointing-in.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrows-pointing-out.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrows-right-left.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrows-up-down.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/at-symbol.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/backspace.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/backward.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/banknotes.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/bars-2.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/bars-3-bottom-left.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/bars-3-bottom-right.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/bars-3-center-left.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/bars-3.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/bars-4.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/bars-arrow-down.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/bars-arrow-up.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/battery-0.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/battery-100.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/battery-50.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/beaker.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/bell-alert.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/bell-slash.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/bell-snooze.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/bell.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/bold.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/bolt-slash.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/bolt.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/book-open.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/bookmark-slash.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/bookmark-square.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/bookmark.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/briefcase.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/bug-ant.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/building-library.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/building-office-2.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/building-office.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/building-storefront.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/cake.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/calculator.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/calendar-date-range.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/calendar-days.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/calendar.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/camera.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/chart-bar-square.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/chart-bar.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/chart-pie.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/chat-bubble-bottom-center-text.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/chat-bubble-bottom-center.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/chat-bubble-left-ellipsis.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/chat-bubble-left-right.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/chat-bubble-left.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/chat-bubble-oval-left-ellipsis.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/chat-bubble-oval-left.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/check-badge.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/check-circle.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/check.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/chevron-double-down.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/chevron-double-left.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/chevron-double-right.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/chevron-double-up.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/chevron-down.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/chevron-left.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/chevron-right.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/chevron-up-down.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/chevron-up.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/circle-stack.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/clipboard-document-check.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/clipboard-document-list.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/clipboard-document.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/clipboard.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/clock.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/cloud-arrow-down.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/cloud-arrow-up.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/cloud.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/code-bracket-square.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/code-bracket.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/cog-6-tooth.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/cog-8-tooth.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/cog.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/command-line.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/computer-desktop.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/cpu-chip.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/credit-card.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/cube-transparent.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/cube.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/currency-bangladeshi.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/currency-dollar.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/currency-euro.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/currency-pound.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/currency-rupee.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/currency-yen.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/cursor-arrow-rays.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/cursor-arrow-ripple.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/device-phone-mobile.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/device-tablet.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/divide.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/document-arrow-down.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/document-arrow-up.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/document-chart-bar.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/document-check.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/document-currency-bangladeshi.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/document-currency-dollar.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/document-currency-euro.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/document-currency-pound.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/document-currency-rupee.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/document-currency-yen.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/document-duplicate.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/document-magnifying-glass.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/document-minus.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/document-plus.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/document-text.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/document.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/ellipsis-horizontal-circle.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/ellipsis-horizontal.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/ellipsis-vertical.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/envelope-open.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/envelope.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/equals.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/exclamation-circle.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/exclamation-triangle.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/eye-dropper.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/eye-slash.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/eye.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/face-frown.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/face-smile.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/film.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/finger-print.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/fire.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/flag.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/folder-arrow-down.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/folder-minus.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/folder-open.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/folder-plus.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/folder.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/forward.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/funnel.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/gif.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/gift-top.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/gift.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/globe-alt.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/globe-americas.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/globe-asia-australia.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/globe-europe-africa.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/h1.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/h2.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/h3.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/hand-raised.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/hand-thumb-down.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/hand-thumb-up.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/hashtag.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/heart.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/home-modern.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/home.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/identification.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/inbox-arrow-down.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/inbox-stack.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/inbox.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/information-circle.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/italic.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/key.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/language.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/lifebuoy.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/light-bulb.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/link-slash.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/link.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/list-bullet.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/lock-closed.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/lock-open.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/magnifying-glass-circle.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/magnifying-glass-minus.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/magnifying-glass-plus.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/magnifying-glass.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/map-pin.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/map.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/megaphone.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/microphone.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/minus-circle.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/minus-small.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/minus.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/moon.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/musical-note.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/newspaper.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/no-symbol.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/numbered-list.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/paint-brush.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/paper-airplane.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/paper-clip.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/pause-circle.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/pause.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/pencil-square.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/pencil.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/percent-badge.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/phone-arrow-down-left.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/phone-arrow-up-right.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/phone-x-mark.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/phone.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/photo.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/play-circle.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/play-pause.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/play.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/plus-circle.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/plus-small.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/plus.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/power.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/presentation-chart-bar.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/presentation-chart-line.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/printer.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/puzzle-piece.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/qr-code.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/question-mark-circle.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/queue-list.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/radio.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/receipt-percent.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/receipt-refund.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/rectangle-group.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/rectangle-stack.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/rocket-launch.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/rss.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/scale.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/scissors.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/server-stack.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/server.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/share.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/shield-check.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/shield-exclamation.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/shopping-bag.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/shopping-cart.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/signal-slash.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/signal.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/slash.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/sparkles.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/speaker-wave.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/speaker-x-mark.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/square-2-stack.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/square-3-stack-3d.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/squares-2x2.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/squares-plus.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/star.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/stop-circle.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/stop.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/strikethrough.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/sun.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/swatch.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/table-cells.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/tag.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/ticket.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/trash.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/trophy.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/truck.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/tv.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/underline.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/user-circle.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/user-group.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/user-minus.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/user-plus.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/user.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/users.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/variable.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/video-camera-slash.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/video-camera.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/view-columns.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/viewfinder-circle.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/wallet.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/wifi.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/window.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/wrench-screwdriver.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/wrench.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/x-circle.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/outline/x-mark.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/academic-cap.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/adjustments-horizontal.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/adjustments-vertical.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/archive-box-arrow-down.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/archive-box-x-mark.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/archive-box.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-down-circle.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-down-left.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-down-on-square-stack.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-down-on-square.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-down-right.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-down-tray.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-down.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-left-circle.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-left-end-on-rectangle.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-left-on-rectangle.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-left-start-on-rectangle.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-left.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-long-down.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-long-left.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-long-right.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-long-up.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-path-rounded-square.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-path.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-right-circle.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-right-end-on-rectangle.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-right-on-rectangle.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-right-start-on-rectangle.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-right.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-small-down.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-small-left.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-small-right.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-small-up.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-top-right-on-square.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-trending-down.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-trending-up.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-turn-down-left.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-turn-down-right.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-turn-left-down.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-turn-left-up.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-turn-right-down.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-turn-right-up.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-turn-up-left.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-turn-up-right.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-up-circle.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-up-left.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-up-on-square-stack.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-up-on-square.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-up-right.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-up-tray.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-up.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-uturn-down.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-uturn-left.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-uturn-right.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-uturn-up.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrows-pointing-in.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrows-pointing-out.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrows-right-left.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrows-up-down.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/at-symbol.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/backspace.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/backward.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/banknotes.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/bars-2.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/bars-3-bottom-left.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/bars-3-bottom-right.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/bars-3-center-left.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/bars-3.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/bars-4.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/bars-arrow-down.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/bars-arrow-up.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/battery-0.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/battery-100.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/battery-50.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/beaker.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/bell-alert.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/bell-slash.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/bell-snooze.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/bell.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/bold.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/bolt-slash.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/bolt.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/book-open.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/bookmark-slash.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/bookmark-square.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/bookmark.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/briefcase.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/bug-ant.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/building-library.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/building-office-2.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/building-office.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/building-storefront.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/cake.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/calculator.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/calendar-date-range.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/calendar-days.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/calendar.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/camera.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/chart-bar-square.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/chart-bar.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/chart-pie.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/chat-bubble-bottom-center-text.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/chat-bubble-bottom-center.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/chat-bubble-left-ellipsis.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/chat-bubble-left-right.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/chat-bubble-left.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/chat-bubble-oval-left-ellipsis.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/chat-bubble-oval-left.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/check-badge.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/check-circle.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/check.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/chevron-double-down.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/chevron-double-left.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/chevron-double-right.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/chevron-double-up.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/chevron-down.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/chevron-left.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/chevron-right.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/chevron-up-down.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/chevron-up.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/circle-stack.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/clipboard-document-check.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/clipboard-document-list.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/clipboard-document.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/clipboard.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/clock.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/cloud-arrow-down.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/cloud-arrow-up.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/cloud.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/code-bracket-square.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/code-bracket.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/cog-6-tooth.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/cog-8-tooth.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/cog.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/command-line.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/computer-desktop.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/cpu-chip.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/credit-card.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/cube-transparent.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/cube.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/currency-bangladeshi.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/currency-dollar.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/currency-euro.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/currency-pound.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/currency-rupee.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/currency-yen.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/cursor-arrow-rays.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/cursor-arrow-ripple.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/device-phone-mobile.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/device-tablet.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/divide.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/document-arrow-down.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/document-arrow-up.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/document-chart-bar.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/document-check.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/document-currency-bangladeshi.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/document-currency-dollar.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/document-currency-euro.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/document-currency-pound.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/document-currency-rupee.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/document-currency-yen.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/document-duplicate.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/document-magnifying-glass.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/document-minus.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/document-plus.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/document-text.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/document.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/ellipsis-horizontal-circle.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/ellipsis-horizontal.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/ellipsis-vertical.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/envelope-open.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/envelope.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/equals.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/exclamation-circle.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/exclamation-triangle.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/eye-dropper.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/eye-slash.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/eye.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/face-frown.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/face-smile.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/film.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/finger-print.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/fire.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/flag.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/folder-arrow-down.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/folder-minus.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/folder-open.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/folder-plus.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/folder.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/forward.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/funnel.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/gif.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/gift-top.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/gift.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/globe-alt.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/globe-americas.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/globe-asia-australia.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/globe-europe-africa.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/h1.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/h2.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/h3.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/hand-raised.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/hand-thumb-down.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/hand-thumb-up.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/hashtag.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/heart.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/home-modern.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/home.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/identification.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/inbox-arrow-down.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/inbox-stack.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/inbox.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/information-circle.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/italic.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/key.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/language.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/lifebuoy.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/light-bulb.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/link-slash.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/link.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/list-bullet.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/lock-closed.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/lock-open.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/magnifying-glass-circle.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/magnifying-glass-minus.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/magnifying-glass-plus.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/magnifying-glass.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/map-pin.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/map.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/megaphone.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/microphone.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/minus-circle.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/minus-small.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/minus.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/moon.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/musical-note.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/newspaper.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/no-symbol.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/numbered-list.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/paint-brush.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/paper-airplane.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/paper-clip.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/pause-circle.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/pause.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/pencil-square.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/pencil.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/percent-badge.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/phone-arrow-down-left.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/phone-arrow-up-right.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/phone-x-mark.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/phone.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/photo.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/play-circle.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/play-pause.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/play.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/plus-circle.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/plus-small.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/plus.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/power.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/presentation-chart-bar.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/presentation-chart-line.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/printer.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/puzzle-piece.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/qr-code.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/question-mark-circle.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/queue-list.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/radio.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/receipt-percent.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/receipt-refund.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/rectangle-group.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/rectangle-stack.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/rocket-launch.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/rss.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/scale.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/scissors.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/server-stack.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/server.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/share.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/shield-check.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/shield-exclamation.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/shopping-bag.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/shopping-cart.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/signal-slash.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/signal.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/slash.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/sparkles.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/speaker-wave.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/speaker-x-mark.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/square-2-stack.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/square-3-stack-3d.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/squares-2x2.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/squares-plus.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/star.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/stop-circle.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/stop.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/strikethrough.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/sun.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/swatch.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/table-cells.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/tag.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/ticket.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/trash.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/trophy.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/truck.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/tv.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/underline.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/user-circle.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/user-group.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/user-minus.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/user-plus.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/user.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/users.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/variable.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/video-camera-slash.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/video-camera.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/view-columns.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/viewfinder-circle.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/wallet.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/wifi.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/window.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/wrench-screwdriver.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/wrench.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/x-circle.svg
create mode 100644 apps/elektrine/assets/vendor/heroicons/optimized/24/solid/x-mark.svg
create mode 100644 apps/elektrine/assets/vendor/topbar.js
create mode 100644 apps/elektrine/lib/elektrine.ex
create mode 100644 apps/elektrine/lib/elektrine/account_identifiers.ex
create mode 100644 apps/elektrine/lib/elektrine/accounts.ex
create mode 100644 apps/elektrine/lib/elektrine/accounts/account_deletion_request.ex
create mode 100644 apps/elektrine/lib/elektrine/accounts/account_endorsement.ex
create mode 100644 apps/elektrine/lib/elektrine/accounts/account_import_worker.ex
create mode 100644 apps/elektrine/lib/elektrine/accounts/account_note.ex
create mode 100644 apps/elektrine/lib/elektrine/accounts/account_notes.ex
create mode 100644 apps/elektrine/lib/elektrine/accounts/account_subscription.ex
create mode 100644 apps/elektrine/lib/elektrine/accounts/app_password.ex
create mode 100644 apps/elektrine/lib/elektrine/accounts/authentication.ex
create mode 100644 apps/elektrine/lib/elektrine/accounts/blocked_users_cache.ex
create mode 100644 apps/elektrine/lib/elektrine/accounts/blocking.ex
create mode 100644 apps/elektrine/lib/elektrine/accounts/built_in_subdomain.ex
create mode 100644 apps/elektrine/lib/elektrine/accounts/cached.ex
create mode 100644 apps/elektrine/lib/elektrine/accounts/capabilities.ex
create mode 100644 apps/elektrine/lib/elektrine/accounts/client_app_setting.ex
create mode 100644 apps/elektrine/lib/elektrine/accounts/client_app_settings.ex
create mode 100644 apps/elektrine/lib/elektrine/accounts/connected_account.ex
create mode 100644 apps/elektrine/lib/elektrine/accounts/endorsements.ex
create mode 100644 apps/elektrine/lib/elektrine/accounts/invite_code.ex
create mode 100644 apps/elektrine/lib/elektrine/accounts/invite_code_use.ex
create mode 100644 apps/elektrine/lib/elektrine/accounts/moderation.ex
create mode 100644 apps/elektrine/lib/elektrine/accounts/mute_expire_worker.ex
create mode 100644 apps/elektrine/lib/elektrine/accounts/muting.ex
create mode 100644 apps/elektrine/lib/elektrine/accounts/passkey_credential.ex
create mode 100644 apps/elektrine/lib/elektrine/accounts/passkeys.ex
create mode 100644 apps/elektrine/lib/elektrine/accounts/recovery_email_verification.ex
create mode 100644 apps/elektrine/lib/elektrine/accounts/storage.ex
create mode 100644 apps/elektrine/lib/elektrine/accounts/subscriptions.ex
create mode 100644 apps/elektrine/lib/elektrine/accounts/tracking.ex
create mode 100644 apps/elektrine/lib/elektrine/accounts/trust_level.ex
create mode 100644 apps/elektrine/lib/elektrine/accounts/trust_level_log.ex
create mode 100644 apps/elektrine/lib/elektrine/accounts/trusted_device.ex
create mode 100644 apps/elektrine/lib/elektrine/accounts/two_factor.ex
create mode 100644 apps/elektrine/lib/elektrine/accounts/user.ex
create mode 100644 apps/elektrine/lib/elektrine/accounts/user_activity_stats.ex
create mode 100644 apps/elektrine/lib/elektrine/accounts/user_block.ex
create mode 100644 apps/elektrine/lib/elektrine/accounts/user_mute.ex
create mode 100644 apps/elektrine/lib/elektrine/accounts/user_session.ex
create mode 100644 apps/elektrine/lib/elektrine/accounts/username_history.ex
create mode 100644 apps/elektrine/lib/elektrine/acme/wildcard_renewal_worker.ex
create mode 100644 apps/elektrine/lib/elektrine/activitypub.ex
create mode 100644 apps/elektrine/lib/elektrine/activitypub/activity.ex
create mode 100644 apps/elektrine/lib/elektrine/activitypub/activity_delivery_worker.ex
create mode 100644 apps/elektrine/lib/elektrine/activitypub/actor.ex
create mode 100644 apps/elektrine/lib/elektrine/activitypub/actor_refresh_worker.ex
create mode 100644 apps/elektrine/lib/elektrine/activitypub/builder.ex
create mode 100644 apps/elektrine/lib/elektrine/activitypub/collection_count_sync_worker.ex
create mode 100644 apps/elektrine/lib/elektrine/activitypub/collection_fetcher.ex
create mode 100644 apps/elektrine/lib/elektrine/activitypub/community_fetcher.ex
create mode 100644 apps/elektrine/lib/elektrine/activitypub/containment.ex
create mode 100644 apps/elektrine/lib/elektrine/activitypub/delivery.ex
create mode 100644 apps/elektrine/lib/elektrine/activitypub/delivery_cleanup.ex
create mode 100644 apps/elektrine/lib/elektrine/activitypub/delivery_cleanup_worker.ex
create mode 100644 apps/elektrine/lib/elektrine/activitypub/delivery_retry_worker.ex
create mode 100644 apps/elektrine/lib/elektrine/activitypub/domain_delivery_health.ex
create mode 100644 apps/elektrine/lib/elektrine/activitypub/domain_migration.ex
create mode 100644 apps/elektrine/lib/elektrine/activitypub/domain_throttler.ex
create mode 100644 apps/elektrine/lib/elektrine/activitypub/federation_cleanup_worker.ex
create mode 100644 apps/elektrine/lib/elektrine/activitypub/federation_helpers.ex
create mode 100644 apps/elektrine/lib/elektrine/activitypub/federation_load_guard.ex
create mode 100644 apps/elektrine/lib/elektrine/activitypub/fetch_remote_poll_service.ex
create mode 100644 apps/elektrine/lib/elektrine/activitypub/fetch_remote_poll_worker.ex
create mode 100644 apps/elektrine/lib/elektrine/activitypub/fetcher.ex
create mode 100644 apps/elektrine/lib/elektrine/activitypub/group_follow.ex
create mode 100644 apps/elektrine/lib/elektrine/activitypub/handler.ex
create mode 100644 apps/elektrine/lib/elektrine/activitypub/handlers/announce_handler.ex
create mode 100644 apps/elektrine/lib/elektrine/activitypub/handlers/block_handler.ex
create mode 100644 apps/elektrine/lib/elektrine/activitypub/handlers/create_handler.ex
create mode 100644 apps/elektrine/lib/elektrine/activitypub/handlers/delete_handler.ex
create mode 100644 apps/elektrine/lib/elektrine/activitypub/handlers/flag_handler.ex
create mode 100644 apps/elektrine/lib/elektrine/activitypub/handlers/follow_handler.ex
create mode 100644 apps/elektrine/lib/elektrine/activitypub/handlers/like_handler.ex
create mode 100644 apps/elektrine/lib/elektrine/activitypub/handlers/move_handler.ex
create mode 100644 apps/elektrine/lib/elektrine/activitypub/handlers/update_handler.ex
create mode 100644 apps/elektrine/lib/elektrine/activitypub/helpers.ex
create mode 100644 apps/elektrine/lib/elektrine/activitypub/http_signature.ex
create mode 100644 apps/elektrine/lib/elektrine/activitypub/inbound_delivery_policy.ex
create mode 100644 apps/elektrine/lib/elektrine/activitypub/inbox_queue.ex
create mode 100644 apps/elektrine/lib/elektrine/activitypub/inbox_rate_limiter.ex
create mode 100644 apps/elektrine/lib/elektrine/activitypub/incoming_activity_worker.ex
create mode 100644 apps/elektrine/lib/elektrine/activitypub/instance.ex
create mode 100644 apps/elektrine/lib/elektrine/activitypub/instances.ex
create mode 100644 apps/elektrine/lib/elektrine/activitypub/key_manager.ex
create mode 100644 apps/elektrine/lib/elektrine/activitypub/lemmy_api.ex
create mode 100644 apps/elektrine/lib/elektrine/activitypub/lemmy_cache.ex
create mode 100644 apps/elektrine/lib/elektrine/activitypub/lemmy_cache_worker.ex
create mode 100644 apps/elektrine/lib/elektrine/activitypub/lemmy_comment_backfill.ex
create mode 100644 apps/elektrine/lib/elektrine/activitypub/local_references.ex
create mode 100644 apps/elektrine/lib/elektrine/activitypub/mastodon_api.ex
create mode 100644 apps/elektrine/lib/elektrine/activitypub/mentions.ex
create mode 100644 apps/elektrine/lib/elektrine/activitypub/mrf.ex
create mode 100644 apps/elektrine/lib/elektrine/activitypub/mrf/anti_followbot_policy.ex
create mode 100644 apps/elektrine/lib/elektrine/activitypub/mrf/anti_link_spam_policy.ex
create mode 100644 apps/elektrine/lib/elektrine/activitypub/mrf/anti_mention_spam_policy.ex
create mode 100644 apps/elektrine/lib/elektrine/activitypub/mrf/emoji_policy.ex
create mode 100644 apps/elektrine/lib/elektrine/activitypub/mrf/force_bot_unlisted_policy.ex
create mode 100644 apps/elektrine/lib/elektrine/activitypub/mrf/hashtag_policy.ex
create mode 100644 apps/elektrine/lib/elektrine/activitypub/mrf/hellthread_policy.ex
create mode 100644 apps/elektrine/lib/elektrine/activitypub/mrf/inline_quote_policy.ex
create mode 100644 apps/elektrine/lib/elektrine/activitypub/mrf/keyword_policy.ex
create mode 100644 apps/elektrine/lib/elektrine/activitypub/mrf/mention_policy.ex
create mode 100644 apps/elektrine/lib/elektrine/activitypub/mrf/no_empty_policy.ex
create mode 100644 apps/elektrine/lib/elektrine/activitypub/mrf/no_placeholder_text_policy.ex
create mode 100644 apps/elektrine/lib/elektrine/activitypub/mrf/normalize_policy.ex
create mode 100644 apps/elektrine/lib/elektrine/activitypub/mrf/object_age_policy.ex
create mode 100644 apps/elektrine/lib/elektrine/activitypub/mrf/policy.ex
create mode 100644 apps/elektrine/lib/elektrine/activitypub/mrf/quiet_reply_policy.ex
create mode 100644 apps/elektrine/lib/elektrine/activitypub/mrf/quote_to_link_tag_policy.ex
create mode 100644 apps/elektrine/lib/elektrine/activitypub/mrf/reject_non_public_policy.ex
create mode 100644 apps/elektrine/lib/elektrine/activitypub/mrf/remote_report_policy.ex
create mode 100644 apps/elektrine/lib/elektrine/activitypub/mrf/simple_policy.ex
create mode 100644 apps/elektrine/lib/elektrine/activitypub/mrf/user_allow_list_policy.ex
create mode 100644 apps/elektrine/lib/elektrine/activitypub/mrf/utils.ex
create mode 100644 apps/elektrine/lib/elektrine/activitypub/mrf/vocabulary_policy.ex
create mode 100644 apps/elektrine/lib/elektrine/activitypub/nodeinfo.ex
create mode 100644 apps/elektrine/lib/elektrine/activitypub/nodeinfo_fetcher_worker.ex
create mode 100644 apps/elektrine/lib/elektrine/activitypub/normalizer.ex
create mode 100644 apps/elektrine/lib/elektrine/activitypub/object_deliveries.ex
create mode 100644 apps/elektrine/lib/elektrine/activitypub/object_delivery.ex
create mode 100644 apps/elektrine/lib/elektrine/activitypub/object_validator.ex
create mode 100644 apps/elektrine/lib/elektrine/activitypub/outbox.ex
create mode 100644 apps/elektrine/lib/elektrine/activitypub/pipeline.ex
create mode 100644 apps/elektrine/lib/elektrine/activitypub/process_activity_worker.ex
create mode 100644 apps/elektrine/lib/elektrine/activitypub/publisher.ex
create mode 100644 apps/elektrine/lib/elektrine/activitypub/reachability_worker.ex
create mode 100644 apps/elektrine/lib/elektrine/activitypub/refresh_counts_worker.ex
create mode 100644 apps/elektrine/lib/elektrine/activitypub/relay.ex
create mode 100644 apps/elektrine/lib/elektrine/activitypub/relay_subscription.ex
create mode 100644 apps/elektrine/lib/elektrine/activitypub/remote_fetch.ex
create mode 100644 apps/elektrine/lib/elektrine/activitypub/replies_fetcher.ex
create mode 100644 apps/elektrine/lib/elektrine/activitypub/replies_ingest_worker.ex
create mode 100644 apps/elektrine/lib/elektrine/activitypub/reply_fetch_policy.ex
create mode 100644 apps/elektrine/lib/elektrine/activitypub/request_replay.ex
create mode 100644 apps/elektrine/lib/elektrine/activitypub/request_replay_cache.ex
create mode 100644 apps/elektrine/lib/elektrine/activitypub/side_effects.ex
create mode 100644 apps/elektrine/lib/elektrine/activitypub/signature_retry_worker.ex
create mode 100644 apps/elektrine/lib/elektrine/activitypub/signature_verification.ex
create mode 100644 apps/elektrine/lib/elektrine/activitypub/signing_key.ex
create mode 100644 apps/elektrine/lib/elektrine/activitypub/submitted_link_repair_worker.ex
create mode 100644 apps/elektrine/lib/elektrine/activitypub/thread_backfill_worker.ex
create mode 100644 apps/elektrine/lib/elektrine/activitypub/tombstone.ex
create mode 100644 apps/elektrine/lib/elektrine/activitypub/tombstones.ex
create mode 100644 apps/elektrine/lib/elektrine/activitypub/undo_resolver.ex
create mode 100644 apps/elektrine/lib/elektrine/activitypub/user_block.ex
create mode 100644 apps/elektrine/lib/elektrine/activitypub/visibility.ex
create mode 100644 apps/elektrine/lib/elektrine/admin.ex
create mode 100644 apps/elektrine/lib/elektrine/admin/announcement.ex
create mode 100644 apps/elektrine/lib/elektrine/admin/announcement_dismissal.ex
create mode 100644 apps/elektrine/lib/elektrine/api/import_rate_limiter.ex
create mode 100644 apps/elektrine/lib/elektrine/api/media_rate_limiter.ex
create mode 100644 apps/elektrine/lib/elektrine/api/rate_limiter.ex
create mode 100644 apps/elektrine/lib/elektrine/api/search_rate_limiter.ex
create mode 100644 apps/elektrine/lib/elektrine/api/timeline_rate_limiter.ex
create mode 100644 apps/elektrine/lib/elektrine/api/write_rate_limiter.ex
create mode 100644 apps/elektrine/lib/elektrine/app_cache.ex
create mode 100644 apps/elektrine/lib/elektrine/application.ex
create mode 100644 apps/elektrine/lib/elektrine/async.ex
create mode 100644 apps/elektrine/lib/elektrine/atomine_proof_bundle.ex
create mode 100644 apps/elektrine/lib/elektrine/atomine_proof_graph.ex
create mode 100644 apps/elektrine/lib/elektrine/audit_log.ex
create mode 100644 apps/elektrine/lib/elektrine/auth/api_token_revocation.ex
create mode 100644 apps/elektrine/lib/elektrine/auth/rate_limiter.ex
create mode 100644 apps/elektrine/lib/elektrine/bluesky.ex
create mode 100644 apps/elektrine/lib/elektrine/bluesky/finch_client.ex
create mode 100644 apps/elektrine/lib/elektrine/bluesky/inbound.ex
create mode 100644 apps/elektrine/lib/elektrine/bluesky/inbound_event.ex
create mode 100644 apps/elektrine/lib/elektrine/bluesky/inbound_poll_worker.ex
create mode 100644 apps/elektrine/lib/elektrine/bluesky/managed.ex
create mode 100644 apps/elektrine/lib/elektrine/bluesky/outbound_worker.ex
create mode 100644 apps/elektrine/lib/elektrine/booking.ex
create mode 100644 apps/elektrine/lib/elektrine/booking/booking.ex
create mode 100644 apps/elektrine/lib/elektrine/booking/confirmation_worker.ex
create mode 100644 apps/elektrine/lib/elektrine/booking/expire_holds_worker.ex
create mode 100644 apps/elektrine/lib/elektrine/booking/notifier.ex
create mode 100644 apps/elektrine/lib/elektrine/booking/page.ex
create mode 100644 apps/elektrine/lib/elektrine/caddy_tls_domain_cache.ex
create mode 100644 apps/elektrine/lib/elektrine/calendar.ex
create mode 100644 apps/elektrine/lib/elektrine/calendar/availability.ex
create mode 100644 apps/elektrine/lib/elektrine/calendar/calendar.ex
create mode 100644 apps/elektrine/lib/elektrine/calendar/event.ex
create mode 100644 apps/elektrine/lib/elektrine/calendar/icalendar.ex
create mode 100644 apps/elektrine/lib/elektrine/calendar/recurrence.ex
create mode 100644 apps/elektrine/lib/elektrine/calls.ex
create mode 100644 apps/elektrine/lib/elektrine/calls/call.ex
create mode 100644 apps/elektrine/lib/elektrine/calls/transport.ex
create mode 100644 apps/elektrine/lib/elektrine/captcha.ex
create mode 100644 apps/elektrine/lib/elektrine/components/copy_button.ex
create mode 100644 apps/elektrine/lib/elektrine/components/experimental_notice.ex
create mode 100644 apps/elektrine/lib/elektrine/components/loaders/skeleton.ex
create mode 100644 apps/elektrine/lib/elektrine/components/report_modal.ex
create mode 100644 apps/elektrine/lib/elektrine/components/trust_level_badge.ex
create mode 100644 apps/elektrine/lib/elektrine/components/user/avatar.ex
create mode 100644 apps/elektrine/lib/elektrine/components/user/username_effects.ex
create mode 100644 apps/elektrine/lib/elektrine/components/user/verification_badge.ex
create mode 100644 apps/elektrine/lib/elektrine/constants.ex
create mode 100644 apps/elektrine/lib/elektrine/database/index_repair.ex
create mode 100644 apps/elektrine/lib/elektrine/dav/rate_limiter.ex
create mode 100644 apps/elektrine/lib/elektrine/db/write_guard.ex
create mode 100644 apps/elektrine/lib/elektrine/developer.ex
create mode 100644 apps/elektrine/lib/elektrine/developer/api_token.ex
create mode 100644 apps/elektrine/lib/elektrine/developer/data_export.ex
create mode 100644 apps/elektrine/lib/elektrine/developer/exports/account_exporter.ex
create mode 100644 apps/elektrine/lib/elektrine/developer/exports/chat_exporter.ex
create mode 100644 apps/elektrine/lib/elektrine/developer/exports/email_exporter.ex
create mode 100644 apps/elektrine/lib/elektrine/developer/exports/social_exporter.ex
create mode 100644 apps/elektrine/lib/elektrine/developer/webhook.ex
create mode 100644 apps/elektrine/lib/elektrine/developer/webhook_delivery.ex
create mode 100644 apps/elektrine/lib/elektrine/developer/workers/export_worker.ex
create mode 100644 apps/elektrine/lib/elektrine/developer/workers/webhook_delivery_worker.ex
create mode 100644 apps/elektrine/lib/elektrine/discord.ex
create mode 100644 apps/elektrine/lib/elektrine/domains.ex
create mode 100644 apps/elektrine/lib/elektrine/drive.ex
create mode 100644 apps/elektrine/lib/elektrine/drive/file_share.ex
create mode 100644 apps/elektrine/lib/elektrine/drive/stored_file.ex
create mode 100644 apps/elektrine/lib/elektrine/drive/stored_folder.ex
create mode 100644 apps/elektrine/lib/elektrine/email_addresses.ex
create mode 100644 apps/elektrine/lib/elektrine/emojis.ex
create mode 100644 apps/elektrine/lib/elektrine/emojis/custom_emoji.ex
create mode 100644 apps/elektrine/lib/elektrine/encryption.ex
create mode 100644 apps/elektrine/lib/elektrine/encryption/key_cache.ex
create mode 100644 apps/elektrine/lib/elektrine/friends.ex
create mode 100644 apps/elektrine/lib/elektrine/friends/friend_request.ex
create mode 100644 apps/elektrine/lib/elektrine/home_blog.ex
create mode 100644 apps/elektrine/lib/elektrine/http/backoff.ex
create mode 100644 apps/elektrine/lib/elektrine/http/safe_fetch.ex
create mode 100644 apps/elektrine/lib/elektrine/input.ex
create mode 100644 apps/elektrine/lib/elektrine/internal_api.ex
create mode 100644 apps/elektrine/lib/elektrine/ip_lookup.ex
create mode 100644 apps/elektrine/lib/elektrine/job_queue.ex
create mode 100644 apps/elektrine/lib/elektrine/job_queue_monitor.ex
create mode 100644 apps/elektrine/lib/elektrine/jobs/attachment_uploader.ex
create mode 100644 apps/elektrine/lib/elektrine/jobs/auto_promote_eligible_users_worker.ex
create mode 100644 apps/elektrine/lib/elektrine/jobs/deactivate_expired_announcements_worker.ex
create mode 100644 apps/elektrine/lib/elektrine/jobs/email_recategorizer.ex
create mode 100644 apps/elektrine/lib/elektrine/jobs/profile_custom_domain_health_worker.ex
create mode 100644 apps/elektrine/lib/elektrine/jobs/recalculate_recent_discussion_scores_worker.ex
create mode 100644 apps/elektrine/lib/elektrine/jobs/reply_later_processor.ex
create mode 100644 apps/elektrine/lib/elektrine/jobs/stale_call_cleanup.ex
create mode 100644 apps/elektrine/lib/elektrine/link_archive.ex
create mode 100644 apps/elektrine/lib/elektrine/link_archive/archive.ex
create mode 100644 apps/elektrine/lib/elektrine/link_archive/fetch_worker.ex
create mode 100644 apps/elektrine/lib/elektrine/mail/telemetry.ex
create mode 100644 apps/elektrine/lib/elektrine/mail_auth/rate_limiter.ex
create mode 100644 apps/elektrine/lib/elektrine/mail_client_settings.ex
create mode 100644 apps/elektrine/lib/elektrine/mailer.ex
create mode 100644 apps/elektrine/lib/elektrine/mailer/delivery_worker.ex
create mode 100644 apps/elektrine/lib/elektrine/markdown.ex
create mode 100644 apps/elektrine/lib/elektrine/markers.ex
create mode 100644 apps/elektrine/lib/elektrine/markers/marker.ex
create mode 100644 apps/elektrine/lib/elektrine/media_proxy.ex
create mode 100644 apps/elektrine/lib/elektrine/messaging.ex
create mode 100644 apps/elektrine/lib/elektrine/messaging/arblarg_profiles.ex
create mode 100644 apps/elektrine/lib/elektrine/messaging/arblarg_sdk.ex
create mode 100644 apps/elektrine/lib/elektrine/messaging/auto_mod_rule.ex
create mode 100644 apps/elektrine/lib/elektrine/messaging/channel_categories.ex
create mode 100644 apps/elektrine/lib/elektrine/messaging/channel_category.ex
create mode 100644 apps/elektrine/lib/elektrine/messaging/chat_conversation.ex
create mode 100644 apps/elektrine/lib/elektrine/messaging/chat_conversation_encryption_key.ex
create mode 100644 apps/elektrine/lib/elektrine/messaging/chat_conversation_key_recipient.ex
create mode 100644 apps/elektrine/lib/elektrine/messaging/chat_conversation_member.ex
create mode 100644 apps/elektrine/lib/elektrine/messaging/chat_conversations.ex
create mode 100644 apps/elektrine/lib/elektrine/messaging/chat_encryption_device.ex
create mode 100644 apps/elektrine/lib/elektrine/messaging/chat_message.ex
create mode 100644 apps/elektrine/lib/elektrine/messaging/chat_message_pin.ex
create mode 100644 apps/elektrine/lib/elektrine/messaging/chat_message_pins.ex
create mode 100644 apps/elektrine/lib/elektrine/messaging/chat_message_reaction.ex
create mode 100644 apps/elektrine/lib/elektrine/messaging/chat_messages.ex
create mode 100644 apps/elektrine/lib/elektrine/messaging/chat_moderation_action.ex
create mode 100644 apps/elektrine/lib/elektrine/messaging/chat_remote_encryption_device.ex
create mode 100644 apps/elektrine/lib/elektrine/messaging/chat_thread.ex
create mode 100644 apps/elektrine/lib/elektrine/messaging/chat_threads.ex
create mode 100644 apps/elektrine/lib/elektrine/messaging/chat_user_hidden_message.ex
create mode 100644 apps/elektrine/lib/elektrine/messaging/chat_user_timeout.ex
create mode 100644 apps/elektrine/lib/elektrine/messaging/chat_webhook.ex
create mode 100644 apps/elektrine/lib/elektrine/messaging/chat_webhooks.ex
create mode 100644 apps/elektrine/lib/elektrine/messaging/community_ban.ex
create mode 100644 apps/elektrine/lib/elektrine/messaging/community_flair.ex
create mode 100644 apps/elektrine/lib/elektrine/messaging/community_search.ex
create mode 100644 apps/elektrine/lib/elektrine/messaging/conversation_member.ex
create mode 100644 apps/elektrine/lib/elektrine/messaging/federated_boost.ex
create mode 100644 apps/elektrine/lib/elektrine/messaging/federated_dislike.ex
create mode 100644 apps/elektrine/lib/elektrine/messaging/federated_like.ex
create mode 100644 apps/elektrine/lib/elektrine/messaging/federation.ex
create mode 100644 apps/elektrine/lib/elektrine/messaging/federation/actors.ex
create mode 100644 apps/elektrine/lib/elektrine/messaging/federation/attachments.ex
create mode 100644 apps/elektrine/lib/elektrine/messaging/federation/auth.ex
create mode 100644 apps/elektrine/lib/elektrine/messaging/federation/builders.ex
create mode 100644 apps/elektrine/lib/elektrine/messaging/federation/config.ex
create mode 100644 apps/elektrine/lib/elektrine/messaging/federation/contexts.ex
create mode 100644 apps/elektrine/lib/elektrine/messaging/federation/delivery.ex
create mode 100644 apps/elektrine/lib/elektrine/messaging/federation/direct_message_state.ex
create mode 100644 apps/elektrine/lib/elektrine/messaging/federation/direct_messages.ex
create mode 100644 apps/elektrine/lib/elektrine/messaging/federation/discovery.ex
create mode 100644 apps/elektrine/lib/elektrine/messaging/federation/dispatch.ex
create mode 100644 apps/elektrine/lib/elektrine/messaging/federation/egress.ex
create mode 100644 apps/elektrine/lib/elektrine/messaging/federation/errors.ex
create mode 100644 apps/elektrine/lib/elektrine/messaging/federation/event_router.ex
create mode 100644 apps/elektrine/lib/elektrine/messaging/federation/event_tracking.ex
create mode 100644 apps/elektrine/lib/elektrine/messaging/federation/extension_events.ex
create mode 100644 apps/elektrine/lib/elektrine/messaging/federation/inbound.ex
create mode 100644 apps/elektrine/lib/elektrine/messaging/federation/ingress.ex
create mode 100644 apps/elektrine/lib/elektrine/messaging/federation/ingress_rate_limiter.ex
create mode 100644 apps/elektrine/lib/elektrine/messaging/federation/maintenance.ex
create mode 100644 apps/elektrine/lib/elektrine/messaging/federation/mirror_broadcasts.ex
create mode 100644 apps/elektrine/lib/elektrine/messaging/federation/mirror_events.ex
create mode 100644 apps/elektrine/lib/elektrine/messaging/federation/mirrors.ex
create mode 100644 apps/elektrine/lib/elektrine/messaging/federation/peer_policies.ex
create mode 100644 apps/elektrine/lib/elektrine/messaging/federation/peers.ex
create mode 100644 apps/elektrine/lib/elektrine/messaging/federation/protocol.ex
create mode 100644 apps/elektrine/lib/elektrine/messaging/federation/public_discovery.ex
create mode 100644 apps/elektrine/lib/elektrine/messaging/federation/publisher.ex
create mode 100644 apps/elektrine/lib/elektrine/messaging/federation/request_auth.ex
create mode 100644 apps/elektrine/lib/elektrine/messaging/federation/runtime.ex
create mode 100644 apps/elektrine/lib/elektrine/messaging/federation/snapshots.ex
create mode 100644 apps/elektrine/lib/elektrine/messaging/federation/state.ex
create mode 100644 apps/elektrine/lib/elektrine/messaging/federation/transport.ex
create mode 100644 apps/elektrine/lib/elektrine/messaging/federation/utils.ex
create mode 100644 apps/elektrine/lib/elektrine/messaging/federation/validation.ex
create mode 100644 apps/elektrine/lib/elektrine/messaging/federation/visibility.ex
create mode 100644 apps/elektrine/lib/elektrine/messaging/federation/voice_calls.ex
create mode 100644 apps/elektrine/lib/elektrine/messaging/federation_account_presence_state.ex
create mode 100644 apps/elektrine/lib/elektrine/messaging/federation_call_session.ex
create mode 100644 apps/elektrine/lib/elektrine/messaging/federation_discovered_peer.ex
create mode 100644 apps/elektrine/lib/elektrine/messaging/federation_event.ex
create mode 100644 apps/elektrine/lib/elektrine/messaging/federation_extension_event.ex
create mode 100644 apps/elektrine/lib/elektrine/messaging/federation_invite_state.ex
create mode 100644 apps/elektrine/lib/elektrine/messaging/federation_membership_state.ex
create mode 100644 apps/elektrine/lib/elektrine/messaging/federation_outbox_event.ex
create mode 100644 apps/elektrine/lib/elektrine/messaging/federation_outbox_retry_worker.ex
create mode 100644 apps/elektrine/lib/elektrine/messaging/federation_outbox_worker.ex
create mode 100644 apps/elektrine/lib/elektrine/messaging/federation_peer_policy.ex
create mode 100644 apps/elektrine/lib/elektrine/messaging/federation_read_cursor.ex
create mode 100644 apps/elektrine/lib/elektrine/messaging/federation_request_replay.ex
create mode 100644 apps/elektrine/lib/elektrine/messaging/federation_retention_worker.ex
create mode 100644 apps/elektrine/lib/elektrine/messaging/federation_room_presence_state.ex
create mode 100644 apps/elektrine/lib/elektrine/messaging/federation_session_client.ex
create mode 100644 apps/elektrine/lib/elektrine/messaging/federation_session_websock.ex
create mode 100644 apps/elektrine/lib/elektrine/messaging/federation_stream_position.ex
create mode 100644 apps/elektrine/lib/elektrine/messaging/link_preview_broadcast.ex
create mode 100644 apps/elektrine/lib/elektrine/messaging/member_schema_base.ex
create mode 100644 apps/elektrine/lib/elektrine/messaging/membership.ex
create mode 100644 apps/elektrine/lib/elektrine/messaging/membership_authz.ex
create mode 100644 apps/elektrine/lib/elektrine/messaging/message_reaction.ex
create mode 100644 apps/elektrine/lib/elektrine/messaging/moderation.ex
create mode 100644 apps/elektrine/lib/elektrine/messaging/moderation_action.ex
create mode 100644 apps/elektrine/lib/elektrine/messaging/moderation_tools.ex
create mode 100644 apps/elektrine/lib/elektrine/messaging/moderator_note.ex
create mode 100644 apps/elektrine/lib/elektrine/messaging/rate_limiter.ex
create mode 100644 apps/elektrine/lib/elektrine/messaging/reactions.ex
create mode 100644 apps/elektrine/lib/elektrine/messaging/reference_peer.ex
create mode 100644 apps/elektrine/lib/elektrine/messaging/reference_peer_protocol.ex
create mode 100644 apps/elektrine/lib/elektrine/messaging/reference_peer_session_server.ex
create mode 100644 apps/elektrine/lib/elektrine/messaging/room_acl.ex
create mode 100644 apps/elektrine/lib/elektrine/messaging/server.ex
create mode 100644 apps/elektrine/lib/elektrine/messaging/server_member.ex
create mode 100644 apps/elektrine/lib/elektrine/messaging/servers.ex
create mode 100644 apps/elektrine/lib/elektrine/messaging/slash_commands.ex
create mode 100644 apps/elektrine/lib/elektrine/messaging/social_conversation.ex
create mode 100644 apps/elektrine/lib/elektrine/messaging/social_message.ex
create mode 100644 apps/elektrine/lib/elektrine/messaging/sync_remote_counts_worker.ex
create mode 100644 apps/elektrine/lib/elektrine/messaging/user_hidden_message.ex
create mode 100644 apps/elektrine/lib/elektrine/messaging/user_post_timestamp.ex
create mode 100644 apps/elektrine/lib/elektrine/messaging/user_timeout.ex
create mode 100644 apps/elektrine/lib/elektrine/messaging/user_warning.ex
create mode 100644 apps/elektrine/lib/elektrine/messaging/voice_channels.ex
create mode 100644 apps/elektrine/lib/elektrine/notifications.ex
create mode 100644 apps/elektrine/lib/elektrine/notifications/federation_notifications.ex
create mode 100644 apps/elektrine/lib/elektrine/notifications/notification.ex
create mode 100644 apps/elektrine/lib/elektrine/oauth.ex
create mode 100644 apps/elektrine/lib/elektrine/oauth/app.ex
create mode 100644 apps/elektrine/lib/elektrine/oauth/authorization.ex
create mode 100644 apps/elektrine/lib/elektrine/oauth/scopes.ex
create mode 100644 apps/elektrine/lib/elektrine/oauth/token.ex
create mode 100644 apps/elektrine/lib/elektrine/oidc.ex
create mode 100644 apps/elektrine/lib/elektrine/oidc/signing_key.ex
create mode 100644 apps/elektrine/lib/elektrine/own_root.ex
create mode 100644 apps/elektrine/lib/elektrine/paths.ex
create mode 100644 apps/elektrine/lib/elektrine/platform/e_nav.ex
create mode 100644 apps/elektrine/lib/elektrine/platform/e_nav_component.ex
create mode 100644 apps/elektrine/lib/elektrine/platform/integrations.ex
create mode 100644 apps/elektrine/lib/elektrine/platform/module_provider.ex
create mode 100644 apps/elektrine/lib/elektrine/platform/module_providers.ex
create mode 100644 apps/elektrine/lib/elektrine/platform/modules.ex
create mode 100644 apps/elektrine/lib/elektrine/platform/runtime_config_validator.ex
create mode 100644 apps/elektrine/lib/elektrine/privacy.ex
create mode 100644 apps/elektrine/lib/elektrine/profiles.ex
create mode 100644 apps/elektrine/lib/elektrine/profiles/analytics_retention_worker.ex
create mode 100644 apps/elektrine/lib/elektrine/profiles/custom_domain.ex
create mode 100644 apps/elektrine/lib/elektrine/profiles/custom_domains.ex
create mode 100644 apps/elektrine/lib/elektrine/profiles/follow.ex
create mode 100644 apps/elektrine/lib/elektrine/profiles/per_site_identities.ex
create mode 100644 apps/elektrine/lib/elektrine/profiles/per_site_identity.ex
create mode 100644 apps/elektrine/lib/elektrine/profiles/profile_link.ex
create mode 100644 apps/elektrine/lib/elektrine/profiles/profile_site_visit.ex
create mode 100644 apps/elektrine/lib/elektrine/profiles/profile_view.ex
create mode 100644 apps/elektrine/lib/elektrine/profiles/profile_widget.ex
create mode 100644 apps/elektrine/lib/elektrine/profiles/public_site_analytics_rollup_worker.ex
create mode 100644 apps/elektrine/lib/elektrine/profiles/site_page_visit.ex
create mode 100644 apps/elektrine/lib/elektrine/profiles/site_session.ex
create mode 100644 apps/elektrine/lib/elektrine/profiles/static_site_deploy.ex
create mode 100644 apps/elektrine/lib/elektrine/profiles/static_site_deployment.ex
create mode 100644 apps/elektrine/lib/elektrine/profiles/static_site_file.ex
create mode 100644 apps/elektrine/lib/elektrine/profiles/user_badge.ex
create mode 100644 apps/elektrine/lib/elektrine/profiles/user_profile.ex
create mode 100644 apps/elektrine/lib/elektrine/proxy_protocol.ex
create mode 100644 apps/elektrine/lib/elektrine/pubsub_topics.ex
create mode 100644 apps/elektrine/lib/elektrine/push.ex
create mode 100644 apps/elektrine/lib/elektrine/push/device_token.ex
create mode 100644 apps/elektrine/lib/elektrine/push/web_push_client.ex
create mode 100644 apps/elektrine/lib/elektrine/push/web_subscription.ex
create mode 100644 apps/elektrine/lib/elektrine/rate_limiter.ex
create mode 100644 apps/elektrine/lib/elektrine/release.ex
create mode 100644 apps/elektrine/lib/elektrine/repo.ex
create mode 100644 apps/elektrine/lib/elektrine/reports.ex
create mode 100644 apps/elektrine/lib/elektrine/reports/report.ex
create mode 100644 apps/elektrine/lib/elektrine/rss.ex
create mode 100644 apps/elektrine/lib/elektrine/rss/feed.ex
create mode 100644 apps/elektrine/lib/elektrine/rss/fetch_feed_worker.ex
create mode 100644 apps/elektrine/lib/elektrine/rss/item.ex
create mode 100644 apps/elektrine/lib/elektrine/rss/parser.ex
create mode 100644 apps/elektrine/lib/elektrine/rss/scheduler_worker.ex
create mode 100644 apps/elektrine/lib/elektrine/rss/subscription.ex
create mode 100644 apps/elektrine/lib/elektrine/runtime_env.ex
create mode 100644 apps/elektrine/lib/elektrine/runtime_secrets.ex
create mode 100644 apps/elektrine/lib/elektrine/search.ex
create mode 100644 apps/elektrine/lib/elektrine/search/cached.ex
create mode 100644 apps/elektrine/lib/elektrine/search/domain_rule.ex
create mode 100644 apps/elektrine/lib/elektrine/search/domain_rules.ex
create mode 100644 apps/elektrine/lib/elektrine/search/paige_rate_limiter.ex
create mode 100644 apps/elektrine/lib/elektrine/search/rate_limiter.ex
create mode 100644 apps/elektrine/lib/elektrine/secrets/backfill.ex
create mode 100644 apps/elektrine/lib/elektrine/secrets/encrypted_string.ex
create mode 100644 apps/elektrine/lib/elektrine/security/content_validator.ex
create mode 100644 apps/elektrine/lib/elektrine/security/file_path.ex
create mode 100644 apps/elektrine/lib/elektrine/security/safe_external_url.ex
create mode 100644 apps/elektrine/lib/elektrine/security/url_validator.ex
create mode 100644 apps/elektrine/lib/elektrine/security_alerts.ex
create mode 100644 apps/elektrine/lib/elektrine/security_alerts/cache.ex
create mode 100644 apps/elektrine/lib/elektrine/social/attachment_cleanup_worker.ex
create mode 100644 apps/elektrine/lib/elektrine/social/conversations.ex
create mode 100644 apps/elektrine/lib/elektrine/social/engagement_count_repair.ex
create mode 100644 apps/elektrine/lib/elektrine/social/engagement_count_repair_worker.ex
create mode 100644 apps/elektrine/lib/elektrine/social/engagement_counts.ex
create mode 100644 apps/elektrine/lib/elektrine/social/expired_data_cleanup_worker.ex
create mode 100644 apps/elektrine/lib/elektrine/social/hashtag.ex
create mode 100644 apps/elektrine/lib/elektrine/social/link_preview.ex
create mode 100644 apps/elektrine/lib/elektrine/social/message_stat.ex
create mode 100644 apps/elektrine/lib/elektrine/social/message_stats.ex
create mode 100644 apps/elektrine/lib/elektrine/social/messages.ex
create mode 100644 apps/elektrine/lib/elektrine/social/poll.ex
create mode 100644 apps/elektrine/lib/elektrine/social/poll_option.ex
create mode 100644 apps/elektrine/lib/elektrine/social/poll_vote.ex
create mode 100644 apps/elektrine/lib/elektrine/social/post_hashtag.ex
create mode 100644 apps/elektrine/lib/elektrine/social/scrobble.ex
create mode 100644 apps/elektrine/lib/elektrine/social/scrobbles.ex
create mode 100644 apps/elektrine/lib/elektrine/static_sites.ex
create mode 100644 apps/elektrine/lib/elektrine/static_sites/github_deploy_worker.ex
create mode 100644 apps/elektrine/lib/elektrine/static_sites/request_resolver.ex
create mode 100644 apps/elektrine/lib/elektrine/strings.ex
create mode 100644 apps/elektrine/lib/elektrine/subscriptions.ex
create mode 100644 apps/elektrine/lib/elektrine/subscriptions/product.ex
create mode 100644 apps/elektrine/lib/elektrine/subscriptions/registration_checkout.ex
create mode 100644 apps/elektrine/lib/elektrine/subscriptions/stripe_client.ex
create mode 100644 apps/elektrine/lib/elektrine/subscriptions/stripe_client/live.ex
create mode 100644 apps/elektrine/lib/elektrine/subscriptions/subscription.ex
create mode 100644 apps/elektrine/lib/elektrine/system.ex
create mode 100644 apps/elektrine/lib/elektrine/system/config.ex
create mode 100644 apps/elektrine/lib/elektrine/telemetry/events.ex
create mode 100644 apps/elektrine/lib/elektrine/text_helpers.ex
create mode 100644 apps/elektrine/lib/elektrine/theme.ex
create mode 100644 apps/elektrine/lib/elektrine/time.ex
create mode 100644 apps/elektrine/lib/elektrine/timeline/rate_limiter.ex
create mode 100644 apps/elektrine/lib/elektrine/uploads.ex
create mode 100644 apps/elektrine/lib/elektrine/user_notifier.ex
create mode 100644 apps/elektrine/lib/elektrine/utils/safe_convert.ex
create mode 100644 apps/elektrine/lib/elektrine/utils/slug.ex
create mode 100644 apps/elektrine/lib/elektrine/vault.ex
create mode 100644 apps/elektrine/lib/elektrine/vault/master_key.ex
create mode 100644 apps/elektrine/lib/elektrine/web_index.ex
create mode 100644 apps/elektrine/lib/elektrine/web_index/crawl_worker.ex
create mode 100644 apps/elektrine/lib/elektrine/web_index/crawler.ex
create mode 100644 apps/elektrine/lib/elektrine/web_index/document.ex
create mode 100644 apps/elektrine/lib/elektrine/web_index/extractor.ex
create mode 100644 apps/elektrine/lib/elektrine/web_index/fetcher.ex
create mode 100644 apps/elektrine/lib/elektrine/web_index/host.ex
create mode 100644 apps/elektrine/lib/elektrine/web_index/provider.ex
create mode 100644 apps/elektrine/lib/elektrine/web_index/robots.ex
create mode 100644 apps/elektrine/lib/elektrine/web_index/scheduler_worker.ex
create mode 100644 apps/elektrine/lib/elektrine/web_index/sitemap_worker.ex
create mode 100644 apps/elektrine/lib/elektrine/webhook/rate_limiter.ex
create mode 100644 apps/elektrine/lib/mix/tasks/activitypub.backfill_signing_keys.ex
create mode 100644 apps/elektrine/lib/mix/tasks/activitypub.migrate_domain.ex
create mode 100644 apps/elektrine/lib/mix/tasks/activitypub.repair_submitted_links.ex
create mode 100644 apps/elektrine/lib/mix/tasks/admin.make.ex
create mode 100644 apps/elektrine/lib/mix/tasks/analytics.seed_site_load.ex
create mode 100644 apps/elektrine/lib/mix/tasks/arblarg.conformance.ex
create mode 100644 apps/elektrine/lib/mix/tasks/badge.grant.ex
create mode 100644 apps/elektrine/lib/mix/tasks/badge.revoke.ex
create mode 100644 apps/elektrine/lib/mix/tasks/cleanup_duplicate_mailboxes.ex
create mode 100644 apps/elektrine/lib/mix/tasks/discussions.demo_posts.ex
create mode 100644 apps/elektrine/lib/mix/tasks/elektrine.db.repair_indexes.ex
create mode 100644 apps/elektrine/lib/mix/tasks/email.inbound_dlq.ex
create mode 100644 apps/elektrine/lib/mix/tasks/email.pubsub_test.ex
create mode 100644 apps/elektrine/lib/mix/tasks/email.recalculate_storage.ex
create mode 100644 apps/elektrine/lib/mix/tasks/email.recategorize.ex
create mode 100644 apps/elektrine/lib/mix/tasks/email.seed_plaintext.ex
create mode 100644 apps/elektrine/lib/mix/tasks/email.seed_realistic.ex
create mode 100644 apps/elektrine/lib/mix/tasks/email.test.ex
create mode 100644 apps/elektrine/lib/mix/tasks/fix_username_duplicates.ex
create mode 100644 apps/elektrine/lib/mix/tasks/messaging.arblarg.sync_artifacts.ex
create mode 100644 apps/elektrine/lib/mix/tasks/messaging.federation.interop_smoke.ex
create mode 100644 apps/elektrine/lib/mix/tasks/messaging.federation.load_test.ex
create mode 100644 apps/elektrine/lib/mix/tasks/migrate_email_attachments.ex
create mode 100644 apps/elektrine/lib/mix/tasks/secrets.backfill.ex
create mode 100644 apps/elektrine/lib/mix/tasks/social.backfill_lemmy_comment_votes.ex
create mode 100644 apps/elektrine/lib/mix/tasks/social.recalculate_reply_counts.ex
create mode 100644 apps/elektrine/lib/mix/tasks/social.repair_engagement_counts.ex
create mode 100644 apps/elektrine/lib/mix/tasks/test_2fa.ex
create mode 100644 apps/elektrine/lib/mix/tasks/users.import_existing.ex
create mode 100644 apps/elektrine/mix.exs
create mode 100644 apps/elektrine/priv/gettext/default.pot
create mode 100644 apps/elektrine/priv/gettext/errors.pot
create mode 100644 apps/elektrine/priv/gettext/zh/LC_MESSAGES/default.po
create mode 100644 apps/elektrine/priv/gettext/zh/LC_MESSAGES/errors.po
create mode 100644 apps/elektrine/priv/repo/baseline_schema.sql
create mode 100644 apps/elektrine/priv/repo/generate_activitypub_keys.exs
create mode 100644 apps/elektrine/priv/repo/migrations/.formatter.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20250511044001_create_users_table.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20250512000001_create_mailboxes.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20250512000002_create_email_messages.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20250513061546_create_temporary_mailboxes.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20250513064926_make_mailbox_user_id_nullable.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20250513072500_update_messages_for_temporary_mailboxes.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20250529050458_add_avatar_to_users.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20250608202809_add_hey_features_to_email_messages.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20250608202829_create_approved_senders.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20250609031912_add_spam_and_archived_to_email_messages.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20250615002423_import_existing_users.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20250615202324_add_username_change_tracking.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20250615235823_add_admin_role_to_users.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20250616000949_add_banned_to_users.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20250622162141_create_email_aliases.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20250622164337_add_forwarding_to_mailboxes.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20250622165252_make_target_email_nullable.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20250622180610_add_alias_username_conflict_indexes.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20250622233020_add_attachments_to_email_messages.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20250624065910_create_account_deletion_requests.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20250628155713_add_two_factor_auth_to_users.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20250630003407_create_invite_codes.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20250630003424_create_invite_code_uses.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20250630011535_create_system_config.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20250706183626_create_rejected_senders.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20250706184034_add_user_to_temporary_mailboxes.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20250712172013_add_bluesky_identity_to_users.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20250720223936_add_github_token_to_users.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20250726184803_remove_screener_feature.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20250817001342_create_siem_tables.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20250817191916_create_threat_intelligence_tables.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20250817210439_add_cybersecurity_news.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20250817212722_add_underground_threat_intelligence.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20250902065912_create_subscriptions.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20250903172225_create_user_profiles_and_links.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20250904070740_add_location_to_user_profiles.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20250904171431_add_favicon_and_title_to_profiles.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20250905054459_add_advanced_customization_to_profiles.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20250905060932_add_ip_tracking_to_users.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20250906055118_create_platform_updates.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20250906081641_create_audit_logs.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20250907053034_create_announcements.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20250907074626_create_announcement_dismissals.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20250907193037_add_display_name_to_users.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20250907232526_add_recovery_email_to_users.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20250909041315_add_locale_to_users.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20250909163235_remove_temporary_mailbox_system.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20250911070737_create_username_history.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20250912165836_add_storage_tracking_to_mailboxes.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20250912173431_update_storage_limit_to_5mb.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20250913160643_remove_bluesky_from_users.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20250913163916_remove_custom_css_from_profiles.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20250914003149_add_privacy_settings_to_profiles.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20250914005156_create_messaging_system.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20250914031911_add_hidden_messages.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20250914073452_add_hash_to_conversations.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20250914073519_add_hash_to_email_messages.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20250914074416_populate_existing_hashes.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20250914081434_remove_username_change_fields.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20250914091730_add_username_to_mailboxes.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20250914172837_create_user_blocks.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20250915010911_add_last_password_change_to_users.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20250915011003_add_index_to_last_password_change.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20250915070107_add_moderation_system.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20250916052139_update_storage_limit_to_500mb.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20250916171831_create_follows.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20250918055140_create_reports.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20250918063443_add_suspension_to_users.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20250918165958_add_social_features_to_messages.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20250918173523_remove_duplicate_user_follows_table.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20250919043723_add_link_previews.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20250919050445_add_hashtag_system.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20250919124426_add_discussion_voting_system.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20250919130802_add_cross_context_promotion_fields.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20250919132803_add_titles_to_messages.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20250920125417_add_unique_constraint_to_community_names.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20250920154313_add_promoted_from_community_fields.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20250920190238_add_last_read_message_id_to_conversation_members.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20250923055335_create_notifications.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20250923155428_add_case_insensitive_unique_index_to_aliases.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20250923155500_add_case_insensitive_unique_index_to_users.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20250923160000_fix_duplicate_aliases_before_index.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20250924000001_add_social_identity_to_users.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20250924000002_grandfather_user_handles.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20250924065350_regenerate_missing_handles_and_ids.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20250925151252_migrate_email_attachments_to_s3.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20250926045456_add_privacy_settings_to_users.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20250926052516_set_default_privacy_settings.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20250926053327_rename_email_notification_fields_to_notify.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20250927050233_add_community_flairs.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20250927065000_update_reply_counts.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20250927070000_add_vote_indexes.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20250927071000_add_community_rules.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20250927153135_add_pinned_to_messages.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20250927153154_create_community_bans.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20250927161505_rename_email_categories.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20250927195300_rename_feed_to_digest.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20250930065342_create_app_passwords.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20250930145634_add_flagged_to_email_messages.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20250930153632_allow_null_to_in_email_messages.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20250930163613_add_deleted_to_email_messages.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20250930171320_add_answered_to_email_messages.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20251004205738_create_profile_views.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20251005051754_add_user_storage_tracking.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20251005052144_remove_mailbox_storage_fields.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20251005053001_update_user_storage_limits_to_500mb.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20251005145316_add_media_metadata_to_messages.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20251005152310_add_file_sizes_to_avatars_and_backgrounds.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20251005153018_remove_animated_title_from_user_profiles.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20251006034404_add_case_insensitive_handle_index.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20251006034431_add_case_insensitive_community_name_index.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20251008000912_add_timezone_to_users.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20251008012454_add_time_format_to_users.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20251008014427_remove_notify_on_group_invite_from_users.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20251008150636_add_encryption_to_messages.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20251008150659_add_encryption_to_email_messages.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20251008181136_add_email_protocol_tracking.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20251009063632_add_user_status.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20251009190614_add_last_seen_to_users.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20251010031206_add_username_effects_to_profiles.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20251010035439_create_user_badges.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20251010035441_add_typewriter_effect_to_profiles.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20251010035442_create_profile_widgets.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20251010050458_add_typewriter_title_to_profiles.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20251010103546_add_link_display_style_and_sections.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20251010104829_add_container_background_to_profiles.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20251010110017_add_container_pattern_to_profiles.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20251010110645_add_pattern_animation_to_profiles.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20251010133611_add_visibility_toggles_to_profiles.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20251010134141_add_text_background_to_profiles.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20251010134855_update_default_font_to_system.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20251010135407_add_visible_to_user_badges.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20251010135908_add_pattern_opacity_to_user_profiles.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20251010150620_add_verified_to_users.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20251010173540_rename_early_adopter_to_beta_tester.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20251010204959_remove_font_family_default.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20251011035521_add_avatar_effect_to_profiles.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20251011050154_add_tick_color_to_profiles.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20251011063738_add_comprehensive_privacy_controls.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20251011150823_set_defaults_for_new_privacy_fields.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20251011212159_create_forwarded_messages.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20251012163307_add_hide_share_button_to_user_profiles.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20251012172541_add_extend_layout_to_user_profiles.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20251015150616_add_hide_community_posts_to_user_profiles.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20251015165346_add_link_highlight_effect_to_user_profiles.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20251016063756_add_thumbnail_url_to_profile_links.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20251016151551_add_display_style_to_profile_links.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20251016152338_add_highlight_effect_to_profile_links.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20251016154738_add_circular_links_position_to_user_profiles.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20251017051314_consolidate_conversation_type.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20251017063439_add_show_qr_code_to_user_profiles.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20251017070808_remove_show_qr_code_from_user_profiles.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20251017160525_drop_approved_senders_table.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20251017160604_cleanup_orphaned_indexes.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20251018050726_add_post_types_support.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20251018124609_add_comprehensive_moderation_features.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20251018162421_add_onboarding_fields.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20251018234059_add_contacts_system.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20251019041333_add_email_signature_to_users.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20251021165630_create_email_unsubscribes.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20251025131913_create_vpn_tables.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20251026013643_add_bandwidth_quota_to_vpn_user_configs.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20251026014738_add_rate_limit_to_vpn_user_configs.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20251026025047_add_trust_level_system.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20251026043133_remove_siem_intel_news_tables.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20251026052454_remove_github_fields_from_users.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20251026081602_add_minimum_trust_level_to_vpn_servers.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20251026090202_change_vpn_server_foreign_key_to_cascade.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20251027022325_add_notification_preferences.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20251027023409_add_discussion_and_comment_notification_preferences.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20251028160556_add_email_performance_indexes.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20251101202100_add_gallery_to_post_type_constraint.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20251102020000_create_post_views.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20251102040000_add_performance_indexes.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20251102050000_add_preferred_email_domain_to_users.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20251106000000_create_trusted_devices.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20251108000001_create_activitypub_tables.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20251108000002_generate_activitypub_keys_for_existing_users.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20251108000003_make_follower_id_nullable.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20251108000004_make_conversation_id_nullable_for_federation.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20251108000005_make_followed_id_nullable.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20251108000006_create_post_boosts.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20251108000007_create_activitypub_relays.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20251109164600_set_manually_approve_followers_default_true.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20251109200000_drop_relays_table.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20251109203000_create_federated_likes.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20251110000001_create_lists.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20251110000002_add_published_at_to_activitypub_actors.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20251111120000_create_custom_emojis.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20251113000001_add_content_warning_to_messages.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20251113000002_add_federation_to_message_reactions.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20251116000000_add_community_support_to_activitypub.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20251117000001_add_federated_source_to_conversations.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20251117000002_regenerate_activitypub_keys_for_lemmy.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20251118000001_change_activitypub_fields_to_text.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20251119000001_make_message_reactions_user_id_nullable.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20251203150755_add_federated_dislikes.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20251203150938_add_federated_boosts.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20251206171650_create_group_follows.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20251209000000_add_missing_performance_indexes.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20251213071259_extend_email_message_fields.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20251213170721_add_activitypub_performance_indexes.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20251216160458_drop_old_username_index.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20251217070133_enhance_recommendation_tracking.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20251218053438_add_processed_to_activities.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20251218055225_add_background_job_tables.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20251218172906_add_oban_jobs_table.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20251223045423_add_email_restriction_fields.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20251224161418_add_unique_actor_username_domain.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20251225205440_create_device_tokens.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20251228000001_fix_duplicate_handles.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20251228000002_add_calls_table.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20251228000003_add_friend_requests_table.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20251229015026_create_chat_messages.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20251229020637_migrate_chat_messages_data.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20251230180851_create_calendars.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20251230181000_extend_contacts_for_carddav.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20251231174204_add_pgp_support.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20260102051253_add_quote_support_to_messages.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20260110225401_create_email_threads.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20260110225402_add_jmap_fields_to_messages.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20260110225403_create_jmap_state_tracking.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20260110225404_create_email_submissions.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20260111043859_add_profile_mode_to_user_profiles.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20260112200000_add_email_features.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20260113024544_create_rss_and_saved_items.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20260115100000_add_registered_via_onion_to_users.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20260117120000_create_user_integrations.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20260118215247_add_composite_performance_indexes.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20260125082934_add_category_to_messages.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20260125085508_create_api_tokens.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20260125090103_create_data_exports.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20260125232349_add_draft_status_to_messages.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20260126072102_add_lemmy_counts_cache.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20260127034957_create_signing_keys.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20260127035230_migrate_existing_keys_to_signing_keys.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20260127090905_add_mrf_policies_to_instances.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20260127091426_create_relay_subscriptions.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20260127100000_create_remote_interactions.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20260127100100_add_voters_tracking_to_polls.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20260127100200_add_emoji_url_to_message_reactions.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20260127100300_add_reachability_to_instances.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20260127100400_create_hashtag_follows.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20260127120000_create_oauth_tables.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20260128142101_add_nodeinfo_to_instances.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20260128144518_drop_subscriptions_table.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20260129145416_create_passkey_credentials.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20260203150001_create_billing_subscriptions.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20260203160001_create_subscription_products.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20260205100001_add_pgp_wkd_hash_to_users.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20260209020000_remove_ai_pentest_feature.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20260215110000_create_password_vault_entries.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20260215123000_create_user_mutes.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20260215124000_create_developer_webhooks.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20260215130000_create_messaging_servers.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20260215141000_add_federation_fields_to_messaging_servers.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20260215152000_add_messaging_federation_event_stream_tables.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20260215165000_add_messaging_federation_outbox_and_archive.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20260216190000_create_api_token_revocations.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20260218090000_create_email_suppressions.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20260218123000_add_bluesky_bridge_fields.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20260218133000_add_bluesky_inbound_tracking.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20260222120000_create_chat_user_hidden_messages.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20260222130000_create_messaging_federation_peer_policies.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20260222143000_add_arblarg_replay_and_idempotency.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20260223010000_backfill_server_channel_visibility.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20260223101500_create_password_vault_settings.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20260223143000_add_arbp_chat_projections.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20260224083500_create_imap_subscriptions.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20260224105000_add_messages_activitypub_url_lookup_index.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20260224112000_add_hot_path_feed_and_email_indexes.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20260224124500_add_activitypub_actor_type_hot_path_index.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20260303110000_create_developer_webhook_deliveries.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20260305100000_create_email_category_preferences.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20260308100000_add_arbp_fast_path_projections.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20260308113000_create_messaging_federation_discovered_peers.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20260308120000_add_arbp_presence_expiry_and_membership_states.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20260308121000_add_continuity_fields_to_messaging_federation_discovered_peers.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20260308133000_create_email_custom_domains.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20260308173000_add_dkim_fields_to_email_custom_domains.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20260308174500_relax_dkim_constraints_on_email_custom_domains.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20260308210000_create_messaging_federation_invite_states.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20260309101500_scope_messaging_federation_event_ids_by_origin.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20260310120000_hash_recovery_tokens.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20260310153000_add_private_mailbox_storage.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20260310170000_add_endpoint_host_and_client_mtu_to_vpn_servers.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20260311103000_add_canonical_activitypub_refs_to_messages.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20260311112000_backfill_messages_canonical_activitypub_refs.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20260314120000_create_jmap_email_tombstones.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20260314133000_create_jmap_email_changes.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20260314153000_normalize_invite_codes_case_insensitively.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20260314170000_add_stripe_customer_id_to_users.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20260314193000_add_one_time_support_to_subscription_products.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20260314195000_create_registration_checkouts.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20260314203000_normalize_post_views_for_trust_tracking.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20260315100000_create_messaging_federation_account_presence_states.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20260315113000_add_federated_fields_to_community_bans.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20260315133000_create_messaging_federation_room_presence_states.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20260316091500_create_messaging_federation_call_sessions.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20260316143000_create_profile_custom_domains.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20260320074000_add_db_hot_path_indexes.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20260320100000_add_redemption_fields_to_registration_checkouts.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20260322194500_create_dns_zones_and_records.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20260322200000_add_dns_zone_soa_fields.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20260322203000_remove_dns_verification_fields.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20260323033500_dedupe_activitypub_instances_and_enforce_ci_uniqueness.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20260323120000_add_oidc_fields_to_oauth_authorizations.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20260323123000_create_oidc_signing_keys.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20260323124000_add_oidc_fields_to_oauth_tokens.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20260326143000_add_admin_email_message_indexes.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20260327121000_add_managed_dns_services.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20260329173000_add_dnssec_and_mail_hardening_fields_to_dns_records.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20260331110000_create_personal_files.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20260331123000_add_expires_at_to_file_shares.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20260331150000_create_stored_folders.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20260331151000_add_access_and_password_to_file_shares.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20260402110000_add_theme_overrides_to_users.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20260404121000_hash_oauth_secrets_and_tokens.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20260404133000_add_auth_valid_after_to_users.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20260404143000_hash_export_and_registration_tokens.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20260404180000_create_dns_query_stats.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20260405120000_add_link_preview_to_chat_messages.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20260405143000_create_profile_site_visits.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20260407120000_add_force_https_to_dns_zones.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20260409130000_split_chat_conversations_from_social_spaces.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20260409143000_repoint_chat_foreign_keys_to_chat_conversations.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20260409153000_create_chat_moderation_tables_and_safety_checks.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20260409154500_delete_legacy_chat_conversations.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20260411120000_add_mastodon_poll_fields.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20260412214000_add_messages_reply_to_inserted_at_id_index.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20260416143000_add_federation_and_imap_hot_path_indexes.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20260418165000_add_built_in_subdomain_mode_to_users.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20260420110000_create_notes.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20260420121500_create_note_shares.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20260421103000_add_protocol_to_vpn_servers.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20260421120000_allow_multiple_vpn_protocols_per_public_ip.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20260422163800_rename_drive_tables.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20260422165500_rename_social_messaging_tables.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20260422170500_restore_conversations_table_name.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20260422171500_rename_conversations_to_social_conversations.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20260422174500_rename_email_tables.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20260425000000_add_email_mailboxes_username_ci_unique.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20260425120000_add_encrypted_payload_to_note_shares.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20260425121000_add_pastebin_options_to_note_shares.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20260425122000_add_burn_after_read_to_drive_shares.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20260425133000_add_hourly_dns_query_stats.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20260425143000_add_alias_rules_engine_fields.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20260425151000_add_global_email_unsubscribe_unique_index.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20260425153000_allow_community_bans_for_chat_conversations.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20260425193000_add_legacy_activitypub_ref_indexes_to_social_messages.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20260425210000_add_literal_legacy_activitypub_ref_indexes.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20260426190000_add_hot_path_social_feed_indexes.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20260426193000_add_broader_social_surface_indexes.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20260428120000_create_site_page_visits.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20260428121000_add_site_sessions.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20260430031000_create_atomine_proofs.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20260430032000_add_claim_fields_to_atomine_proofs.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20260430033000_add_live_mode_to_atomine_proofs.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20260501090000_create_atomine_trust_sessions.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20260501091000_create_connected_accounts.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20260502120000_create_atomine_attestations.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20260502121000_add_global_dns_proof_uniqueness.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20260503000000_update_oban_to_v14.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20260503120000_create_atomine_credits.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20260503132000_add_chat_messages_search_index_gin.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20260503134000_add_chat_client_encryption.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20260503135000_add_chat_remote_encryption_devices.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20260503170000_add_activitypub_request_replays.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20260503171000_add_encrypted_password_vault_metadata.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20260503172000_add_chat_encryption_device_trust_fields.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20260503173000_add_mailbox_category_filter_settings.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20260504120000_backfill_chat_conversation_hashes.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20260507120000_change_activitypub_actor_profile_fields_to_text.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20260509120000_ensure_fast_federated_timeline_index.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20260510120000_encrypt_notes_content.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20260513011500_create_external_email_deliveries.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20260513013000_expand_external_email_deliveries.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20260513020000_create_external_email_ops_tables.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20260513021000_create_internal_email_deliveries.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20260516100000_add_db_indexes_for_site_analytics_and_oban_queue_stats.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20260516110000_rename_password_vault_to_nerve.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20260517043000_add_site_session_rollup_indexes_for_analytics.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20260518120000_add_global_atomine_proof_claim_grant_index.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20260520061000_create_static_site_deployments.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20260520062000_add_github_webhook_fields_to_static_site_deployments.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20260523214500_rename_atomine_shield_to_gate_in_dns_metadata.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20260601000100_default_built_in_subdomain_mode_to_path.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20260603180000_restore_existing_platform_profile_subdomains.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20260604120000_add_spam_filter_enabled_to_mailboxes.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20260604121000_add_auto_reply_enabled_to_mailboxes.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20260606170000_add_oban_and_timeline_hot_path_indexes.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20260613000000_add_failing_since_to_custom_domains.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20260620000000_backfill_orphaned_thread_messages.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20260621000000_create_uptime_monitors.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20260621000100_create_uptime_checks.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20260621000200_create_uptime_incidents.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20260623000000_add_domain_identity_to_oauth_grants.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20260623000100_create_profile_per_site_identities.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20260623000200_hash_and_encrypt_share_tokens.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20260623000300_encrypt_webhook_secrets.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20260623000400_hash_email_unsubscribe_tokens.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20260623000500_hash_and_encrypt_device_tokens.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20260628000000_create_kairo_tables.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20260628120000_create_account_master_keys.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20260628130000_add_kairo_source_encryption.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20260629120000_add_kairo_content_at_rest.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20260629140000_drop_notes_tables.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20260630000000_add_remote_engagement_counts_to_social_messages.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20260630001000_create_social_message_stats.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20260630001100_guard_boosts_against_deleted_messages.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20260630001200_create_activitypub_tombstones.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20260701000000_create_activitypub_object_deliveries.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20260701000100_create_social_thread_mutes.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20260701000200_add_group_key_to_notifications.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20260701000300_create_api_markers.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20260701000400_create_social_filters.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20260701000500_add_expiration_to_user_mutes.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20260701000600_create_account_notes.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20260701000700_create_bookmark_folders.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20260701000800_create_web_push_subscriptions.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20260701000900_create_suggested_account_dismissals.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20260701001000_create_account_endorsements.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20260701001100_create_account_subscriptions.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20260701001200_create_client_app_settings.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20260701001300_create_social_scrobbles.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20260701001400_add_birthday_fields_to_users.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20260701001500_add_account_migration_metadata_to_users.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20260701001600_add_notification_privacy_settings_to_users.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20260701001700_add_account_list_privacy_settings_to_users.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20260704100001_create_messaging_channel_categories.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20260704100002_create_chat_message_pins.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20260704120001_create_chat_threads.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20260704120002_add_thread_id_to_chat_messages.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20260704130001_create_chat_webhooks.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20260704130002_add_webhook_id_to_chat_messages.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20260704130003_add_is_bot_to_users.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20260704140000_kairo_dedup_and_project_nilify.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20260704150000_add_profile_editing_deploy_link_image_features.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20260704160000_create_search_domain_rules.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20260704170000_create_user_sessions.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20260705000000_add_social_messages_community_actor_uri_id_desc_index.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20260706000000_add_nameserver_set_to_dns_zones.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20260707000000_create_social_recommendation_items.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20260708061000_add_site_session_referrer_index_for_analytics.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20260708062000_create_public_site_analytics_rollups.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20260708094000_dedupe_hashtags_by_normalized_name.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20260708100000_add_social_messages_remote_actor_feed_index.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20260710000000_add_federated_timeline_view_indexes.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20260710010000_create_web_index.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20260712010000_add_encrypted_raw_source_to_email_messages.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20260712090000_add_theme_mode_to_users.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20260725120000_create_link_archives.exs
create mode 100644 apps/elektrine/priv/repo/migrations/20260725130000_create_booking_pages.exs
create mode 100644 apps/elektrine/priv/repo/seed_fediverse_posts.exs
create mode 100644 apps/elektrine/priv/repo/seed_platform_demo_screenshots.exs
create mode 100644 apps/elektrine/priv/repo/seed_test_emails.exs
create mode 100644 apps/elektrine/priv/repo/seeds.exs
create mode 100644 apps/elektrine/priv/repo/seeds_invite_codes.exs
create mode 100644 apps/elektrine/priv/repo/test_recategorizer.exs
create mode 100644 apps/elektrine/priv/static/favicon.ico
create mode 100644 apps/elektrine/priv/static/fonts/Geist-Black.ttf
create mode 100644 apps/elektrine/priv/static/fonts/Geist-BlackItalic.ttf
create mode 100644 apps/elektrine/priv/static/fonts/Geist-Bold.ttf
create mode 100644 apps/elektrine/priv/static/fonts/Geist-BoldItalic.ttf
create mode 100644 apps/elektrine/priv/static/fonts/Geist-ExtraBold.ttf
create mode 100644 apps/elektrine/priv/static/fonts/Geist-ExtraBoldItalic.ttf
create mode 100644 apps/elektrine/priv/static/fonts/Geist-ExtraLight.ttf
create mode 100644 apps/elektrine/priv/static/fonts/Geist-ExtraLightItalic.ttf
create mode 100644 apps/elektrine/priv/static/fonts/Geist-Italic-cyrillic-ext.woff2
create mode 100644 apps/elektrine/priv/static/fonts/Geist-Italic-cyrillic.woff2
create mode 100644 apps/elektrine/priv/static/fonts/Geist-Italic-latin-ext.woff2
create mode 100644 apps/elektrine/priv/static/fonts/Geist-Italic-latin.woff2
create mode 100644 apps/elektrine/priv/static/fonts/Geist-Italic-vietnamese.woff2
create mode 100644 apps/elektrine/priv/static/fonts/Geist-Italic.ttf
create mode 100644 apps/elektrine/priv/static/fonts/Geist-Light.ttf
create mode 100644 apps/elektrine/priv/static/fonts/Geist-LightItalic.ttf
create mode 100644 apps/elektrine/priv/static/fonts/Geist-Medium.ttf
create mode 100644 apps/elektrine/priv/static/fonts/Geist-MediumItalic.ttf
create mode 100644 apps/elektrine/priv/static/fonts/Geist-Regular.ttf
create mode 100644 apps/elektrine/priv/static/fonts/Geist-SemiBold.ttf
create mode 100644 apps/elektrine/priv/static/fonts/Geist-SemiBoldItalic.ttf
create mode 100644 apps/elektrine/priv/static/fonts/Geist-Thin.ttf
create mode 100644 apps/elektrine/priv/static/fonts/Geist-ThinItalic.ttf
create mode 100644 apps/elektrine/priv/static/fonts/Geist-cyrillic-ext.woff2
create mode 100644 apps/elektrine/priv/static/fonts/Geist-cyrillic.woff2
create mode 100644 apps/elektrine/priv/static/fonts/Geist-latin-ext.woff2
create mode 100644 apps/elektrine/priv/static/fonts/Geist-latin.woff2
create mode 100644 apps/elektrine/priv/static/fonts/Geist-vietnamese.woff2
create mode 100644 apps/elektrine/priv/static/fonts/GeistMono-Black.ttf
create mode 100644 apps/elektrine/priv/static/fonts/GeistMono-BlackItalic.ttf
create mode 100644 apps/elektrine/priv/static/fonts/GeistMono-Bold.ttf
create mode 100644 apps/elektrine/priv/static/fonts/GeistMono-BoldItalic.ttf
create mode 100644 apps/elektrine/priv/static/fonts/GeistMono-ExtraBold.ttf
create mode 100644 apps/elektrine/priv/static/fonts/GeistMono-ExtraBoldItalic.ttf
create mode 100644 apps/elektrine/priv/static/fonts/GeistMono-ExtraLight.ttf
create mode 100644 apps/elektrine/priv/static/fonts/GeistMono-ExtraLightItalic.ttf
create mode 100644 apps/elektrine/priv/static/fonts/GeistMono-Italic-cyrillic-ext.woff2
create mode 100644 apps/elektrine/priv/static/fonts/GeistMono-Italic-cyrillic.woff2
create mode 100644 apps/elektrine/priv/static/fonts/GeistMono-Italic-latin-ext.woff2
create mode 100644 apps/elektrine/priv/static/fonts/GeistMono-Italic-latin.woff2
create mode 100644 apps/elektrine/priv/static/fonts/GeistMono-Italic-symbols2.woff2
create mode 100644 apps/elektrine/priv/static/fonts/GeistMono-Italic-vietnamese.woff2
create mode 100644 apps/elektrine/priv/static/fonts/GeistMono-Italic.ttf
create mode 100644 apps/elektrine/priv/static/fonts/GeistMono-Light.ttf
create mode 100644 apps/elektrine/priv/static/fonts/GeistMono-LightItalic.ttf
create mode 100644 apps/elektrine/priv/static/fonts/GeistMono-Medium.ttf
create mode 100644 apps/elektrine/priv/static/fonts/GeistMono-MediumItalic.ttf
create mode 100644 apps/elektrine/priv/static/fonts/GeistMono-Regular.ttf
create mode 100644 apps/elektrine/priv/static/fonts/GeistMono-SemiBold.ttf
create mode 100644 apps/elektrine/priv/static/fonts/GeistMono-SemiBoldItalic.ttf
create mode 100644 apps/elektrine/priv/static/fonts/GeistMono-Thin.ttf
create mode 100644 apps/elektrine/priv/static/fonts/GeistMono-ThinItalic.ttf
create mode 100644 apps/elektrine/priv/static/fonts/GeistMono-cyrillic-ext.woff2
create mode 100644 apps/elektrine/priv/static/fonts/GeistMono-cyrillic.woff2
create mode 100644 apps/elektrine/priv/static/fonts/GeistMono-latin-ext.woff2
create mode 100644 apps/elektrine/priv/static/fonts/GeistMono-latin.woff2
create mode 100644 apps/elektrine/priv/static/fonts/GeistMono-symbols2.woff2
create mode 100644 apps/elektrine/priv/static/fonts/GeistMono-vietnamese.woff2
create mode 100644 apps/elektrine/priv/static/fonts/GeistPixel-Regular-VariableFont_ELSH.ttf
create mode 100644 apps/elektrine/priv/static/fonts/GeistPixel-latin-ext.woff2
create mode 100644 apps/elektrine/priv/static/fonts/GeistPixel-latin.woff2
create mode 100644 apps/elektrine/priv/static/images/android-chrome-192x192.png
create mode 100644 apps/elektrine/priv/static/images/favicon-16x16.png
create mode 100644 apps/elektrine/priv/static/images/favicon-32x32.png
create mode 100644 apps/elektrine/priv/static/images/favicon.ico
create mode 100644 apps/elektrine/priv/static/images/home/.gitkeep
create mode 100644 apps/elektrine/priv/static/images/home/buttons/.gitkeep
create mode 100644 apps/elektrine/priv/static/images/home/buttons/button-01.gif
create mode 100644 apps/elektrine/priv/static/images/home/buttons/button-02.gif
create mode 100644 apps/elektrine/priv/static/images/home/buttons/button-03.gif
create mode 100644 apps/elektrine/priv/static/images/home/buttons/button-04.gif
create mode 100644 apps/elektrine/priv/static/images/home/buttons/button-05.gif
create mode 100644 apps/elektrine/priv/static/images/home/buttons/button-06.gif
create mode 100644 apps/elektrine/priv/static/images/home/buttons/button-07.gif
create mode 100644 apps/elektrine/priv/static/images/home/buttons/button-08.gif
create mode 100644 apps/elektrine/priv/static/images/home/poster-01.jpg
create mode 100644 apps/elektrine/priv/static/images/mark.png
create mode 100644 apps/elektrine/priv/static/openapi/ext-v1.yaml
create mode 100644 apps/elektrine/priv/static/robots.txt
create mode 100644 apps/elektrine/priv/static/sw.js
create mode 100755 apps/elektrine/rel/env.sh.eex
create mode 100755 apps/elektrine/rel/overlays/bin/migrate
create mode 100755 apps/elektrine/rel/overlays/bin/migrate.bat
create mode 100755 apps/elektrine/rel/overlays/bin/server
create mode 100755 apps/elektrine/rel/overlays/bin/server.bat
create mode 100644 apps/elektrine/test/elektrine/account_identifiers_test.exs
create mode 100644 apps/elektrine/test/elektrine/accounts/account_import_worker_test.exs
create mode 100644 apps/elektrine/test/elektrine/accounts/account_notes_test.exs
create mode 100644 apps/elektrine/test/elektrine/accounts/app_password_test.exs
create mode 100644 apps/elektrine/test/elektrine/accounts/capabilities_test.exs
create mode 100644 apps/elektrine/test/elektrine/accounts/connected_account_test.exs
create mode 100644 apps/elektrine/test/elektrine/accounts/domain_blocks_test.exs
create mode 100644 apps/elektrine/test/elektrine/accounts/invite_codes_test.exs
create mode 100644 apps/elektrine/test/elektrine/accounts/muting_test.exs
create mode 100644 apps/elektrine/test/elektrine/accounts/passkeys_test.exs
create mode 100644 apps/elektrine/test/elektrine/accounts/recovery_email_verification_test.exs
create mode 100644 apps/elektrine/test/elektrine/accounts/storage_test.exs
create mode 100644 apps/elektrine/test/elektrine/accounts/trust_level_test.exs
create mode 100644 apps/elektrine/test/elektrine/accounts/two_factor_test.exs
create mode 100644 apps/elektrine/test/elektrine/accounts/user_test.exs
create mode 100644 apps/elektrine/test/elektrine/accounts_privacy_test.exs
create mode 100644 apps/elektrine/test/elektrine/accounts_status_test.exs
create mode 100644 apps/elektrine/test/elektrine/accounts_test.exs
create mode 100644 apps/elektrine/test/elektrine/acme/wildcard_renewal_worker_test.exs
create mode 100644 apps/elektrine/test/elektrine/activitypub/activity_delivery_worker_test.exs
create mode 100644 apps/elektrine/test/elektrine/activitypub/actor_secrets_test.exs
create mode 100644 apps/elektrine/test/elektrine/activitypub/actor_url_security_test.exs
create mode 100644 apps/elektrine/test/elektrine/activitypub/boundary_guardrails_test.exs
create mode 100644 apps/elektrine/test/elektrine/activitypub/collection_fetcher_test.exs
create mode 100644 apps/elektrine/test/elektrine/activitypub/community_fetcher_test.exs
create mode 100644 apps/elektrine/test/elektrine/activitypub/domain_migration_test.exs
create mode 100644 apps/elektrine/test/elektrine/activitypub/fetch_remote_post_replies_test.exs
create mode 100644 apps/elektrine/test/elektrine/activitypub/fetch_remote_user_timeline_test.exs
create mode 100644 apps/elektrine/test/elektrine/activitypub/fetcher_test.exs
create mode 100644 apps/elektrine/test/elektrine/activitypub/get_or_fetch_actor_test.exs
create mode 100644 apps/elektrine/test/elektrine/activitypub/handler_test.exs
create mode 100644 apps/elektrine/test/elektrine/activitypub/handlers/announce_handler_test.exs
create mode 100644 apps/elektrine/test/elektrine/activitypub/handlers/create_handler_test.exs
create mode 100644 apps/elektrine/test/elektrine/activitypub/handlers/delete_handler_test.exs
create mode 100644 apps/elektrine/test/elektrine/activitypub/handlers/flag_handler_test.exs
create mode 100644 apps/elektrine/test/elektrine/activitypub/handlers/follow_handler_test.exs
create mode 100644 apps/elektrine/test/elektrine/activitypub/handlers/like_handler_test.exs
create mode 100644 apps/elektrine/test/elektrine/activitypub/handlers/move_handler_test.exs
create mode 100644 apps/elektrine/test/elektrine/activitypub/handlers/update_handler_test.exs
create mode 100644 apps/elektrine/test/elektrine/activitypub/helpers_test.exs
create mode 100644 apps/elektrine/test/elektrine/activitypub/http_signature_test.exs
create mode 100644 apps/elektrine/test/elektrine/activitypub/instances_test.exs
create mode 100644 apps/elektrine/test/elektrine/activitypub/lemmy_api_test.exs
create mode 100644 apps/elektrine/test/elektrine/activitypub/lemmy_comment_backfill_test.exs
create mode 100644 apps/elektrine/test/elektrine/activitypub/mastodon_api_test.exs
create mode 100644 apps/elektrine/test/elektrine/activitypub/mentions_test.exs
create mode 100644 apps/elektrine/test/elektrine/activitypub/mrf/anti_followbot_policy_test.exs
create mode 100644 apps/elektrine/test/elektrine/activitypub/mrf/hellthread_policy_test.exs
create mode 100644 apps/elektrine/test/elektrine/activitypub/mrf/keyword_policy_test.exs
create mode 100644 apps/elektrine/test/elektrine/activitypub/mrf/pleroma_policy_batch_test.exs
create mode 100644 apps/elektrine/test/elektrine/activitypub/mrf/simple_policy_test.exs
create mode 100644 apps/elektrine/test/elektrine/activitypub/nodeinfo_fetcher_worker_security_test.exs
create mode 100644 apps/elektrine/test/elektrine/activitypub/normalizer_test.exs
create mode 100644 apps/elektrine/test/elektrine/activitypub/object_validator_test.exs
create mode 100644 apps/elektrine/test/elektrine/activitypub/outbox_test.exs
create mode 100644 apps/elektrine/test/elektrine/activitypub/pipeline_test.exs
create mode 100644 apps/elektrine/test/elektrine/activitypub/process_activity_worker_test.exs
create mode 100644 apps/elektrine/test/elektrine/activitypub/publisher_test.exs
create mode 100644 apps/elektrine/test/elektrine/activitypub/refresh_counts_worker_test.exs
create mode 100644 apps/elektrine/test/elektrine/activitypub/relay_test.exs
create mode 100644 apps/elektrine/test/elektrine/activitypub/replies_fetcher_test.exs
create mode 100644 apps/elektrine/test/elektrine/activitypub/replies_ingest_worker_test.exs
create mode 100644 apps/elektrine/test/elektrine/activitypub/reply_fetch_policy_test.exs
create mode 100644 apps/elektrine/test/elektrine/activitypub/signature_retry_worker_test.exs
create mode 100644 apps/elektrine/test/elektrine/activitypub/signature_verification_test.exs
create mode 100644 apps/elektrine/test/elektrine/activitypub/thread_backfill_worker_test.exs
create mode 100644 apps/elektrine/test/elektrine/activitypub/undo_resolver_test.exs
create mode 100644 apps/elektrine/test/elektrine/admin_test.exs
create mode 100644 apps/elektrine/test/elektrine/atomine_proof_graph_test.exs
create mode 100644 apps/elektrine/test/elektrine/bluesky_inbound_test.exs
create mode 100644 apps/elektrine/test/elektrine/bluesky_managed_test.exs
create mode 100644 apps/elektrine/test/elektrine/bluesky_test.exs
create mode 100644 apps/elektrine/test/elektrine/booking_confirmation_test.exs
create mode 100644 apps/elektrine/test/elektrine/booking_test.exs
create mode 100644 apps/elektrine/test/elektrine/calendar/availability_test.exs
create mode 100644 apps/elektrine/test/elektrine/calendar/availability_zone_test.exs
create mode 100644 apps/elektrine/test/elektrine/calendar/busy_blocks_test.exs
create mode 100644 apps/elektrine/test/elektrine/calendar/calendar_test.exs
create mode 100644 apps/elektrine/test/elektrine/calendar/icalendar_test.exs
create mode 100644 apps/elektrine/test/elektrine/calls/transport_test.exs
create mode 100644 apps/elektrine/test/elektrine/calls_test.exs
create mode 100644 apps/elektrine/test/elektrine/captcha_test.exs
create mode 100644 apps/elektrine/test/elektrine/db/write_guard_test.exs
create mode 100644 apps/elektrine/test/elektrine/developer/exports/account_exporter_test.exs
create mode 100644 apps/elektrine/test/elektrine/developer/webhook_delivery_worker_test.exs
create mode 100644 apps/elektrine/test/elektrine/developer_exports_test.exs
create mode 100644 apps/elektrine/test/elektrine/developer_tokens_test.exs
create mode 100644 apps/elektrine/test/elektrine/developer_webhooks_test.exs
create mode 100644 apps/elektrine/test/elektrine/discord_test.exs
create mode 100644 apps/elektrine/test/elektrine/domains_test.exs
create mode 100644 apps/elektrine/test/elektrine/drive_test.exs
create mode 100644 apps/elektrine/test/elektrine/emojis_test.exs
create mode 100644 apps/elektrine/test/elektrine/encrypted_search_test.exs
create mode 100644 apps/elektrine/test/elektrine/encryption_test.exs
create mode 100644 apps/elektrine/test/elektrine/friends_test.exs
create mode 100644 apps/elektrine/test/elektrine/home_blog_test.exs
create mode 100644 apps/elektrine/test/elektrine/http/safe_fetch_test.exs
create mode 100644 apps/elektrine/test/elektrine/input_test.exs
create mode 100644 apps/elektrine/test/elektrine/job_queue_test.exs
create mode 100644 apps/elektrine/test/elektrine/link_archive_test.exs
create mode 100644 apps/elektrine/test/elektrine/mail_client_settings_test.exs
create mode 100644 apps/elektrine/test/elektrine/mailer/delivery_worker_test.exs
create mode 100644 apps/elektrine/test/elektrine/markers_test.exs
create mode 100644 apps/elektrine/test/elektrine/messaging/activitypub_ref_lookup_test.exs
create mode 100644 apps/elektrine/test/elektrine/messaging/arblarg_extension_conformance_test.exs
create mode 100644 apps/elektrine/test/elektrine/messaging/arblarg_golden_vectors_test.exs
create mode 100644 apps/elektrine/test/elektrine/messaging/arblarg_profiles_test.exs
create mode 100644 apps/elektrine/test/elektrine/messaging/arblarg_sdk_test.exs
create mode 100644 apps/elektrine/test/elektrine/messaging/channel_categories_test.exs
create mode 100644 apps/elektrine/test/elektrine/messaging/chat_conversations_credits_test.exs
create mode 100644 apps/elektrine/test/elektrine/messaging/chat_conversations_test.exs
create mode 100644 apps/elektrine/test/elektrine/messaging/chat_message_pins_test.exs
create mode 100644 apps/elektrine/test/elektrine/messaging/chat_messages_test.exs
create mode 100644 apps/elektrine/test/elektrine/messaging/chat_threads_test.exs
create mode 100644 apps/elektrine/test/elektrine/messaging/chat_webhooks_test.exs
create mode 100644 apps/elektrine/test/elektrine/messaging/custom_domain_aliases_test.exs
create mode 100644 apps/elektrine/test/elektrine/messaging/federated_message_normalization_test.exs
create mode 100644 apps/elektrine/test/elektrine/messaging/federation/config_test.exs
create mode 100644 apps/elektrine/test/elektrine/messaging/federation_dm_test.exs
create mode 100644 apps/elektrine/test/elektrine/messaging/federation_errors_test.exs
create mode 100644 apps/elektrine/test/elektrine/messaging/federation_ingress_rate_limiter_test.exs
create mode 100644 apps/elektrine/test/elektrine/messaging/federation_outbox_worker_test.exs
create mode 100644 apps/elektrine/test/elektrine/messaging/federation_peer_discovery_test.exs
create mode 100644 apps/elektrine/test/elektrine/messaging/federation_peer_policy_test.exs
create mode 100644 apps/elektrine/test/elektrine/messaging/federation_pins_test.exs
create mode 100644 apps/elektrine/test/elektrine/messaging/federation_protocol_test.exs
create mode 100644 apps/elektrine/test/elektrine/messaging/federation_remote_join_test.exs
create mode 100644 apps/elektrine/test/elektrine/messaging/federation_room_participation_test.exs
create mode 100644 apps/elektrine/test/elektrine/messaging/federation_session_websock_test.exs
create mode 100644 apps/elektrine/test/elektrine/messaging/federation_test.exs
create mode 100644 apps/elektrine/test/elektrine/messaging/federation_threads_test.exs
create mode 100644 apps/elektrine/test/elektrine/messaging/messages_mentions_test.exs
create mode 100644 apps/elektrine/test/elektrine/messaging/messages_test.exs
create mode 100644 apps/elektrine/test/elektrine/messaging/moderation_tools_test.exs
create mode 100644 apps/elektrine/test/elektrine/messaging/reference_peer_interop_test.exs
create mode 100644 apps/elektrine/test/elektrine/messaging/servers_test.exs
create mode 100644 apps/elektrine/test/elektrine/messaging/slash_commands_test.exs
create mode 100644 apps/elektrine/test/elektrine/messaging/social_message_security_test.exs
create mode 100644 apps/elektrine/test/elektrine/messaging/voice_channels_test.exs
create mode 100644 apps/elektrine/test/elektrine/messaging_encryption_test.exs
create mode 100644 apps/elektrine/test/elektrine/notifications/federation_notifications_test.exs
create mode 100644 apps/elektrine/test/elektrine/notifications_test.exs
create mode 100644 apps/elektrine/test/elektrine/oauth/app_test.exs
create mode 100644 apps/elektrine/test/elektrine/paths_test.exs
create mode 100644 apps/elektrine/test/elektrine/platform/modules_test.exs
create mode 100644 apps/elektrine/test/elektrine/platform/runtime_config_validator_test.exs
create mode 100644 apps/elektrine/test/elektrine/privacy_test.exs
create mode 100644 apps/elektrine/test/elektrine/profile_custom_domains_test.exs
create mode 100644 apps/elektrine/test/elektrine/profile_per_site_identities_test.exs
create mode 100644 apps/elektrine/test/elektrine/profiles/profile_widget_test.exs
create mode 100644 apps/elektrine/test/elektrine/profiles/static_site_deployment_test.exs
create mode 100644 apps/elektrine/test/elektrine/profiles_test.exs
create mode 100644 apps/elektrine/test/elektrine/push/web_push_client_test.exs
create mode 100644 apps/elektrine/test/elektrine/push_test.exs
create mode 100644 apps/elektrine/test/elektrine/reports_test.exs
create mode 100644 apps/elektrine/test/elektrine/rss/fetch_feed_worker_test.exs
create mode 100644 apps/elektrine/test/elektrine/rss/parser_test.exs
create mode 100644 apps/elektrine/test/elektrine/rss_test.exs
create mode 100644 apps/elektrine/test/elektrine/runtime_env_test.exs
create mode 100644 apps/elektrine/test/elektrine/search/domain_rules_test.exs
create mode 100644 apps/elektrine/test/elektrine/search/paige_rate_limiter_test.exs
create mode 100644 apps/elektrine/test/elektrine/search_test.exs
create mode 100644 apps/elektrine/test/elektrine/secrets/backfill_test.exs
create mode 100644 apps/elektrine/test/elektrine/secrets/encrypted_string_test.exs
create mode 100644 apps/elektrine/test/elektrine/security/safe_external_url_test.exs
create mode 100644 apps/elektrine/test/elektrine/security/url_validator_test.exs
create mode 100644 apps/elektrine/test/elektrine/security_alerts_test.exs
create mode 100644 apps/elektrine/test/elektrine/social/engagement_counts_test.exs
create mode 100644 apps/elektrine/test/elektrine/social/expired_data_cleanup_worker_test.exs
create mode 100644 apps/elektrine/test/elektrine/static_sites/github_deploy_worker_security_test.exs
create mode 100644 apps/elektrine/test/elektrine/static_sites_test.exs
create mode 100644 apps/elektrine/test/elektrine/strings_test.exs
create mode 100644 apps/elektrine/test/elektrine/subscriptions_test.exs
create mode 100644 apps/elektrine/test/elektrine/theme_test.exs
create mode 100644 apps/elektrine/test/elektrine/uploads_test.exs
create mode 100644 apps/elektrine/test/elektrine/vault_test.exs
create mode 100644 apps/elektrine/test/elektrine/web_index/extractor_test.exs
create mode 100644 apps/elektrine/test/elektrine/web_index/fetcher_test.exs
create mode 100644 apps/elektrine/test/elektrine/web_index/robots_test.exs
create mode 100644 apps/elektrine/test/elektrine/web_index_test.exs
create mode 100644 apps/elektrine/test/support/channel_case.ex
create mode 100644 apps/elektrine/test/support/conn_case.ex
create mode 100644 apps/elektrine/test/support/data_case.ex
create mode 100644 apps/elektrine/test/support/feature_case.ex
create mode 100644 apps/elektrine/test/support/fixtures/accounts_fixtures.ex
create mode 100644 apps/elektrine/test/support/fixtures/email_fixtures.ex
create mode 100644 apps/elektrine/test/support/fixtures/social_fixtures.ex
create mode 100644 apps/elektrine/test/test_helper.exs
create mode 100644 apps/elektrine_dns/README.md
create mode 100644 apps/elektrine_dns/lib/elektrine/dns.ex
create mode 100644 apps/elektrine_dns/lib/elektrine/dns/application.ex
create mode 100644 apps/elektrine_dns/lib/elektrine/dns/authority.ex
create mode 100644 apps/elektrine_dns/lib/elektrine/dns/domain_health.ex
create mode 100644 apps/elektrine_dns/lib/elektrine/dns/generators/bluesky.ex
create mode 100644 apps/elektrine_dns/lib/elektrine/dns/generators/mail.ex
create mode 100644 apps/elektrine_dns/lib/elektrine/dns/generators/turn.ex
create mode 100644 apps/elektrine_dns/lib/elektrine/dns/generators/vpn.ex
create mode 100644 apps/elektrine_dns/lib/elektrine/dns/generators/web.ex
create mode 100644 apps/elektrine_dns/lib/elektrine/dns/health_monitor.ex
create mode 100644 apps/elektrine_dns/lib/elektrine/dns/mail_security.ex
create mode 100644 apps/elektrine_dns/lib/elektrine/dns/managed_records.ex
create mode 100644 apps/elektrine_dns/lib/elektrine/dns/packet.ex
create mode 100644 apps/elektrine_dns/lib/elektrine/dns/profile_wildcard_bootstrap.ex
create mode 100644 apps/elektrine_dns/lib/elektrine/dns/query.ex
create mode 100644 apps/elektrine_dns/lib/elektrine/dns/query_stat.ex
create mode 100644 apps/elektrine_dns/lib/elektrine/dns/query_stats_buffer.ex
create mode 100644 apps/elektrine_dns/lib/elektrine/dns/record.ex
create mode 100644 apps/elektrine_dns/lib/elektrine/dns/recursive.ex
create mode 100644 apps/elektrine_dns/lib/elektrine/dns/recursive_cache.ex
create mode 100644 apps/elektrine_dns/lib/elektrine/dns/recursive_transport.ex
create mode 100644 apps/elektrine_dns/lib/elektrine/dns/request_guard.ex
create mode 100644 apps/elektrine_dns/lib/elektrine/dns/service_binding.ex
create mode 100644 apps/elektrine_dns/lib/elektrine/dns/tcp_server.ex
create mode 100644 apps/elektrine_dns/lib/elektrine/dns/udp_server.ex
create mode 100644 apps/elektrine_dns/lib/elektrine/dns/zone.ex
create mode 100644 apps/elektrine_dns/lib/elektrine/dns/zone_cache.ex
create mode 100644 apps/elektrine_dns/lib/elektrine/dns/zone_change_listener.ex
create mode 100644 apps/elektrine_dns/lib/elektrine/dns/zone_service_config.ex
create mode 100644 apps/elektrine_dns/lib/elektrine_dns_web.ex
create mode 100644 apps/elektrine_dns/lib/elektrine_dns_web/components/platform/e_nav.ex
create mode 100644 apps/elektrine_dns/lib/elektrine_dns_web/controllers/api/dns_controller.ex
create mode 100644 apps/elektrine_dns/lib/elektrine_dns_web/live/dns_live/index.ex
create mode 100644 apps/elektrine_dns/lib/mix/tasks/elektrine.dns.ensure_profile_wildcard.ex
create mode 100644 apps/elektrine_dns/mix.exs
create mode 100644 apps/elektrine_dns/test/elektrine/dns/authority_cache_test.exs
create mode 100644 apps/elektrine_dns/test/elektrine/dns/health_monitor_test.exs
create mode 100644 apps/elektrine_dns/test/elektrine/dns/managed_records_test.exs
create mode 100644 apps/elektrine_dns/test/elektrine/dns/packet_test.exs
create mode 100644 apps/elektrine_dns/test/elektrine/dns/profile_wildcard_test.exs
create mode 100644 apps/elektrine_dns/test/elektrine/dns/query_stats_buffer_test.exs
create mode 100644 apps/elektrine_dns/test/elektrine/dns/query_test.exs
create mode 100644 apps/elektrine_dns/test/elektrine/dns/record_health_check_test.exs
create mode 100644 apps/elektrine_dns/test/elektrine/dns/recursive_test.exs
create mode 100644 apps/elektrine_dns/test/elektrine/dns/request_guard_test.exs
create mode 100644 apps/elektrine_dns/test/elektrine/dns/zone_change_listener_test.exs
create mode 100644 apps/elektrine_dns/test/elektrine/dns/zone_verification_test.exs
create mode 100644 apps/elektrine_dns/test/elektrine/dns_analytics_test.exs
create mode 100644 apps/elektrine_dns/test/elektrine/dns_context_test.exs
create mode 100644 apps/elektrine_dns/test/test_helper.exs
create mode 100644 apps/elektrine_email/.gitignore
create mode 100644 apps/elektrine_email/README.md
create mode 100644 apps/elektrine_email/lib/elektrine/email.ex
create mode 100644 apps/elektrine_email/lib/elektrine/email/alias.ex
create mode 100644 apps/elektrine_email/lib/elektrine/email/aliases.ex
create mode 100644 apps/elektrine_email/lib/elektrine/email/attachment_storage.ex
create mode 100644 apps/elektrine_email/lib/elektrine/email/auto_replies.ex
create mode 100644 apps/elektrine_email/lib/elektrine/email/auto_reply.ex
create mode 100644 apps/elektrine_email/lib/elektrine/email/blocked_sender.ex
create mode 100644 apps/elektrine_email/lib/elektrine/email/blocked_senders.ex
create mode 100644 apps/elektrine_email/lib/elektrine/email/cache.ex
create mode 100644 apps/elektrine_email/lib/elektrine/email/cache_hooks.ex
create mode 100644 apps/elektrine_email/lib/elektrine/email/cached.ex
create mode 100644 apps/elektrine_email/lib/elektrine/email/categorizer.ex
create mode 100644 apps/elektrine_email/lib/elektrine/email/category_preference.ex
create mode 100644 apps/elektrine_email/lib/elektrine/email/category_preferences.ex
create mode 100644 apps/elektrine_email/lib/elektrine/email/contact.ex
create mode 100644 apps/elektrine_email/lib/elektrine/email/contact_group.ex
create mode 100644 apps/elektrine_email/lib/elektrine/email/contacts.ex
create mode 100644 apps/elektrine_email/lib/elektrine/email/custom_domain.ex
create mode 100644 apps/elektrine_email/lib/elektrine/email/custom_domain_health_worker.ex
create mode 100644 apps/elektrine_email/lib/elektrine/email/custom_domains.ex
create mode 100644 apps/elektrine_email/lib/elektrine/email/custom_folders.ex
create mode 100644 apps/elektrine_email/lib/elektrine/email/deliverability.ex
create mode 100644 apps/elektrine_email/lib/elektrine/email/dkim.ex
create mode 100644 apps/elektrine_email/lib/elektrine/email/export.ex
create mode 100644 apps/elektrine_email/lib/elektrine/email/exports.ex
create mode 100644 apps/elektrine_email/lib/elektrine/email/external_delivery.ex
create mode 100644 apps/elektrine_email/lib/elektrine/email/external_delivery_alerts.ex
create mode 100644 apps/elektrine_email/lib/elektrine/email/external_delivery_attempt.ex
create mode 100644 apps/elektrine_email/lib/elektrine/email/external_delivery_control.ex
create mode 100644 apps/elektrine_email/lib/elektrine/email/external_delivery_maintenance_worker.ex
create mode 100644 apps/elektrine_email/lib/elektrine/email/external_delivery_metric_snapshot.ex
create mode 100644 apps/elektrine_email/lib/elektrine/email/external_delivery_worker.ex
create mode 100644 apps/elektrine_email/lib/elektrine/email/external_domain_throttle.ex
create mode 100644 apps/elektrine_email/lib/elektrine/email/filter.ex
create mode 100644 apps/elektrine_email/lib/elektrine/email/filters.ex
create mode 100644 apps/elektrine_email/lib/elektrine/email/folder.ex
create mode 100644 apps/elektrine_email/lib/elektrine/email/folders.ex
create mode 100644 apps/elektrine_email/lib/elektrine/email/forwarded_message.ex
create mode 100644 apps/elektrine_email/lib/elektrine/email/haraka_adapter.ex
create mode 100644 apps/elektrine_email/lib/elektrine/email/haraka_admin.ex
create mode 100644 apps/elektrine_email/lib/elektrine/email/haraka_client.ex
create mode 100644 apps/elektrine_email/lib/elektrine/email/header_decoder.ex
create mode 100644 apps/elektrine_email/lib/elektrine/email/header_sanitizer.ex
create mode 100644 apps/elektrine_email/lib/elektrine/email/imap_subscription.ex
create mode 100644 apps/elektrine_email/lib/elektrine/email/imap_subscriptions.ex
create mode 100644 apps/elektrine_email/lib/elektrine/email/inbound_authentication.ex
create mode 100644 apps/elektrine_email/lib/elektrine/email/inbound_routing.ex
create mode 100644 apps/elektrine_email/lib/elektrine/email/internal_delivery.ex
create mode 100644 apps/elektrine_email/lib/elektrine/email/internal_delivery_attempt.ex
create mode 100644 apps/elektrine_email/lib/elektrine/email/internal_delivery_worker.ex
create mode 100644 apps/elektrine_email/lib/elektrine/email/internal_origin.ex
create mode 100644 apps/elektrine_email/lib/elektrine/email/label.ex
create mode 100644 apps/elektrine_email/lib/elektrine/email/labels.ex
create mode 100644 apps/elektrine_email/lib/elektrine/email/list_types.ex
create mode 100644 apps/elektrine_email/lib/elektrine/email/mailbox.ex
create mode 100644 apps/elektrine_email/lib/elektrine/email/mailbox_adapter.ex
create mode 100644 apps/elektrine_email/lib/elektrine/email/mailbox_encryption.ex
create mode 100644 apps/elektrine_email/lib/elektrine/email/mailboxes.ex
create mode 100644 apps/elektrine_email/lib/elektrine/email/message.ex
create mode 100644 apps/elektrine_email/lib/elektrine/email/messages.ex
create mode 100644 apps/elektrine_email/lib/elektrine/email/mime_body_extractor.ex
create mode 100644 apps/elektrine_email/lib/elektrine/email/payload_sanitizer.ex
create mode 100644 apps/elektrine_email/lib/elektrine/email/pgp.ex
create mode 100644 apps/elektrine_email/lib/elektrine/email/pgp_key_cache.ex
create mode 100644 apps/elektrine_email/lib/elektrine/email/processing.ex
create mode 100644 apps/elektrine_email/lib/elektrine/email/rate_limiter.ex
create mode 100644 apps/elektrine_email/lib/elektrine/email/receiver.ex
create mode 100644 apps/elektrine_email/lib/elektrine/email/safe_sender.ex
create mode 100644 apps/elektrine_email/lib/elektrine/email/safe_senders.ex
create mode 100644 apps/elektrine_email/lib/elektrine/email/sanitizer.ex
create mode 100644 apps/elektrine_email/lib/elektrine/email/search.ex
create mode 100644 apps/elektrine_email/lib/elektrine/email/send_email_worker.ex
create mode 100644 apps/elektrine_email/lib/elektrine/email/sender.ex
create mode 100644 apps/elektrine_email/lib/elektrine/email/sender_class.ex
create mode 100644 apps/elektrine_email/lib/elektrine/email/suppression.ex
create mode 100644 apps/elektrine_email/lib/elektrine/email/suppressions.ex
create mode 100644 apps/elektrine_email/lib/elektrine/email/system_delivery.ex
create mode 100644 apps/elektrine_email/lib/elektrine/email/system_delivery_worker.ex
create mode 100644 apps/elektrine_email/lib/elektrine/email/template.ex
create mode 100644 apps/elektrine_email/lib/elektrine/email/templates.ex
create mode 100644 apps/elektrine_email/lib/elektrine/email/unsubscribe.ex
create mode 100644 apps/elektrine_email/lib/elektrine/email/unsubscribes.ex
create mode 100644 apps/elektrine_email/lib/elektrine/email/vcard.ex
create mode 100644 apps/elektrine_email/lib/elektrine/email_config.ex
create mode 100644 apps/elektrine_email/lib/elektrine/imap/append_parser.ex
create mode 100644 apps/elektrine_email/lib/elektrine/imap/commands.ex
create mode 100644 apps/elektrine_email/lib/elektrine/imap/commands/append.ex
create mode 100644 apps/elektrine_email/lib/elektrine/imap/commands/auth.ex
create mode 100644 apps/elektrine_email/lib/elektrine/imap/commands/idle.ex
create mode 100644 apps/elektrine_email/lib/elektrine/imap/commands/mailbox.ex
create mode 100644 apps/elektrine_email/lib/elektrine/imap/commands/message.ex
create mode 100644 apps/elektrine_email/lib/elektrine/imap/commands/search.ex
create mode 100644 apps/elektrine_email/lib/elektrine/imap/commands/shared.ex
create mode 100644 apps/elektrine_email/lib/elektrine/imap/folders.ex
create mode 100644 apps/elektrine_email/lib/elektrine/imap/helpers.ex
create mode 100644 apps/elektrine_email/lib/elektrine/imap/idle_tracker.ex
create mode 100644 apps/elektrine_email/lib/elektrine/imap/rate_limiter.ex
create mode 100644 apps/elektrine_email/lib/elektrine/imap/recent_state.ex
create mode 100644 apps/elektrine_email/lib/elektrine/imap/recent_tracker.ex
create mode 100644 apps/elektrine_email/lib/elektrine/imap/response.ex
create mode 100644 apps/elektrine_email/lib/elektrine/imap/server.ex
create mode 100644 apps/elektrine_email/lib/elektrine/imap/supervisor.ex
create mode 100644 apps/elektrine_email/lib/elektrine/jmap/email_change.ex
create mode 100644 apps/elektrine_email/lib/elektrine/jmap/email_submission.ex
create mode 100644 apps/elektrine_email/lib/elektrine/jmap/email_tombstone.ex
create mode 100644 apps/elektrine_email/lib/elektrine/jmap/jmap.ex
create mode 100644 apps/elektrine_email/lib/elektrine/jmap/state.ex
create mode 100644 apps/elektrine_email/lib/elektrine/jmap/thread.ex
create mode 100644 apps/elektrine_email/lib/elektrine/mail/socket.ex
create mode 100644 apps/elektrine_email/lib/elektrine/pop3/connection_tracker.ex
create mode 100644 apps/elektrine_email/lib/elektrine/pop3/rate_limiter.ex
create mode 100644 apps/elektrine_email/lib/elektrine/pop3/server.ex
create mode 100644 apps/elektrine_email/lib/elektrine/pop3/supervisor.ex
create mode 100644 apps/elektrine_email/lib/elektrine/smtp/rate_limiter.ex
create mode 100644 apps/elektrine_email/lib/elektrine/smtp/send_rate_limiter.ex
create mode 100644 apps/elektrine_email/lib/elektrine/smtp/server.ex
create mode 100644 apps/elektrine_email/lib/elektrine/smtp/supervisor.ex
create mode 100644 apps/elektrine_email/lib/elektrine_email/haraka_inbound_worker.ex
create mode 100644 apps/elektrine_email/lib/elektrine_email/mcp_tools.ex
create mode 100644 apps/elektrine_email/lib/elektrine_email/platform.ex
create mode 100644 apps/elektrine_email/lib/elektrine_email_web.ex
create mode 100644 apps/elektrine_email/lib/elektrine_email_web/components/email/display.ex
create mode 100644 apps/elektrine_email/lib/elektrine_email_web/components/email/sidebar.ex
create mode 100644 apps/elektrine_email/lib/elektrine_email_web/components/platform/elektrine_nav.ex
create mode 100644 apps/elektrine_email/lib/elektrine_email_web/controllers/admin/aliases_controller.ex
create mode 100644 apps/elektrine_email/lib/elektrine_email_web/controllers/admin/aliases_html.ex
create mode 100644 apps/elektrine_email/lib/elektrine_email_web/controllers/admin/aliases_html/aliases.html.heex
create mode 100644 apps/elektrine_email/lib/elektrine_email_web/controllers/admin/aliases_html/forwarded_messages.html.heex
create mode 100644 apps/elektrine_email/lib/elektrine_email_web/controllers/admin/custom_domains_controller.ex
create mode 100644 apps/elektrine_email/lib/elektrine_email_web/controllers/admin/custom_domains_html.ex
create mode 100644 apps/elektrine_email/lib/elektrine_email_web/controllers/admin/custom_domains_html/custom_domains.html.heex
create mode 100644 apps/elektrine_email/lib/elektrine_email_web/controllers/admin/haraka_html.ex
create mode 100644 apps/elektrine_email/lib/elektrine_email_web/controllers/admin/mailboxes_controller.ex
create mode 100644 apps/elektrine_email/lib/elektrine_email_web/controllers/admin/mailboxes_html.ex
create mode 100644 apps/elektrine_email/lib/elektrine_email_web/controllers/admin/mailboxes_html/mailboxes.html.heex
create mode 100644 apps/elektrine_email/lib/elektrine_email_web/controllers/admin/messages_controller.ex
create mode 100644 apps/elektrine_email/lib/elektrine_email_web/controllers/admin/messages_html.ex
create mode 100644 apps/elektrine_email/lib/elektrine_email_web/controllers/admin/messages_html/messages.html.heex
create mode 100644 apps/elektrine_email/lib/elektrine_email_web/controllers/admin/messages_html/user_messages.html.heex
create mode 100644 apps/elektrine_email/lib/elektrine_email_web/controllers/admin/messages_html/view_message.html.heex
create mode 100644 apps/elektrine_email/lib/elektrine_email_web/controllers/admin/messages_html/view_user_message.html.heex
create mode 100644 apps/elektrine_email/lib/elektrine_email_web/controllers/admin/system_emails_controller.ex
create mode 100644 apps/elektrine_email/lib/elektrine_email_web/controllers/admin/system_emails_html.ex
create mode 100644 apps/elektrine_email/lib/elektrine_email_web/controllers/admin/system_emails_html/new.html.heex
create mode 100644 apps/elektrine_email/lib/elektrine_email_web/controllers/api/alias_controller.ex
create mode 100644 apps/elektrine_email/lib/elektrine_email_web/controllers/api/email_controller.ex
create mode 100644 apps/elektrine_email/lib/elektrine_email_web/controllers/api/ext_contacts_controller.ex
create mode 100644 apps/elektrine_email/lib/elektrine_email_web/controllers/api/ext_email_controller.ex
create mode 100644 apps/elektrine_email/lib/elektrine_email_web/controllers/api/mailbox_controller.ex
create mode 100644 apps/elektrine_email/lib/elektrine_email_web/controllers/attachment_controller.ex
create mode 100644 apps/elektrine_email/lib/elektrine_email_web/controllers/dav/address_book_controller.ex
create mode 100644 apps/elektrine_email/lib/elektrine_email_web/controllers/email_controller.ex
create mode 100644 apps/elektrine_email/lib/elektrine_email_web/controllers/email_html.ex
create mode 100644 apps/elektrine_email/lib/elektrine_email_web/controllers/email_html/print.html.heex
create mode 100644 apps/elektrine_email/lib/elektrine_email_web/controllers/haraka_webhook_controller.ex
create mode 100644 apps/elektrine_email/lib/elektrine_email_web/controllers/jmap/api_controller.ex
create mode 100644 apps/elektrine_email/lib/elektrine_email_web/controllers/jmap/blob_controller.ex
create mode 100644 apps/elektrine_email/lib/elektrine_email_web/controllers/jmap/event_source_controller.ex
create mode 100644 apps/elektrine_email/lib/elektrine_email_web/controllers/jmap/session_controller.ex
create mode 100644 apps/elektrine_email/lib/elektrine_email_web/controllers/unsubscribe_controller.ex
create mode 100644 apps/elektrine_email/lib/elektrine_email_web/controllers/unsubscribe_html.ex
create mode 100644 apps/elektrine_email/lib/elektrine_email_web/controllers/unsubscribe_html/confirmed.html.heex
create mode 100644 apps/elektrine_email/lib/elektrine_email_web/controllers/unsubscribe_html/resubscribed.html.heex
create mode 100644 apps/elektrine_email/lib/elektrine_email_web/controllers/unsubscribe_html/show.html.heex
create mode 100644 apps/elektrine_email/lib/elektrine_email_web/controllers/wkd_controller.ex
create mode 100644 apps/elektrine_email/lib/elektrine_email_web/email_preference_helpers.ex
create mode 100644 apps/elektrine_email/lib/elektrine_email_web/live/contacts_live/index.ex
create mode 100644 apps/elektrine_email/lib/elektrine_email_web/live/contacts_live/index.html.heex
create mode 100644 apps/elektrine_email/lib/elektrine_email_web/live/contacts_live/operations/contact_operations.ex
create mode 100644 apps/elektrine_email/lib/elektrine_email_web/live/email_live/compose.ex
create mode 100644 apps/elektrine_email/lib/elektrine_email_web/live/email_live/compose.html.heex
create mode 100644 apps/elektrine_email/lib/elektrine_email_web/live/email_live/email_helpers.ex
create mode 100644 apps/elektrine_email/lib/elektrine_email_web/live/email_live/index.ex
create mode 100644 apps/elektrine_email/lib/elektrine_email_web/live/email_live/index.html.heex
create mode 100644 apps/elektrine_email/lib/elektrine_email_web/live/email_live/operations/alias_operations.ex
create mode 100644 apps/elektrine_email/lib/elektrine_email_web/live/email_live/operations/bulk_operations.ex
create mode 100644 apps/elektrine_email/lib/elektrine_email_web/live/email_live/operations/compose_operations.ex
create mode 100644 apps/elektrine_email/lib/elektrine_email_web/live/email_live/operations/contact_operations.ex
create mode 100644 apps/elektrine_email/lib/elektrine_email_web/live/email_live/operations/message_operations.ex
create mode 100644 apps/elektrine_email/lib/elektrine_email_web/live/email_live/operations/navigation_operations.ex
create mode 100644 apps/elektrine_email/lib/elektrine_email_web/live/email_live/operations/reply_later_operations.ex
create mode 100644 apps/elektrine_email/lib/elektrine_email_web/live/email_live/operations/search_operations.ex
create mode 100644 apps/elektrine_email/lib/elektrine_email_web/live/email_live/operations/selection_operations.ex
create mode 100644 apps/elektrine_email/lib/elektrine_email_web/live/email_live/operations/tab_content.ex
create mode 100644 apps/elektrine_email/lib/elektrine_email_web/live/email_live/raw.ex
create mode 100644 apps/elektrine_email/lib/elektrine_email_web/live/email_live/raw.html.heex
create mode 100644 apps/elektrine_email/lib/elektrine_email_web/live/email_live/router.ex
create mode 100644 apps/elektrine_email/lib/elektrine_email_web/live/email_live/search.ex
create mode 100644 apps/elektrine_email/lib/elektrine_email_web/live/email_live/search.html.heex
create mode 100644 apps/elektrine_email/lib/elektrine_email_web/live/email_live/settings.ex
create mode 100644 apps/elektrine_email/lib/elektrine_email_web/live/email_live/settings/content_settings.ex
create mode 100644 apps/elektrine_email/lib/elektrine_email_web/live/email_live/settings/domain_settings.ex
create mode 100644 apps/elektrine_email/lib/elektrine_email_web/live/email_live/settings/filter_settings.ex
create mode 100644 apps/elektrine_email/lib/elektrine_email_web/live/email_live/settings/helpers.ex
create mode 100644 apps/elektrine_email/lib/elektrine_email_web/live/email_live/settings/sender_settings.ex
create mode 100644 apps/elektrine_email/lib/elektrine_email_web/live/email_live/show.ex
create mode 100644 apps/elektrine_email/lib/elektrine_email_web/live/email_live/show.html.heex
create mode 100644 apps/elektrine_email/lib/elektrine_email_web/live/unsubscribe_live/confirmed.ex
create mode 100644 apps/elektrine_email/lib/elektrine_email_web/live/unsubscribe_live/show.ex
create mode 100644 apps/elektrine_email/lib/elektrine_email_web/live/user_settings_email.ex
create mode 100644 apps/elektrine_email/lib/elektrine_email_web/plugs/email_ownership_guard.ex
create mode 100644 apps/elektrine_email/lib/elektrine_email_web/plugs/jmap_auth.ex
create mode 100644 apps/elektrine_email/lib/elektrine_email_web/storage_email.ex
create mode 100644 apps/elektrine_email/lib/elektrine_email_web/user_auth_email.ex
create mode 100644 apps/elektrine_email/lib/elektrine_email_web/user_error_helpers.ex
create mode 100644 apps/elektrine_email/lib/elektrine_email_web/user_settings_email_controller.ex
create mode 100644 apps/elektrine_email/mix.exs
create mode 100644 apps/elektrine_email/test/elektrine/email/alias_test.exs
create mode 100644 apps/elektrine_email/test/elektrine/email/attachment_storage_test.exs
create mode 100644 apps/elektrine_email/test/elektrine/email/auto_replies_test.exs
create mode 100644 apps/elektrine_email/test/elektrine/email/categorizer_test.exs
create mode 100644 apps/elektrine_email/test/elektrine/email/category_preferences_test.exs
create mode 100644 apps/elektrine_email/test/elektrine/email/custom_domains_test.exs
create mode 100644 apps/elektrine_email/test/elektrine/email/dkim_test.exs
create mode 100644 apps/elektrine_email/test/elektrine/email/exports_test.exs
create mode 100644 apps/elektrine_email/test/elektrine/email/filters_test.exs
create mode 100644 apps/elektrine_email/test/elektrine/email/haraka_adapter_test.exs
create mode 100644 apps/elektrine_email/test/elektrine/email/haraka_client_test.exs
create mode 100644 apps/elektrine_email/test/elektrine/email/header_decoder_test.exs
create mode 100644 apps/elektrine_email/test/elektrine/email/header_sanitizer_test.exs
create mode 100644 apps/elektrine_email/test/elektrine/email/inbound_authentication_test.exs
create mode 100644 apps/elektrine_email/test/elektrine/email/inbound_routing_test.exs
create mode 100644 apps/elektrine_email/test/elektrine/email/mailbox_adapter_test.exs
create mode 100644 apps/elektrine_email/test/elektrine/email/mailbox_private_storage_test.exs
create mode 100644 apps/elektrine_email/test/elektrine/email/mailbox_test.exs
create mode 100644 apps/elektrine_email/test/elektrine/email/message_operations_test.exs
create mode 100644 apps/elektrine_email/test/elektrine/email/mime_body_extractor_test.exs
create mode 100644 apps/elektrine_email/test/elektrine/email/pgp_receive_test.exs
create mode 100644 apps/elektrine_email/test/elektrine/email/pgp_test.exs
create mode 100644 apps/elektrine_email/test/elektrine/email/platform_email_headers_test.exs
create mode 100644 apps/elektrine_email/test/elektrine/email/plus_addressing_test.exs
create mode 100644 apps/elektrine_email/test/elektrine/email/rate_limiter_test.exs
create mode 100644 apps/elektrine_email/test/elektrine/email/sanitizer_test.exs
create mode 100644 apps/elektrine_email/test/elektrine/email/send_email_worker_test.exs
create mode 100644 apps/elektrine_email/test/elektrine/email/sender_class_test.exs
create mode 100644 apps/elektrine_email/test/elektrine/email/sender_credit_gate_test.exs
create mode 100644 apps/elektrine_email/test/elektrine/email/sender_pipeline_test.exs
create mode 100644 apps/elektrine_email/test/elektrine/email/sender_rate_limit_test.exs
create mode 100644 apps/elektrine_email/test/elektrine/email/sender_validation_test.exs
create mode 100644 apps/elektrine_email/test/elektrine/email/suppressions_test.exs
create mode 100644 apps/elektrine_email/test/elektrine/email/system_delivery_test.exs
create mode 100644 apps/elektrine_email/test/elektrine/email/unsubscribes_test.exs
create mode 100644 apps/elektrine_email/test/elektrine/email/vcard_test.exs
create mode 100644 apps/elektrine_email/test/elektrine/email_basic_functions_test.exs
create mode 100644 apps/elektrine_email/test/elektrine/email_encryption_test.exs
create mode 100644 apps/elektrine_email/test/elektrine/email_test.exs
create mode 100644 apps/elektrine_email/test/elektrine/imap/commands_test.exs
create mode 100644 apps/elektrine_email/test/elektrine/imap/helpers_test.exs
create mode 100644 apps/elektrine_email/test/elektrine/imap/response_security_test.exs
create mode 100644 apps/elektrine_email/test/elektrine/mail/protocol_auth_rate_limiter_test.exs
create mode 100644 apps/elektrine_email/test/elektrine/mail/protocol_transcript_test.exs
create mode 100644 apps/elektrine_email/test/elektrine/mail_auth/rate_limiter_test.exs
create mode 100644 apps/elektrine_email/test/elektrine/pop3/connection_tracker_test.exs
create mode 100644 apps/elektrine_email/test/elektrine_email_web/components/email/display_test.exs
create mode 100644 apps/elektrine_email/test/elektrine_email_web/live/email_live/email_helpers_test.exs
create mode 100644 apps/elektrine_email/test/elektrine_email_web/user_error_helpers_test.exs
create mode 100644 apps/elektrine_email/test/fixtures/email/css_preamble_plain.txt
create mode 100644 apps/elektrine_email/test/fixtures/imap_client_transcripts.json
create mode 100644 apps/elektrine_email/test/support/data_case.ex
create mode 100644 apps/elektrine_email/test/support/fixtures/README.md
create mode 100644 apps/elektrine_email/test/support/fixtures/accounts_fixtures.ex
create mode 100644 apps/elektrine_email/test/support/fixtures/email_fixtures.ex
create mode 100644 apps/elektrine_email/test/support/fixtures/pop3-test-cert.pem
create mode 100644 apps/elektrine_email/test/support/fixtures/pop3-test-key.pem
create mode 100644 apps/elektrine_email/test/support/fixtures/social_fixtures.ex
create mode 100644 apps/elektrine_email/test/test_helper.exs
create mode 100644 apps/elektrine_nerve/.gitignore
create mode 100644 apps/elektrine_nerve/README.md
create mode 100644 apps/elektrine_nerve/lib/elektrine/nerve.ex
create mode 100644 apps/elektrine_nerve/lib/elektrine/nerve/nerve_entry.ex
create mode 100644 apps/elektrine_nerve/lib/elektrine/nerve/nerve_settings.ex
create mode 100644 apps/elektrine_nerve/lib/elektrine/nerve/payloads.ex
create mode 100644 apps/elektrine_nerve/lib/elektrine_nerve_web.ex
create mode 100644 apps/elektrine_nerve/lib/elektrine_nerve_web/api/nerve_controller.ex
create mode 100644 apps/elektrine_nerve/lib/elektrine_nerve_web/api/response.ex
create mode 100644 apps/elektrine_nerve/lib/elektrine_nerve_web/components/platform/e_nav.ex
create mode 100644 apps/elektrine_nerve/lib/elektrine_nerve_web/nerve_live.ex
create mode 100644 apps/elektrine_nerve/mix.exs
create mode 100644 apps/elektrine_nerve/test/elektrine/nerve_test.exs
create mode 100644 apps/elektrine_nerve/test/support/data_case.ex
create mode 100644 apps/elektrine_nerve/test/support/fixtures/accounts_fixtures.ex
create mode 100644 apps/elektrine_nerve/test/test_helper.exs
create mode 100644 apps/elektrine_social/.gitignore
create mode 100644 apps/elektrine_social/README.md
create mode 100644 apps/elektrine_social/lib/elektrine/social.ex
create mode 100644 apps/elektrine_social/lib/elektrine/social/bookmark_folder.ex
create mode 100644 apps/elektrine_social/lib/elektrine/social/bookmark_folders.ex
create mode 100644 apps/elektrine_social/lib/elektrine/social/bookmarks.ex
create mode 100644 apps/elektrine_social/lib/elektrine/social/boosts.ex
create mode 100644 apps/elektrine_social/lib/elektrine/social/creator_satisfaction.ex
create mode 100644 apps/elektrine_social/lib/elektrine/social/cross_posting.ex
create mode 100644 apps/elektrine_social/lib/elektrine/social/discussions.ex
create mode 100644 apps/elektrine_social/lib/elektrine/social/drafts.ex
create mode 100644 apps/elektrine_social/lib/elektrine/social/federated_feeds.ex
create mode 100644 apps/elektrine_social/lib/elektrine/social/feed_policy.ex
create mode 100644 apps/elektrine_social/lib/elektrine/social/feed_query.ex
create mode 100644 apps/elektrine_social/lib/elektrine/social/fetch_link_preview_worker.ex
create mode 100644 apps/elektrine_social/lib/elektrine/social/filter.ex
create mode 100644 apps/elektrine_social/lib/elektrine/social/filters.ex
create mode 100644 apps/elektrine_social/lib/elektrine/social/hashtag_extractor.ex
create mode 100644 apps/elektrine_social/lib/elektrine/social/hashtag_follow.ex
create mode 100644 apps/elektrine_social/lib/elektrine/social/hashtag_follows.ex
create mode 100644 apps/elektrine_social/lib/elektrine/social/hashtags.ex
create mode 100644 apps/elektrine_social/lib/elektrine/social/home_feed.ex
create mode 100644 apps/elektrine_social/lib/elektrine/social/home_feed_cache.ex
create mode 100644 apps/elektrine_social/lib/elektrine/social/home_feed_fanout_worker.ex
create mode 100644 apps/elektrine_social/lib/elektrine/social/home_feed_invalidation_worker.ex
create mode 100644 apps/elektrine_social/lib/elektrine/social/likes.ex
create mode 100644 apps/elektrine_social/lib/elektrine/social/link_preview_fetcher.ex
create mode 100644 apps/elektrine_social/lib/elektrine/social/link_preview_job.ex
create mode 100644 apps/elektrine_social/lib/elektrine/social/link_preview_refresh_worker.ex
create mode 100644 apps/elektrine_social/lib/elektrine/social/link_preview_worker.ex
create mode 100644 apps/elektrine_social/lib/elektrine/social/list.ex
create mode 100644 apps/elektrine_social/lib/elektrine/social/list_member.ex
create mode 100644 apps/elektrine_social/lib/elektrine/social/lists.ex
create mode 100644 apps/elektrine_social/lib/elektrine/social/media_attachments.ex
create mode 100644 apps/elektrine_social/lib/elektrine/social/message_policy.ex
create mode 100644 apps/elektrine_social/lib/elektrine/social/message_vote.ex
create mode 100644 apps/elektrine_social/lib/elektrine/social/notification_policy.ex
create mode 100644 apps/elektrine_social/lib/elektrine/social/oembed.ex
create mode 100644 apps/elektrine_social/lib/elektrine/social/polls.ex
create mode 100644 apps/elektrine_social/lib/elektrine/social/post_boost.ex
create mode 100644 apps/elektrine_social/lib/elektrine/social/post_dismissal.ex
create mode 100644 apps/elektrine_social/lib/elektrine/social/post_like.ex
create mode 100644 apps/elektrine_social/lib/elektrine/social/post_view.ex
create mode 100644 apps/elektrine_social/lib/elektrine/social/recommendation_item.ex
create mode 100644 apps/elektrine_social/lib/elektrine/social/recommendation_refresh_worker.ex
create mode 100644 apps/elektrine_social/lib/elektrine/social/recommendations.ex
create mode 100644 apps/elektrine_social/lib/elektrine/social/saved_item.ex
create mode 100644 apps/elektrine_social/lib/elektrine/social/scheduled_post_publisher_worker.ex
create mode 100644 apps/elektrine_social/lib/elektrine/social/status_reactions.ex
create mode 100644 apps/elektrine_social/lib/elektrine/social/suggested_account_dismissal.ex
create mode 100644 apps/elektrine_social/lib/elektrine/social/suggested_follows.ex
create mode 100644 apps/elektrine_social/lib/elektrine/social/thread_mute.ex
create mode 100644 apps/elektrine_social/lib/elektrine/social/thread_mutes.ex
create mode 100644 apps/elektrine_social/lib/elektrine/social/timeline_load_verifier.ex
create mode 100644 apps/elektrine_social/lib/elektrine/social/timeline_pagination.ex
create mode 100644 apps/elektrine_social/lib/elektrine/social/timeline_relationships.ex
create mode 100644 apps/elektrine_social/lib/elektrine/social/views.ex
create mode 100644 apps/elektrine_social/lib/elektrine/social/votes.ex
create mode 100644 apps/elektrine_social/lib/elektrine_social/moderation/iftas_blocklist.ex
create mode 100644 apps/elektrine_social/lib/elektrine_social/moderation/iftas_blocklist_worker.ex
create mode 100644 apps/elektrine_social/lib/elektrine_social/platform.ex
create mode 100644 apps/elektrine_social/lib/elektrine_social/remote_user/metrics.ex
create mode 100644 apps/elektrine_social/lib/elektrine_social/remote_user/metrics_worker.ex
create mode 100644 apps/elektrine_social/lib/elektrine_social/remote_user/outbox_sync.ex
create mode 100644 apps/elektrine_social/lib/elektrine_social/remote_user/outbox_sync_worker.ex
create mode 100644 apps/elektrine_social/lib/elektrine_social_web.ex
create mode 100644 apps/elektrine_social/lib/elektrine_social_web/components/platform/e_nav.ex
create mode 100644 apps/elektrine_social/lib/elektrine_social_web/components/social/content_journey.ex
create mode 100644 apps/elektrine_social/lib/elektrine_social_web/components/social/embedded_post.ex
create mode 100644 apps/elektrine_social/lib/elektrine_social_web/components/social/fediverse_follow.ex
create mode 100644 apps/elektrine_social/lib/elektrine_social_web/components/social/follow_button.ex
create mode 100644 apps/elektrine_social/lib/elektrine_social_web/components/social/poll.ex
create mode 100644 apps/elektrine_social/lib/elektrine_social_web/components/social/poll_display.ex
create mode 100644 apps/elektrine_social/lib/elektrine_social_web/components/social/post_actions.ex
create mode 100644 apps/elektrine_social/lib/elektrine_social_web/components/social/post_reactions.ex
create mode 100644 apps/elektrine_social/lib/elektrine_social_web/components/social/post_utilities.ex
create mode 100644 apps/elektrine_social/lib/elektrine_social_web/components/social/remote_post_shared.ex
create mode 100644 apps/elektrine_social/lib/elektrine_social_web/components/social/reply_item.ex
create mode 100644 apps/elektrine_social/lib/elektrine_social_web/components/social/rss_item.ex
create mode 100644 apps/elektrine_social/lib/elektrine_social_web/components/social/timeline_post.ex
create mode 100644 apps/elektrine_social/lib/elektrine_social_web/components/social/timeline_post_ancestors.ex
create mode 100644 apps/elektrine_social/lib/elektrine_social_web/components/social/timeline_post_boost.ex
create mode 100644 apps/elektrine_social/lib/elektrine_social_web/components/social/timeline_post_card.ex
create mode 100644 apps/elektrine_social/lib/elektrine_social_web/components/social/timeline_post_compact.ex
create mode 100644 apps/elektrine_social/lib/elektrine_social_web/components/social/timeline_post_footer.ex
create mode 100644 apps/elektrine_social/lib/elektrine_social_web/components/social/timeline_post_media.ex
create mode 100644 apps/elektrine_social/lib/elektrine_social_web/components/social/timeline_stream_post.ex
create mode 100644 apps/elektrine_social/lib/elektrine_social_web/components/ui/image_modal.ex
create mode 100644 apps/elektrine_social/lib/elektrine_social_web/components/user/hover_card.ex
create mode 100644 apps/elektrine_social/lib/elektrine_social_web/controllers/activitypub/actor_request.ex
create mode 100644 apps/elektrine_social/lib/elektrine_social_web/controllers/activitypub/signature_actor_verifier.ex
create mode 100644 apps/elektrine_social/lib/elektrine_social_web/controllers/activitypub_controller.ex
create mode 100644 apps/elektrine_social/lib/elektrine_social_web/controllers/api/ext_social_controller.ex
create mode 100644 apps/elektrine_social/lib/elektrine_social_web/controllers/api/social_controller.ex
create mode 100644 apps/elektrine_social/lib/elektrine_social_web/controllers/external_interaction_controller.ex
create mode 100644 apps/elektrine_social/lib/elektrine_social_web/controllers/media_proxy_controller.ex
create mode 100644 apps/elektrine_social/lib/elektrine_social_web/controllers/nodeinfo_controller.ex
create mode 100644 apps/elektrine_social/lib/elektrine_social_web/controllers/webfinger_controller.ex
create mode 100644 apps/elektrine_social/lib/elektrine_social_web/live/discussions_live/community.ex
create mode 100644 apps/elektrine_social/lib/elektrine_social_web/live/discussions_live/community.html.heex
create mode 100644 apps/elektrine_social/lib/elektrine_social_web/live/discussions_live/index.ex
create mode 100644 apps/elektrine_social/lib/elektrine_social_web/live/discussions_live/index.html.heex
create mode 100644 apps/elektrine_social/lib/elektrine_social_web/live/discussions_live/operations/flair_operations.ex
create mode 100644 apps/elektrine_social/lib/elektrine_social_web/live/discussions_live/operations/member_operations.ex
create mode 100644 apps/elektrine_social/lib/elektrine_social_web/live/discussions_live/operations/moderation_operations.ex
create mode 100644 apps/elektrine_social/lib/elektrine_social_web/live/discussions_live/operations/post_operations.ex
create mode 100644 apps/elektrine_social/lib/elektrine_social_web/live/discussions_live/operations/sort_helpers.ex
create mode 100644 apps/elektrine_social/lib/elektrine_social_web/live/discussions_live/operations/ui_operations.ex
create mode 100644 apps/elektrine_social/lib/elektrine_social_web/live/discussions_live/operations/voting_operations.ex
create mode 100644 apps/elektrine_social/lib/elektrine_social_web/live/discussions_live/post.ex
create mode 100644 apps/elektrine_social/lib/elektrine_social_web/live/discussions_live/post.html.heex
create mode 100644 apps/elektrine_social/lib/elektrine_social_web/live/discussions_live/post_operations/moderation_operations.ex
create mode 100644 apps/elektrine_social/lib/elektrine_social_web/live/discussions_live/post_operations/reply_operations.ex
create mode 100644 apps/elektrine_social/lib/elektrine_social_web/live/discussions_live/post_operations/ui_operations.ex
create mode 100644 apps/elektrine_social/lib/elektrine_social_web/live/discussions_live/post_operations/voting_operations.ex
create mode 100644 apps/elektrine_social/lib/elektrine_social_web/live/discussions_live/post_router.ex
create mode 100644 apps/elektrine_social/lib/elektrine_social_web/live/discussions_live/router.ex
create mode 100644 apps/elektrine_social/lib/elektrine_social_web/live/discussions_live/session_context.ex
create mode 100644 apps/elektrine_social/lib/elektrine_social_web/live/discussions_live/settings.ex
create mode 100644 apps/elektrine_social/lib/elektrine_social_web/live/discussions_live/settings.html.heex
create mode 100644 apps/elektrine_social/lib/elektrine_social_web/live/filters_live/index.ex
create mode 100644 apps/elektrine_social/lib/elektrine_social_web/live/filters_live/index.html.heex
create mode 100644 apps/elektrine_social/lib/elektrine_social_web/live/gallery_live/index.ex
create mode 100644 apps/elektrine_social/lib/elektrine_social_web/live/gallery_live/index.html.heex
create mode 100644 apps/elektrine_social/lib/elektrine_social_web/live/global_feed_page.ex
create mode 100644 apps/elektrine_social/lib/elektrine_social_web/live/hashtag_live/show.ex
create mode 100644 apps/elektrine_social/lib/elektrine_social_web/live/hashtag_live/show.html.heex
create mode 100644 apps/elektrine_social/lib/elektrine_social_web/live/list_live/index.ex
create mode 100644 apps/elektrine_social/lib/elektrine_social_web/live/list_live/index.html.heex
create mode 100644 apps/elektrine_social/lib/elektrine_social_web/live/list_live/show.ex
create mode 100644 apps/elektrine_social/lib/elektrine_social_web/live/list_live/show.html.heex
create mode 100644 apps/elektrine_social/lib/elektrine_social_web/live/remote_post_live/access_policy.ex
create mode 100644 apps/elektrine_social/lib/elektrine_social_web/live/remote_post_live/ancestor_context_components.ex
create mode 100644 apps/elektrine_social/lib/elektrine_social_web/live/remote_post_live/cached_post_fields.ex
create mode 100644 apps/elektrine_social/lib/elektrine_social_web/live/remote_post_live/counts.ex
create mode 100644 apps/elektrine_social/lib/elektrine_social_web/live/remote_post_live/detail_components.ex
create mode 100644 apps/elektrine_social/lib/elektrine_social_web/live/remote_post_live/detail_state.ex
create mode 100644 apps/elektrine_social/lib/elektrine_social_web/live/remote_post_live/discussion_source.ex
create mode 100644 apps/elektrine_social/lib/elektrine_social_web/live/remote_post_live/display_counts.ex
create mode 100644 apps/elektrine_social/lib/elektrine_social_web/live/remote_post_live/interactions.ex
create mode 100644 apps/elektrine_social/lib/elektrine_social_web/live/remote_post_live/navigation.ex
create mode 100644 apps/elektrine_social/lib/elektrine_social_web/live/remote_post_live/polls.ex
create mode 100644 apps/elektrine_social/lib/elektrine_social_web/live/remote_post_live/quick_reply_components.ex
create mode 100644 apps/elektrine_social/lib/elektrine_social_web/live/remote_post_live/reply_author_components.ex
create mode 100644 apps/elektrine_social/lib/elektrine_social_web/live/remote_post_live/show.ex
create mode 100644 apps/elektrine_social/lib/elektrine_social_web/live/remote_post_live/show.html.heex
create mode 100644 apps/elektrine_social/lib/elektrine_social_web/live/remote_post_live/submitted_links.ex
create mode 100644 apps/elektrine_social/lib/elektrine_social_web/live/remote_post_live/surface_helpers.ex
create mode 100644 apps/elektrine_social/lib/elektrine_social_web/live/remote_post_live/threaded_comment_components.ex
create mode 100644 apps/elektrine_social/lib/elektrine_social_web/live/remote_post_live/threading.ex
create mode 100644 apps/elektrine_social/lib/elektrine_social_web/live/remote_user_live/actor_lookup.ex
create mode 100644 apps/elektrine_social/lib/elektrine_social_web/live/remote_user_live/community_post_operations.ex
create mode 100644 apps/elektrine_social/lib/elektrine_social_web/live/remote_user_live/counts.ex
create mode 100644 apps/elektrine_social/lib/elektrine_social_web/live/remote_user_live/follow_operations.ex
create mode 100644 apps/elektrine_social/lib/elektrine_social_web/live/remote_user_live/media_modal.ex
create mode 100644 apps/elektrine_social/lib/elektrine_social_web/live/remote_user_live/post_interaction_operations.ex
create mode 100644 apps/elektrine_social/lib/elektrine_social_web/live/remote_user_live/post_sorting.ex
create mode 100644 apps/elektrine_social/lib/elektrine_social_web/live/remote_user_live/post_state.ex
create mode 100644 apps/elektrine_social/lib/elektrine_social_web/live/remote_user_live/reaction_surfaces.ex
create mode 100644 apps/elektrine_social/lib/elektrine_social_web/live/remote_user_live/reply_operations.ex
create mode 100644 apps/elektrine_social/lib/elektrine_social_web/live/remote_user_live/show.ex
create mode 100644 apps/elektrine_social/lib/elektrine_social_web/live/remote_user_live/show.html.heex
create mode 100644 apps/elektrine_social/lib/elektrine_social_web/live/remote_user_live/timeline_loader.ex
create mode 100644 apps/elektrine_social/lib/elektrine_social_web/live/timeline_live/index.ex
create mode 100644 apps/elektrine_social/lib/elektrine_social_web/live/timeline_live/index.html.heex
create mode 100644 apps/elektrine_social/lib/elektrine_social_web/live/timeline_live/operations/bookmark_folder_operations.ex
create mode 100644 apps/elektrine_social/lib/elektrine_social_web/live/timeline_live/operations/helpers.ex
create mode 100644 apps/elektrine_social/lib/elektrine_social_web/live/timeline_live/operations/image_operations.ex
create mode 100644 apps/elektrine_social/lib/elektrine_social_web/live/timeline_live/operations/navigation_operations.ex
create mode 100644 apps/elektrine_social/lib/elektrine_social_web/live/timeline_live/operations/post_operations.ex
create mode 100644 apps/elektrine_social/lib/elektrine_social_web/live/timeline_live/operations/reply_operations.ex
create mode 100644 apps/elektrine_social/lib/elektrine_social_web/live/timeline_live/operations/social_operations.ex
create mode 100644 apps/elektrine_social/lib/elektrine_social_web/live/timeline_live/operations/tracking_operations.ex
create mode 100644 apps/elektrine_social/lib/elektrine_social_web/live/timeline_live/operations/ui_operations.ex
create mode 100644 apps/elektrine_social/lib/elektrine_social_web/live/timeline_live/operations/voting_operations.ex
create mode 100644 apps/elektrine_social/lib/elektrine_social_web/live/timeline_live/post.ex
create mode 100644 apps/elektrine_social/lib/elektrine_social_web/live/timeline_live/post.html.heex
create mode 100644 apps/elektrine_social/lib/elektrine_social_web/live/timeline_live/reply_context_previews.ex
create mode 100644 apps/elektrine_social/lib/elektrine_social_web/live/timeline_live/router.ex
create mode 100644 apps/elektrine_social/lib/elektrine_social_web/live/videos_live/index.ex
create mode 100644 apps/elektrine_social/lib/elektrine_social_web/live/videos_live/index.html.heex
create mode 100644 apps/elektrine_social/lib/mix/tasks/social.verify_timeline_load.ex
create mode 100644 apps/elektrine_social/mix.exs
create mode 100644 apps/elektrine_social/test/elektrine/social/account_subscription_notifications_test.exs
create mode 100644 apps/elektrine_social/test/elektrine/social/blocked_instances_test.exs
create mode 100644 apps/elektrine_social/test/elektrine/social/bookmark_folders_test.exs
create mode 100644 apps/elektrine_social/test/elektrine/social/bookmarks_test.exs
create mode 100644 apps/elektrine_social/test/elektrine/social/boosts_test.exs
create mode 100644 apps/elektrine_social/test/elektrine/social/drafts_test.exs
create mode 100644 apps/elektrine_social/test/elektrine/social/filters_test.exs
create mode 100644 apps/elektrine_social/test/elektrine/social/hashtag_extractor_test.exs
create mode 100644 apps/elektrine_social/test/elektrine/social/hashtags_test.exs
create mode 100644 apps/elektrine_social/test/elektrine/social/home_feed_cache_invalidation_test.exs
create mode 100644 apps/elektrine_social/test/elektrine/social/likes_test.exs
create mode 100644 apps/elektrine_social/test/elektrine/social/link_preview_refresh_worker_test.exs
create mode 100644 apps/elektrine_social/test/elektrine/social/link_preview_test.exs
create mode 100644 apps/elektrine_social/test/elektrine/social/media_metadata_test.exs
create mode 100644 apps/elektrine_social/test/elektrine/social/notification_policy_test.exs
create mode 100644 apps/elektrine_social/test/elektrine/social/poll_test.exs
create mode 100644 apps/elektrine_social/test/elektrine/social/recommendation_refresh_worker_test.exs
create mode 100644 apps/elektrine_social/test/elektrine/social/recommendations_test.exs
create mode 100644 apps/elektrine_social/test/elektrine/social/remote_user/outbox_sync_test.exs
create mode 100644 apps/elektrine_social/test/elektrine/social/remote_user_metrics_test.exs
create mode 100644 apps/elektrine_social/test/elektrine/social/reply_counts_test.exs
create mode 100644 apps/elektrine_social/test/elektrine/social/status_pins_test.exs
create mode 100644 apps/elektrine_social/test/elektrine/social/timeline_load_verifier_test.exs
create mode 100644 apps/elektrine_social/test/elektrine/social/views_test.exs
create mode 100644 apps/elektrine_social/test/elektrine/social/votes_test.exs
create mode 100644 apps/elektrine_social/test/elektrine_social/moderation/iftas_blocklist_test.exs
create mode 100644 apps/elektrine_social/test/elektrine_social_web/components/social/poll_display_test.exs
create mode 100644 apps/elektrine_social/test/elektrine_social_web/components/social/post_reactions_test.exs
create mode 100644 apps/elektrine_social/test/elektrine_social_web/live/discussions_live/flair_operations_test.exs
create mode 100644 apps/elektrine_social/test/elektrine_social_web/live/timeline_live/feed_display_toggles_test.exs
create mode 100644 apps/elektrine_social/test/elektrine_social_web/live/timeline_live/post_operations_test.exs
create mode 100644 apps/elektrine_social/test/support/data_case.ex
create mode 100644 apps/elektrine_social/test/support/fixtures/accounts_fixtures.ex
create mode 100644 apps/elektrine_social/test/support/fixtures/email_fixtures.ex
create mode 100644 apps/elektrine_social/test/support/fixtures/social_fixtures.ex
create mode 100644 apps/elektrine_social/test/test_helper.exs
create mode 100644 apps/elektrine_uptime/README.md
create mode 100644 apps/elektrine_uptime/lib/elektrine/uptime.ex
create mode 100644 apps/elektrine_uptime/lib/elektrine/uptime/application.ex
create mode 100644 apps/elektrine_uptime/lib/elektrine/uptime/check.ex
create mode 100644 apps/elektrine_uptime/lib/elektrine/uptime/check_scheduler_worker.ex
create mode 100644 apps/elektrine_uptime/lib/elektrine/uptime/check_worker.ex
create mode 100644 apps/elektrine_uptime/lib/elektrine/uptime/checker.ex
create mode 100644 apps/elektrine_uptime/lib/elektrine/uptime/checker/behaviour.ex
create mode 100644 apps/elektrine_uptime/lib/elektrine/uptime/email.ex
create mode 100644 apps/elektrine_uptime/lib/elektrine/uptime/incident.ex
create mode 100644 apps/elektrine_uptime/lib/elektrine/uptime/monitor.ex
create mode 100644 apps/elektrine_uptime/lib/elektrine/uptime/notifier.ex
create mode 100644 apps/elektrine_uptime/lib/elektrine_uptime_web.ex
create mode 100644 apps/elektrine_uptime/lib/elektrine_uptime_web/live/uptime_live/index.ex
create mode 100644 apps/elektrine_uptime/lib/elektrine_uptime_web/live/uptime_live/index.html.heex
create mode 100644 apps/elektrine_uptime/mix.exs
create mode 100644 apps/elektrine_uptime/test/elektrine/uptime/check_worker_test.exs
create mode 100644 apps/elektrine_uptime/test/elektrine/uptime/checker_test.exs
create mode 100644 apps/elektrine_uptime/test/elektrine/uptime/notifier_test.exs
create mode 100644 apps/elektrine_uptime/test/elektrine/uptime/uptime_test.exs
create mode 100644 apps/elektrine_uptime/test/elektrine_uptime_web/live/uptime_live_test.exs
create mode 100644 apps/elektrine_uptime/test/test_helper.exs
create mode 100644 apps/elektrine_uptime/test_support/data_case.ex
create mode 100644 apps/elektrine_vpn/.gitignore
create mode 100644 apps/elektrine_vpn/README.md
create mode 100644 apps/elektrine_vpn/lib/elektrine/vpn.ex
create mode 100644 apps/elektrine_vpn/lib/elektrine/vpn/connection_log.ex
create mode 100644 apps/elektrine_vpn/lib/elektrine/vpn/health_monitor.ex
create mode 100644 apps/elektrine_vpn/lib/elektrine/vpn/peer_cache.ex
create mode 100644 apps/elektrine_vpn/lib/elektrine/vpn/self_hosted_reconciler.ex
create mode 100644 apps/elektrine_vpn/lib/elektrine/vpn/self_hosted_server.ex
create mode 100644 apps/elektrine_vpn/lib/elektrine/vpn/self_hosted_shadowsocks_server.ex
create mode 100644 apps/elektrine_vpn/lib/elektrine/vpn/server.ex
create mode 100644 apps/elektrine_vpn/lib/elektrine/vpn/shadowsocks_adapter.ex
create mode 100644 apps/elektrine_vpn/lib/elektrine/vpn/stale_node_reaper_worker.ex
create mode 100644 apps/elektrine_vpn/lib/elektrine/vpn/stats_aggregator.ex
create mode 100644 apps/elektrine_vpn/lib/elektrine/vpn/user_config.ex
create mode 100644 apps/elektrine_vpn/lib/elektrine/vpn/wire_guard_adapter.ex
create mode 100644 apps/elektrine_vpn/lib/elektrine_vpn/platform.ex
create mode 100644 apps/elektrine_vpn/lib/elektrine_vpn_web.ex
create mode 100644 apps/elektrine_vpn/lib/elektrine_vpn_web/components/platform/elektrine_nav.ex
create mode 100644 apps/elektrine_vpn/lib/elektrine_vpn_web/controllers/admin/vpn_controller.ex
create mode 100644 apps/elektrine_vpn/lib/elektrine_vpn_web/controllers/admin/vpn_html.ex
create mode 100644 apps/elektrine_vpn/lib/elektrine_vpn_web/controllers/admin/vpn_html/confirm_delete_vpn_server.html.heex
create mode 100644 apps/elektrine_vpn/lib/elektrine_vpn_web/controllers/admin/vpn_html/edit_vpn_server.html.heex
create mode 100644 apps/elektrine_vpn/lib/elektrine_vpn_web/controllers/admin/vpn_html/edit_vpn_user_config.html.heex
create mode 100644 apps/elektrine_vpn/lib/elektrine_vpn_web/controllers/admin/vpn_html/new_vpn_server.html.heex
create mode 100644 apps/elektrine_vpn/lib/elektrine_vpn_web/controllers/admin/vpn_html/vpn_dashboard.html.heex
create mode 100644 apps/elektrine_vpn/lib/elektrine_vpn_web/controllers/admin/vpn_html/vpn_users.html.heex
create mode 100644 apps/elektrine_vpn/lib/elektrine_vpn_web/controllers/api/vpn_controller.ex
create mode 100644 apps/elektrine_vpn/lib/elektrine_vpn_web/controllers/vpn_api_controller.ex
create mode 100644 apps/elektrine_vpn/lib/elektrine_vpn_web/live/page_live/vpn_policy.ex
create mode 100644 apps/elektrine_vpn/lib/elektrine_vpn_web/live/vpn_live/index.ex
create mode 100644 apps/elektrine_vpn/lib/elektrine_vpn_web/live/vpn_live/index.html.heex
create mode 100644 apps/elektrine_vpn/lib/mix/tasks/elektrine.vpn.reap_stale_nodes.ex
create mode 100644 apps/elektrine_vpn/mix.exs
create mode 100644 apps/elektrine_vpn/test/elektrine/vpn/shadowsocks_adapter_test.exs
create mode 100644 apps/elektrine_vpn/test/elektrine/vpn/stale_node_reaper_worker_test.exs
create mode 100644 apps/elektrine_vpn/test/elektrine/vpn/wire_guard_adapter_test.exs
create mode 100644 apps/elektrine_vpn/test/elektrine/vpn_test.exs
create mode 100644 apps/elektrine_vpn/test/support/data_case.ex
create mode 100644 apps/elektrine_vpn/test/test_helper.exs
create mode 100644 apps/elektrine_web/.gitignore
create mode 100644 apps/elektrine_web/README.md
create mode 100644 apps/elektrine_web/lib/elektrine_web.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/account_settings.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/admin_security.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/atomine_gate.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/atomine_pow.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/canonical_url.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/channels/call_channel.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/channels/mobile_channel.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/channels/presence.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/channels/user_socket.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/channels/voice_channel.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/client_ip.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/components/core_components.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/components/datetime/local_time.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/components/emoji_picker.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/components/layout/announcement.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/components/layout/header.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/components/layout/navigation.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/components/layouts.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/components/layouts/admin.html.heex
create mode 100644 apps/elektrine_web/lib/elektrine_web/components/layouts/app.html.heex
create mode 100644 apps/elektrine_web/lib/elektrine_web/components/layouts/profile.html.heex
create mode 100644 apps/elektrine_web/lib/elektrine_web/components/layouts/root.html.heex
create mode 100644 apps/elektrine_web/lib/elektrine_web/components/notification/bell.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/components/platform/e_nav.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/components/platform/elektrine_nav.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/components/presence/helpers.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/components/profile/account_extras.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/components/profile/containers.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/components/profile/modals.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/components/social/fediverse_follow.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/components/social/post_actions.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/components/social/post_reactions.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/components/social/post_utilities.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/components/social/youtube_preview.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/components/ui/action_toolbar.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/components/ui/badge.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/components/ui/brand_icon.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/components/ui/button.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/components/ui/card.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/components/ui/dropdown.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/components/ui/empty_state.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/components/ui/floating_panel.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/components/ui/form.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/components/ui/icon.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/components/ui/image_modal.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/components/ui/loading.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/components/ui/modal.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/components/ui/pill_switcher.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/components/ui/section_header.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/components/ui/stats_row.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/components/ui/table.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/components/ui/uplot_chart.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/components/user/hover_card.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/components/user/username_effects.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/components/user/verification_badge.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/admin/announcements_controller.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/admin/announcements_html.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/admin/communities_controller.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/admin/communities_html.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/admin/deletion_requests_controller.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/admin/deletion_requests_html.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/admin/email_delivery_controller.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/admin/email_delivery_html.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/admin/email_delivery_html/index.html.heex
create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/admin/haraka_controller.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/admin/haraka_html.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/admin/haraka_html/index.html.heex
create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/admin/invite_codes_controller.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/admin/invite_codes_html.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/admin/moderation_controller.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/admin/moderation_html.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/admin/monitoring_controller.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/admin/monitoring_html.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/admin/security_controller.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/admin/security_html.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/admin/subscriptions_controller.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/admin/subscriptions_html.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/admin/users_controller.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/admin/users_html.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/admin_audit_logs_controller.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/admin_audit_logs_html.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/admin_audit_logs_html/index.html.heex
create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/admin_controller.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/admin_html.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/admin_html/account_lookup.html.heex
create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/admin_html/active_users.html.heex
create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/admin_html/announcements.html.heex
create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/admin_html/ban.html.heex
create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/admin_html/communities.html.heex
create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/admin_html/content_moderation.html.heex
create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/admin_html/dashboard.html.heex
create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/admin_html/deletion_requests.html.heex
create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/admin_html/edit.html.heex
create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/admin_html/edit_announcement.html.heex
create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/admin_html/edit_invite_code.html.heex
create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/admin_html/edit_product.html.heex
create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/admin_html/elevate.html.heex
create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/admin_html/imap_users.html.heex
create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/admin_html/invite_codes.html.heex
create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/admin_html/new.html.heex
create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/admin_html/new_announcement.html.heex
create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/admin_html/new_invite_code.html.heex
create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/admin_html/new_product.html.heex
create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/admin_html/operations.html.heex
create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/admin_html/pop3_users.html.heex
create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/admin_html/show_community.html.heex
create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/admin_html/show_deletion_request.html.heex
create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/admin_html/subscriptions.html.heex
create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/admin_html/two_factor_status.html.heex
create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/admin_html/unsubscribe_stats.html.heex
create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/admin_html/users.html.heex
create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/api/account_birthday_controller.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/api/account_credential_controller.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/api/account_directory_controller.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/api/account_json.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/api/account_note_controller.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/api/account_registration_controller.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/api/account_relationship_controller.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/api/account_search_controller.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/api/account_status_controller.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/api/announcement_controller.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/api/app_controller.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/api/atomine_attestation_controller.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/api/auth_controller.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/api/backup_controller.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/api/bookmark_controller.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/api/bookmark_folder_controller.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/api/calendar_controller.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/api/chat_compat_controller.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/api/custom_emoji_controller.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/api/device_controller.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/api/direct_conversation_controller.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/api/domain_block_controller.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/api/export_controller.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/api/favourite_controller.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/api/filter_controller.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/api/follow_request_controller.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/api/global_search_controller.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/api/instance_controller.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/api/kairo_controller.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/api/list_controller.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/api/marker_controller.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/api/mcp_controller.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/api/media_attachment_controller.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/api/meta_controller.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/api/notification_controller.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/api/outgoing_follow_request_controller.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/api/pagination_headers.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/api/password_reset_controller.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/api/poll_controller.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/api/poll_json.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/api/preference_controller.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/api/preferences_controller.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/api/proof_controller.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/api/push_subscription_controller.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/api/relationship_import_controller.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/api/relationship_json.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/api/report_controller.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/api/response.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/api/scheduled_status_controller.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/api/scrobble_controller.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/api/search_controller.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/api/settings_controller.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/api/static_site_controller.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/api/status_action_controller.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/api/status_json.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/api/status_pin_controller.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/api/status_reaction_controller.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/api/status_read_controller.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/api/suggestion_controller.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/api/tag_controller.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/api/timeline_controller.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/api/trend_controller.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/api/two_factor_authentication_controller.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/api/user_controller.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/api/utility_controller.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/api/webhook_controller.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/autoconfig_controller.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/bluesky_identity_controller.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/caddy_tls_controller.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/captcha_controller.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/changeset_json.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/connected_account_controller.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/dav/calendar_controller.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/dav/drive_controller.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/dav/principal_controller.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/dav_controller.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/drive_controller.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/drive_share_controller.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/error_html.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/error_html/403.html.heex
create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/error_html/404.html.heex
create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/error_html/413.html.heex
create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/error_html/500.html.heex
create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/error_json.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/fallback_controller.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/health_controller.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/internal_acme_dns_controller.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/internal_dns_controller.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/internal_edge_proxy_controller.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/link_controller.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/locale_controller.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/mail_security_controller.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/messaging_federation_controller.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/nerve_extension_controller.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/oidc_client_controller.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/oidc_client_html.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/oidc_client_html/edit.html.heex
create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/oidc_client_html/index.html.heex
create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/oidc_client_html/new.html.heex
create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/oidc_controller.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/oidc_grant_controller.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/oidc_grant_html.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/oidc_grant_html/index.html.heex
create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/oidc_html.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/oidc_html/authorize.html.heex
create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/own_root_controller.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/page_controller.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/page_html.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/page_html/canary.html.heex
create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/passkey_controller.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/password_reset_controller.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/password_reset_html.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/password_reset_html/edit.html.heex
create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/password_reset_html/new.html.heex
create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/private_attachment_controller.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/profile_controller.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/profile_html.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/recovery_email_controller.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/recovery_email_html.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/recovery_email_html/error.html.heex
create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/recovery_email_html/success.html.heex
create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/registration_payment_controller.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/registration_payment_html.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/registration_payment_html/show.html.heex
create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/sitemap_controller.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/sitemap_html.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/sitemap_xml/index.xml.eex
create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/stripe_webhook_controller.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/two_factor_controller.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/two_factor_html.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/two_factor_html/new.html.heex
create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/user_registration_controller.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/user_session_controller.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/user_settings_controller.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/user_settings_html.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/user_settings_html/delete.html.heex
create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/user_settings_html/edit.html.heex
create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/user_settings_html/edit_password.html.heex
create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/user_settings_html/two_factor_manage.html.heex
create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/user_settings_html/two_factor_new_codes.html.heex
create mode 100644 apps/elektrine_web/lib/elektrine_web/controllers/user_settings_html/two_factor_setup.html.heex
create mode 100644 apps/elektrine_web/lib/elektrine_web/dav/properties.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/dav/response_helpers.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/endpoint.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/gettext.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/github_oidc.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/github_webhooks.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/html_helpers.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/json_log_formatter.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/live/admin_live/badge_management.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/live/admin_live/badge_management.html.heex
create mode 100644 apps/elektrine_web/lib/elektrine_web/live/admin_live/bluesky_bridge.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/live/admin_live/emojis.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/live/admin_live/emojis.html.heex
create mode 100644 apps/elektrine_web/lib/elektrine_web/live/admin_live/federation.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/live/admin_live/messaging_federation.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/live/admin_live/relays.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/live/admin_live/reports_dashboard.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/live/announcement_cache.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/live/atomine_proofs_live/show.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/live/atomine_proofs_live/show.html.heex
create mode 100644 apps/elektrine_web/lib/elektrine_web/live/auth_hooks.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/live/auth_live/login.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/live/auth_live/password_reset.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/live/auth_live/password_reset_edit.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/live/auth_live/register.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/live/auth_live/two_factor.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/live/booking_live/cancel.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/live/booking_live/manage.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/live/booking_live/show.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/live/calendar_live/index.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/live/calendar_live/index.html.heex
create mode 100644 apps/elektrine_web/lib/elektrine_web/live/calendar_live/operations/calendar_operations.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/live/components/report_modal.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/live/drive_live.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/live/drive_live.html.heex
create mode 100644 apps/elektrine_web/lib/elektrine_web/live/e_nav_live.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/live/friends_live.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/live/friends_live.html.heex
create mode 100644 apps/elektrine_web/lib/elektrine_web/live/helpers/post_state_helpers.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/live/hooks/current_path_hook.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/live/hooks/notification_count_hook.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/live/hooks/platform_module_hook.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/live/hooks/presence_hook.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/live/hooks/timezone_hook.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/live/kairo_live/index.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/live/link_archive_live/index.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/live/nav_badge_live.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/live/notification_helpers.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/live/notifications_live.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/live/notifications_live.html.heex
create mode 100644 apps/elektrine_web/lib/elektrine_web/live/onboarding_live.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/live/page_live/about.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/live/page_live/contact.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/live/page_live/dev_flash_test.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/live/page_live/faq.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/live/page_live/home.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/live/page_live/legal.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/live/page_live/privacy.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/live/page_live/terms.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/live/post_interactions.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/live/profile_live/analytics.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/live/profile_live/analytics.html.heex
create mode 100644 apps/elektrine_web/lib/elektrine_web/live/profile_live/design_sections.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/live/profile_live/design_theme_sections.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/live/profile_live/domain_analytics.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/live/profile_live/domain_analytics.html.heex
create mode 100644 apps/elektrine_web/lib/elektrine_web/live/profile_live/domains.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/live/profile_live/edit.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/live/profile_live/edit.html.heex
create mode 100644 apps/elektrine_web/lib/elektrine_web/live/profile_live/edit_sections.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/live/profile_live/effects_sections.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/live/profile_live/show.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/live/profile_live/show.html.heex
create mode 100644 apps/elektrine_web/lib/elektrine_web/live/proofs_live.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/live/recovery_email_live/error.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/live/recovery_email_live/success.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/live/search_live.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/live/settings_live/app_passwords.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/live/settings_live/delete_account.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/live/settings_live/edit_password.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/live/settings_live/master_password.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/live/settings_live/passkey_manage.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/live/settings_live/rss.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/live/settings_live/two_factor_manage.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/live/settings_live/two_factor_setup.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/live/storage_live.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/live/storage_live.html.heex
create mode 100644 apps/elektrine_web/lib/elektrine_web/live/subscribe_live.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/live/user_settings_live.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/live/user_settings_live.html.heex
create mode 100644 apps/elektrine_web/lib/elektrine_web/mail_autoconfig.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/mcp/protocol.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/mcp/tool_registry.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/mcp/tools.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/optional_module.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/platform/integrations.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/platform/module_delegates.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/platform_access.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/plugs/activitypub_accept.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/plugs/activitypub_rate_limit.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/plugs/activitypub_request_guard.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/plugs/api_auth.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/plugs/api_rate_limit.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/plugs/block_private_upload_paths.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/plugs/cache_raw_body.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/plugs/conditional_live_reloader.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/plugs/csrf_error_handler.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/plugs/dav_auth.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/plugs/dav_rate_limit.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/plugs/dns_edge_proxy.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/plugs/enforce_https.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/plugs/ensure_http_signature_plug.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/plugs/http_signature_plug.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/plugs/internal_api_auth.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/plugs/locale.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/plugs/messaging_federation_auth.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/plugs/notification_count.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/plugs/optional_delegate.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/plugs/pat_auth.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/plugs/profile_csp.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/plugs/profile_custom_domain.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/plugs/profile_subdomain.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/plugs/receive_only_email_domain_host.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/plugs/request_telemetry.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/plugs/require_module_access.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/plugs/require_platform_module.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/plugs/runtime_session.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/plugs/security_headers.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/plugs/site_challenge.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/plugs/site_page_tracking.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/plugs/static_site_plug.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/plugs/timezone_plug.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/plugs/tor_aware.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/plugs/webdav_method_override.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/post_navigation.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/profile_navigation.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/profile_theme.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/router.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/routes/chat.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/routes/dns.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/routes/email.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/routes/nerve.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/routes/social.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/routes/uptime.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/routes/vpn.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/safe_live_navigation.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/scrubbers/remote_content.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/session_config.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/telemetry.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/telemetry_measurements.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/url_helpers.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/user_auth.ex
create mode 100644 apps/elektrine_web/lib/elektrine_web/web_search.ex
create mode 100644 apps/elektrine_web/mix.exs
create mode 100644 apps/elektrine_web/priv/canary/canary-public-key.asc
create mode 100644 apps/elektrine_web/priv/canary/current.md
create mode 100644 apps/elektrine_web/priv/canary/current.md.asc
create mode 100644 apps/elektrine_web/priv/nerve-extension/background.js
create mode 100644 apps/elektrine_web/priv/nerve-extension/content.js
create mode 100644 apps/elektrine_web/priv/nerve-extension/launcher.js
create mode 100644 apps/elektrine_web/priv/nerve-extension/lib/api.js
create mode 100644 apps/elektrine_web/priv/nerve-extension/lib/crypto.js
create mode 100644 apps/elektrine_web/priv/nerve-extension/lib/kairo_capture.js
create mode 100644 apps/elektrine_web/priv/nerve-extension/lib/storage.js
create mode 100644 apps/elektrine_web/priv/nerve-extension/lib/theme.js
create mode 100644 apps/elektrine_web/priv/nerve-extension/manager.html
create mode 100644 apps/elektrine_web/priv/nerve-extension/manifest.json
create mode 100644 apps/elektrine_web/priv/nerve-extension/options.html
create mode 100644 apps/elektrine_web/priv/nerve-extension/options.js
create mode 100644 apps/elektrine_web/priv/nerve-extension/package-lock.json
create mode 100644 apps/elektrine_web/priv/nerve-extension/package.json
create mode 100644 apps/elektrine_web/priv/nerve-extension/popup.html
create mode 100644 apps/elektrine_web/priv/nerve-extension/popup.js
create mode 100644 apps/elektrine_web/priv/nerve-extension/styles.css
create mode 100644 apps/elektrine_web/test/elektrine/components/user/avatar_test.exs
create mode 100644 apps/elektrine_web/test/elektrine_web/atomine_pow_test.exs
create mode 100644 apps/elektrine_web/test/elektrine_web/channels/call_channel_test.exs
create mode 100644 apps/elektrine_web/test/elektrine_web/channels/voice_channel_test.exs
create mode 100644 apps/elektrine_web/test/elektrine_web/client_ip_test.exs
create mode 100644 apps/elektrine_web/test/elektrine_web/components/emoji_picker_test.exs
create mode 100644 apps/elektrine_web/test/elektrine_web/components/layouts_test.exs
create mode 100644 apps/elektrine_web/test/elektrine_web/components/presence_helpers_test.exs
create mode 100644 apps/elektrine_web/test/elektrine_web/components/social/post_actions_test.exs
create mode 100644 apps/elektrine_web/test/elektrine_web/components/social/post_utilities_test.exs
create mode 100644 apps/elektrine_web/test/elektrine_web/components/social/youtube_preview_test.exs
create mode 100644 apps/elektrine_web/test/elektrine_web/components/ui/image_modal_test.exs
create mode 100644 apps/elektrine_web/test/elektrine_web/components/ui/uplot_chart_test.exs
create mode 100644 apps/elektrine_web/test/elektrine_web/components/user/hover_card_test.exs
create mode 100644 apps/elektrine_web/test/elektrine_web/components/user/username_effects_test.exs
create mode 100644 apps/elektrine_web/test/elektrine_web/controllers/activitypub_test.exs
create mode 100644 apps/elektrine_web/test/elektrine_web/controllers/admin/announcements_controller_test.exs
create mode 100644 apps/elektrine_web/test/elektrine_web/controllers/admin/chat_messages_controller_test.exs
create mode 100644 apps/elektrine_web/test/elektrine_web/controllers/admin/communities_controller_test.exs
create mode 100644 apps/elektrine_web/test/elektrine_web/controllers/admin/custom_domains_controller_test.exs
create mode 100644 apps/elektrine_web/test/elektrine_web/controllers/admin/deletion_requests_controller_test.exs
create mode 100644 apps/elektrine_web/test/elektrine_web/controllers/admin/elevate_page_test.exs
create mode 100644 apps/elektrine_web/test/elektrine_web/controllers/admin/email_delivery_controller_test.exs
create mode 100644 apps/elektrine_web/test/elektrine_web/controllers/admin/haraka_controller_test.exs
create mode 100644 apps/elektrine_web/test/elektrine_web/controllers/admin/invite_codes_controller_test.exs
create mode 100644 apps/elektrine_web/test/elektrine_web/controllers/admin/messages_controller_test.exs
create mode 100644 apps/elektrine_web/test/elektrine_web/controllers/admin/moderation_controller_test.exs
create mode 100644 apps/elektrine_web/test/elektrine_web/controllers/admin/monitoring_controller_test.exs
create mode 100644 apps/elektrine_web/test/elektrine_web/controllers/admin/users_controller_test.exs
create mode 100644 apps/elektrine_web/test/elektrine_web/controllers/admin/vpn_controller_test.exs
create mode 100644 apps/elektrine_web/test/elektrine_web/controllers/api/account_birthday_controller_test.exs
create mode 100644 apps/elektrine_web/test/elektrine_web/controllers/api/account_credential_controller_test.exs
create mode 100644 apps/elektrine_web/test/elektrine_web/controllers/api/account_directory_controller_test.exs
create mode 100644 apps/elektrine_web/test/elektrine_web/controllers/api/account_registration_controller_test.exs
create mode 100644 apps/elektrine_web/test/elektrine_web/controllers/api/account_relationship_controller_test.exs
create mode 100644 apps/elektrine_web/test/elektrine_web/controllers/api/account_relationship_follow_controller_test.exs
create mode 100644 apps/elektrine_web/test/elektrine_web/controllers/api/account_search_controller_test.exs
create mode 100644 apps/elektrine_web/test/elektrine_web/controllers/api/account_status_controller_test.exs
create mode 100644 apps/elektrine_web/test/elektrine_web/controllers/api/announcement_controller_test.exs
create mode 100644 apps/elektrine_web/test/elektrine_web/controllers/api/app_controller_test.exs
create mode 100644 apps/elektrine_web/test/elektrine_web/controllers/api/atomine_attestation_controller_test.exs
create mode 100644 apps/elektrine_web/test/elektrine_web/controllers/api/auth_controller_test.exs
create mode 100644 apps/elektrine_web/test/elektrine_web/controllers/api/backup_controller_test.exs
create mode 100644 apps/elektrine_web/test/elektrine_web/controllers/api/bookmark_controller_test.exs
create mode 100644 apps/elektrine_web/test/elektrine_web/controllers/api/chat_compat_controller_test.exs
create mode 100644 apps/elektrine_web/test/elektrine_web/controllers/api/conversation_controller_test.exs
create mode 100644 apps/elektrine_web/test/elektrine_web/controllers/api/custom_emoji_controller_test.exs
create mode 100644 apps/elektrine_web/test/elektrine_web/controllers/api/direct_conversation_controller_test.exs
create mode 100644 apps/elektrine_web/test/elektrine_web/controllers/api/dns_controller_test.exs
create mode 100644 apps/elektrine_web/test/elektrine_web/controllers/api/domain_block_controller_test.exs
create mode 100644 apps/elektrine_web/test/elektrine_web/controllers/api/email_controller_test.exs
create mode 100644 apps/elektrine_web/test/elektrine_web/controllers/api/export_controller_test.exs
create mode 100644 apps/elektrine_web/test/elektrine_web/controllers/api/ext_v1_controller_test.exs
create mode 100644 apps/elektrine_web/test/elektrine_web/controllers/api/favourite_controller_test.exs
create mode 100644 apps/elektrine_web/test/elektrine_web/controllers/api/filter_controller_test.exs
create mode 100644 apps/elektrine_web/test/elektrine_web/controllers/api/instance_controller_test.exs
create mode 100644 apps/elektrine_web/test/elektrine_web/controllers/api/kairo_controller_test.exs
create mode 100644 apps/elektrine_web/test/elektrine_web/controllers/api/list_controller_test.exs
create mode 100644 apps/elektrine_web/test/elektrine_web/controllers/api/mcp_controller_test.exs
create mode 100644 apps/elektrine_web/test/elektrine_web/controllers/api/media_attachment_controller_test.exs
create mode 100644 apps/elektrine_web/test/elektrine_web/controllers/api/notification_controller_test.exs
create mode 100644 apps/elektrine_web/test/elektrine_web/controllers/api/password_reset_controller_test.exs
create mode 100644 apps/elektrine_web/test/elektrine_web/controllers/api/poll_controller_test.exs
create mode 100644 apps/elektrine_web/test/elektrine_web/controllers/api/preference_controller_test.exs
create mode 100644 apps/elektrine_web/test/elektrine_web/controllers/api/preferences_controller_test.exs
create mode 100644 apps/elektrine_web/test/elektrine_web/controllers/api/push_subscription_controller_test.exs
create mode 100644 apps/elektrine_web/test/elektrine_web/controllers/api/relationship_import_controller_test.exs
create mode 100644 apps/elektrine_web/test/elektrine_web/controllers/api/report_controller_test.exs
create mode 100644 apps/elektrine_web/test/elektrine_web/controllers/api/scheduled_status_controller_test.exs
create mode 100644 apps/elektrine_web/test/elektrine_web/controllers/api/scrobble_controller_test.exs
create mode 100644 apps/elektrine_web/test/elektrine_web/controllers/api/search_controller_test.exs
create mode 100644 apps/elektrine_web/test/elektrine_web/controllers/api/server_controller_test.exs
create mode 100644 apps/elektrine_web/test/elektrine_web/controllers/api/settings_controller_test.exs
create mode 100644 apps/elektrine_web/test/elektrine_web/controllers/api/social_controller_test.exs
create mode 100644 apps/elektrine_web/test/elektrine_web/controllers/api/status_action_controller_test.exs
create mode 100644 apps/elektrine_web/test/elektrine_web/controllers/api/status_pin_controller_test.exs
create mode 100644 apps/elektrine_web/test/elektrine_web/controllers/api/status_reaction_controller_test.exs
create mode 100644 apps/elektrine_web/test/elektrine_web/controllers/api/status_read_controller_test.exs
create mode 100644 apps/elektrine_web/test/elektrine_web/controllers/api/suggestion_controller_test.exs
create mode 100644 apps/elektrine_web/test/elektrine_web/controllers/api/tag_controller_test.exs
create mode 100644 apps/elektrine_web/test/elektrine_web/controllers/api/timeline_controller_test.exs
create mode 100644 apps/elektrine_web/test/elektrine_web/controllers/api/trend_controller_test.exs
create mode 100644 apps/elektrine_web/test/elektrine_web/controllers/api/two_factor_authentication_controller_test.exs
create mode 100644 apps/elektrine_web/test/elektrine_web/controllers/api/utility_controller_test.exs
create mode 100644 apps/elektrine_web/test/elektrine_web/controllers/api/vpn_controller_test.exs
create mode 100644 apps/elektrine_web/test/elektrine_web/controllers/api/vpn_registration_controller_test.exs
create mode 100644 apps/elektrine_web/test/elektrine_web/controllers/api/webhook_controller_test.exs
create mode 100644 apps/elektrine_web/test/elektrine_web/controllers/arblarg_conformance_test.exs
create mode 100644 apps/elektrine_web/test/elektrine_web/controllers/autoconfig_controller_test.exs
create mode 100644 apps/elektrine_web/test/elektrine_web/controllers/caddy_tls_controller_test.exs
create mode 100644 apps/elektrine_web/test/elektrine_web/controllers/connected_account_controller_test.exs
create mode 100644 apps/elektrine_web/test/elektrine_web/controllers/dav/address_book_controller_test.exs
create mode 100644 apps/elektrine_web/test/elektrine_web/controllers/dav/calendar_controller_test.exs
create mode 100644 apps/elektrine_web/test/elektrine_web/controllers/dav/drive_controller_test.exs
create mode 100644 apps/elektrine_web/test/elektrine_web/controllers/drive_controller_test.exs
create mode 100644 apps/elektrine_web/test/elektrine_web/controllers/drive_share_controller_test.exs
create mode 100644 apps/elektrine_web/test/elektrine_web/controllers/email_controller_test.exs
create mode 100644 apps/elektrine_web/test/elektrine_web/controllers/email_delivery_smoke_test.exs
create mode 100644 apps/elektrine_web/test/elektrine_web/controllers/error_html_test.exs
create mode 100644 apps/elektrine_web/test/elektrine_web/controllers/error_json_test.exs
create mode 100644 apps/elektrine_web/test/elektrine_web/controllers/external_interaction_controller_test.exs
create mode 100644 apps/elektrine_web/test/elektrine_web/controllers/haraka_webhook_controller_test.exs
create mode 100644 apps/elektrine_web/test/elektrine_web/controllers/health_controller_test.exs
create mode 100644 apps/elektrine_web/test/elektrine_web/controllers/impersonation_controller_test.exs
create mode 100644 apps/elektrine_web/test/elektrine_web/controllers/internal_acme_dns_controller_test.exs
create mode 100644 apps/elektrine_web/test/elektrine_web/controllers/internal_dns_controller_test.exs
create mode 100644 apps/elektrine_web/test/elektrine_web/controllers/jmap_controller_test.exs
create mode 100644 apps/elektrine_web/test/elektrine_web/controllers/link_controller_test.exs
create mode 100644 apps/elektrine_web/test/elektrine_web/controllers/mail_security_controller_test.exs
create mode 100644 apps/elektrine_web/test/elektrine_web/controllers/media_proxy_controller_test.exs
create mode 100644 apps/elektrine_web/test/elektrine_web/controllers/messaging_federation_controller_test.exs
create mode 100644 apps/elektrine_web/test/elektrine_web/controllers/messaging_federation_rate_limit_test.exs
create mode 100644 apps/elektrine_web/test/elektrine_web/controllers/nerve_extension_controller_test.exs
create mode 100644 apps/elektrine_web/test/elektrine_web/controllers/oidc_client_controller_test.exs
create mode 100644 apps/elektrine_web/test/elektrine_web/controllers/oidc_controller_test.exs
create mode 100644 apps/elektrine_web/test/elektrine_web/controllers/oidc_grant_controller_test.exs
create mode 100644 apps/elektrine_web/test/elektrine_web/controllers/own_root_controller_test.exs
create mode 100644 apps/elektrine_web/test/elektrine_web/controllers/page_controller_test.exs
create mode 100644 apps/elektrine_web/test/elektrine_web/controllers/password_reset_controller_test.exs
create mode 100644 apps/elektrine_web/test/elektrine_web/controllers/private_attachment_controller_test.exs
create mode 100644 apps/elektrine_web/test/elektrine_web/controllers/profile_controller_test.exs
create mode 100644 apps/elektrine_web/test/elektrine_web/controllers/recovery_email_controller_test.exs
create mode 100644 apps/elektrine_web/test/elektrine_web/controllers/registration_payment_controller_test.exs
create mode 100644 apps/elektrine_web/test/elektrine_web/controllers/unsubscribe_controller_test.exs
create mode 100644 apps/elektrine_web/test/elektrine_web/controllers/user_registration_controller_test.exs
create mode 100644 apps/elektrine_web/test/elektrine_web/controllers/user_session_controller_test.exs
create mode 100644 apps/elektrine_web/test/elektrine_web/controllers/user_settings_controller_test.exs
create mode 100644 apps/elektrine_web/test/elektrine_web/controllers/user_settings_html_test.exs
create mode 100644 apps/elektrine_web/test/elektrine_web/controllers/vpn_api_controller_test.exs
create mode 100644 apps/elektrine_web/test/elektrine_web/controllers/wkd_controller_test.exs
create mode 100644 apps/elektrine_web/test/elektrine_web/features/auth_test.exs
create mode 100644 apps/elektrine_web/test/elektrine_web/features/chat_test.exs
create mode 100644 apps/elektrine_web/test/elektrine_web/features/email_test.exs
create mode 100644 apps/elektrine_web/test/elektrine_web/features/home_page_test.exs
create mode 100644 apps/elektrine_web/test/elektrine_web/features/profile_test.exs
create mode 100644 apps/elektrine_web/test/elektrine_web/features/timeline_test.exs
create mode 100644 apps/elektrine_web/test/elektrine_web/html_helpers_actor_test.exs
create mode 100644 apps/elektrine_web/test/elektrine_web/live/admin_live/badge_management_test.exs
create mode 100644 apps/elektrine_web/test/elektrine_web/live/admin_live/emojis_test.exs
create mode 100644 apps/elektrine_web/test/elektrine_web/live/admin_live/federation_test.exs
create mode 100644 apps/elektrine_web/test/elektrine_web/live/admin_live/relays_test.exs
create mode 100644 apps/elektrine_web/test/elektrine_web/live/admin_live/reports_dashboard_test.exs
create mode 100644 apps/elektrine_web/test/elektrine_web/live/app_passwords_live_test.exs
create mode 100644 apps/elektrine_web/test/elektrine_web/live/atomine_proofs_live/show_test.exs
create mode 100644 apps/elektrine_web/test/elektrine_web/live/auth_live/register_security_test.exs
create mode 100644 apps/elektrine_web/test/elektrine_web/live/auth_pow_copy_test.exs
create mode 100644 apps/elektrine_web/test/elektrine_web/live/authenticated_routes_live_test.exs
create mode 100644 apps/elektrine_web/test/elektrine_web/live/booking_live_test.exs
create mode 100644 apps/elektrine_web/test/elektrine_web/live/booking_manage_live_test.exs
create mode 100644 apps/elektrine_web/test/elektrine_web/live/calendar_live/operations/calendar_operations_test.exs
create mode 100644 apps/elektrine_web/test/elektrine_web/live/chat_live/index_test.exs
create mode 100644 apps/elektrine_web/test/elektrine_web/live/chat_live/operations/call_info_operations_test.exs
create mode 100644 apps/elektrine_web/test/elektrine_web/live/chat_live/operations/helpers_test.exs
create mode 100644 apps/elektrine_web/test/elektrine_web/live/chat_live/operations/message_info_operations_test.exs
create mode 100644 apps/elektrine_web/test/elektrine_web/live/chat_live/router_test.exs
create mode 100644 apps/elektrine_web/test/elektrine_web/live/chat_live_index_test.exs
create mode 100644 apps/elektrine_web/test/elektrine_web/live/chat_live_threads_test.exs
create mode 100644 apps/elektrine_web/test/elektrine_web/live/community_post_test.exs
create mode 100644 apps/elektrine_web/test/elektrine_web/live/discussions_index_live_test.exs
create mode 100644 apps/elektrine_web/test/elektrine_web/live/discussions_live/operations/member_operations_test.exs
create mode 100644 apps/elektrine_web/test/elektrine_web/live/discussions_settings_test.exs
create mode 100644 apps/elektrine_web/test/elektrine_web/live/drive_live_test.exs
create mode 100644 apps/elektrine_web/test/elektrine_web/live/email_compose_live_test.exs
create mode 100644 apps/elektrine_web/test/elektrine_web/live/email_live/compose_paragraph_spacing_test.exs
create mode 100644 apps/elektrine_web/test/elektrine_web/live/email_live/compose_test.exs
create mode 100644 apps/elektrine_web/test/elektrine_web/live/email_live/email_helpers_test.exs
create mode 100644 apps/elektrine_web/test/elektrine_web/live/email_live/index_test.exs
create mode 100644 apps/elektrine_web/test/elektrine_web/live/email_live/search_test.exs
create mode 100644 apps/elektrine_web/test/elektrine_web/live/email_live/settings_operations_test.exs
create mode 100644 apps/elektrine_web/test/elektrine_web/live/email_live/show_private_mailbox_test.exs
create mode 100644 apps/elektrine_web/test/elektrine_web/live/email_live/show_test.exs
create mode 100644 apps/elektrine_web/test/elektrine_web/live/email_live/show_thread_context_test.exs
create mode 100644 apps/elektrine_web/test/elektrine_web/live/friends_live_test.exs
create mode 100644 apps/elektrine_web/test/elektrine_web/live/gallery_live_test.exs
create mode 100644 apps/elektrine_web/test/elektrine_web/live/hashtag_live_show_test.exs
create mode 100644 apps/elektrine_web/test/elektrine_web/live/helpers/post_state_helpers_test.exs
create mode 100644 apps/elektrine_web/test/elektrine_web/live/kairo_live_test.exs
create mode 100644 apps/elektrine_web/test/elektrine_web/live/link_archive_live_test.exs
create mode 100644 apps/elektrine_web/test/elektrine_web/live/list_live_test.exs
create mode 100644 apps/elektrine_web/test/elektrine_web/live/locale_live_test.exs
create mode 100644 apps/elektrine_web/test/elektrine_web/live/master_password_live_test.exs
create mode 100644 apps/elektrine_web/test/elektrine_web/live/nerve_live_test.exs
create mode 100644 apps/elektrine_web/test/elektrine_web/live/notifications_live_test.exs
create mode 100644 apps/elektrine_web/test/elektrine_web/live/page_live/home_test.exs
create mode 100644 apps/elektrine_web/test/elektrine_web/live/page_live/static_pages_test.exs
create mode 100644 apps/elektrine_web/test/elektrine_web/live/paige_live_test.exs
create mode 100644 apps/elektrine_web/test/elektrine_web/live/passkey_manage_live_test.exs
create mode 100644 apps/elektrine_web/test/elektrine_web/live/profile_live/edit_security_test.exs
create mode 100644 apps/elektrine_web/test/elektrine_web/live/profile_live/show_security_test.exs
create mode 100644 apps/elektrine_web/test/elektrine_web/live/profile_live_domain_analytics_test.exs
create mode 100644 apps/elektrine_web/test/elektrine_web/live/profile_live_domains_test.exs
create mode 100644 apps/elektrine_web/test/elektrine_web/live/proofs_live_test.exs
create mode 100644 apps/elektrine_web/test/elektrine_web/live/remote_post_live/seo_test.exs
create mode 100644 apps/elektrine_web/test/elektrine_web/live/remote_post_live_show_test.exs
create mode 100644 apps/elektrine_web/test/elektrine_web/live/remote_user_live/follow_button_test.exs
create mode 100644 apps/elektrine_web/test/elektrine_web/live/remote_user_live/seo_test.exs
create mode 100644 apps/elektrine_web/test/elektrine_web/live/remote_user_live/show_test.exs
create mode 100644 apps/elektrine_web/test/elektrine_web/live/storage_live_test.exs
create mode 100644 apps/elektrine_web/test/elektrine_web/live/subscribe_live_test.exs
create mode 100644 apps/elektrine_web/test/elektrine_web/live/timeline_filters_test.exs
create mode 100644 apps/elektrine_web/test/elektrine_web/live/timeline_live/operations/image_operations_test.exs
create mode 100644 apps/elektrine_web/test/elektrine_web/live/timeline_live/operations/reply_operations_test.exs
create mode 100644 apps/elektrine_web/test/elektrine_web/live/timeline_live/operations/social_operations_test.exs
create mode 100644 apps/elektrine_web/test/elektrine_web/live/timeline_live/reply_context_previews_test.exs
create mode 100644 apps/elektrine_web/test/elektrine_web/live/timeline_post_detail_test.exs
create mode 100644 apps/elektrine_web/test/elektrine_web/live/user_invite_codes_live_test.exs
create mode 100644 apps/elektrine_web/test/elektrine_web/live/user_settings_live_test.exs
create mode 100644 apps/elektrine_web/test/elektrine_web/live/user_settings_pgp_test.exs
create mode 100644 apps/elektrine_web/test/elektrine_web/live/videos_live_test.exs
create mode 100644 apps/elektrine_web/test/elektrine_web/platform/admin_subset_test.exs
create mode 100644 apps/elektrine_web/test/elektrine_web/platform/shared_routes_subset_test.exs
create mode 100644 apps/elektrine_web/test/elektrine_web/platform_access_test.exs
create mode 100644 apps/elektrine_web/test/elektrine_web/plugs/api_rate_limit_test.exs
create mode 100644 apps/elektrine_web/test/elektrine_web/plugs/dav_auth_test.exs
create mode 100644 apps/elektrine_web/test/elektrine_web/plugs/dns_edge_proxy_test.exs
create mode 100644 apps/elektrine_web/test/elektrine_web/plugs/email_ownership_guard_test.exs
create mode 100644 apps/elektrine_web/test/elektrine_web/plugs/enforce_https_test.exs
create mode 100644 apps/elektrine_web/test/elektrine_web/plugs/http_signature_plug_test.exs
create mode 100644 apps/elektrine_web/test/elektrine_web/plugs/optional_delegate_test.exs
create mode 100644 apps/elektrine_web/test/elektrine_web/plugs/profile_custom_domain_test.exs
create mode 100644 apps/elektrine_web/test/elektrine_web/plugs/profile_subdomain_test.exs
create mode 100644 apps/elektrine_web/test/elektrine_web/plugs/receive_only_email_domain_host_test.exs
create mode 100644 apps/elektrine_web/test/elektrine_web/plugs/require_platform_module_test.exs
create mode 100644 apps/elektrine_web/test/elektrine_web/plugs/security_headers_test.exs
create mode 100644 apps/elektrine_web/test/elektrine_web/plugs/site_challenge_test.exs
create mode 100644 apps/elektrine_web/test/elektrine_web/plugs/static_site_plug_test.exs
create mode 100644 apps/elektrine_web/test/elektrine_web/presence_test.exs
create mode 100644 apps/elektrine_web/test/elektrine_web/profile_navigation_test.exs
create mode 100644 apps/elektrine_web/test/elektrine_web/safe_live_navigation_test.exs
create mode 100644 apps/elektrine_web/test/elektrine_web/user_auth_test.exs
create mode 100644 apps/elektrine_web/test/elektrine_web/web_search_test.exs
create mode 100644 apps/elektrine_web/test/support/channel_case.ex
create mode 100644 apps/elektrine_web/test/support/conn_case.ex
create mode 100644 apps/elektrine_web/test/support/data_case.ex
create mode 100644 apps/elektrine_web/test/support/fixtures/accounts_fixtures.ex
create mode 100644 apps/elektrine_web/test/support/fixtures/email_fixtures.ex
create mode 100644 apps/elektrine_web/test/support/fixtures/social_fixtures.ex
create mode 100644 apps/elektrine_web/test/test_helper.exs
create mode 100644 apps/kairo/lib/kairo.ex
create mode 100644 apps/kairo/lib/kairo/project.ex
create mode 100644 apps/kairo/lib/kairo/source.ex
create mode 100644 apps/kairo/lib/kairo/url_fetch_worker.ex
create mode 100644 apps/kairo/mix.exs
create mode 100644 apps/kairo/test/kairo/url_fetch_worker_test.exs
create mode 100644 apps/kairo/test/kairo_test.exs
create mode 100644 apps/kairo/test/support/accounts_fixtures.ex
create mode 100644 apps/kairo/test/support/data_case.ex
create mode 100644 apps/kairo/test/test_helper.exs
create mode 100644 apps/paige/lib/paige.ex
create mode 100644 apps/paige/lib/paige/application.ex
create mode 100644 apps/paige/lib/paige/http.ex
create mode 100644 apps/paige/lib/paige/kind.ex
create mode 100644 apps/paige/lib/paige/provider.ex
create mode 100644 apps/paige/lib/paige/providers/brave.ex
create mode 100644 apps/paige/lib/paige/providers/duck_duck_go.ex
create mode 100644 apps/paige/lib/paige/providers/github.ex
create mode 100644 apps/paige/lib/paige/providers/hacker_news.ex
create mode 100644 apps/paige/lib/paige/providers/wiby.ex
create mode 100644 apps/paige/lib/paige/providers/wikipedia.ex
create mode 100644 apps/paige/lib/paige/result.ex
create mode 100644 apps/paige/lib/paige/scraper_throttle.ex
create mode 100644 apps/paige/mix.exs
create mode 100644 apps/paige/test/paige/http_test.exs
create mode 100644 apps/paige/test/paige/providers_test.exs
create mode 100644 apps/paige/test/paige/scraper_providers_test.exs
create mode 100644 apps/paige/test/paige/scraper_throttle_test.exs
create mode 100644 apps/paige/test/paige_test.exs
create mode 100644 apps/paige/test/test_helper.exs
create mode 100644 clients/README.md
create mode 100644 clients/nerve-extension/background.js
create mode 100644 clients/nerve-extension/content.js
create mode 100644 clients/nerve-extension/launcher.js
create mode 100644 clients/nerve-extension/lib/api.js
create mode 100644 clients/nerve-extension/lib/crypto.js
create mode 100644 clients/nerve-extension/lib/kairo_capture.js
create mode 100644 clients/nerve-extension/lib/storage.js
create mode 100644 clients/nerve-extension/lib/theme.js
create mode 100644 clients/nerve-extension/manager.html
create mode 100644 clients/nerve-extension/manifest.json
create mode 100644 clients/nerve-extension/options.html
create mode 100644 clients/nerve-extension/options.js
create mode 100644 clients/nerve-extension/package-lock.json
create mode 100644 clients/nerve-extension/package.json
create mode 100644 clients/nerve-extension/popup.html
create mode 100644 clients/nerve-extension/popup.js
create mode 100644 clients/nerve-extension/styles.css
create mode 100644 config/config.exs
create mode 100644 config/dev.exs
create mode 100644 config/prod.exs
create mode 100644 config/runtime.exs
create mode 100644 config/runtime/bluesky.exs
create mode 100644 config/runtime/dns.exs
create mode 100644 config/runtime/mail_protocols.exs
create mode 100644 config/runtime/messaging_federation.exs
create mode 100644 config/runtime/stripe.exs
create mode 100644 config/runtime/uploads.exs
create mode 100644 config/runtime/webrtc.exs
create mode 100644 config/test.exs
create mode 100644 deploy/caddy/Caddyfile
create mode 100644 deploy/caddy/Caddyfile.baremetal
create mode 100644 deploy/caddy/Caddyfile.baremetal.external-certs
create mode 100644 deploy/caddy/Caddyfile.baremetal.wildcard-external
create mode 100644 deploy/caddy/Caddyfile.external-certs
create mode 100644 deploy/caddy/Caddyfile.wildcard-external
create mode 100644 deploy/caddy/Dockerfile
create mode 100644 deploy/docker/Dockerfile
create mode 100644 deploy/docker/compose.core.yml
create mode 100644 deploy/docker/compose.full.yml
create mode 100644 deploy/docker/compose.magpie-network.yml
create mode 100755 deploy/docker/docker-entrypoint.sh
create mode 100644 deploy/docker/elektrine-compose.service
create mode 100644 deploy/docker/initdb/010-extensions.sql
create mode 100755 deploy/docker/start.sh
create mode 100644 deploy/generated/.gitkeep
create mode 100644 deploy/onion/torrc
create mode 100644 deploy/terraform/vpn-fleet/.gitignore
create mode 100644 deploy/terraform/vpn-fleet/README.md
create mode 100644 deploy/terraform/vpn-fleet/examples/digitalocean/.terraform.lock.hcl
create mode 100644 deploy/terraform/vpn-fleet/examples/digitalocean/main.tf
create mode 100644 deploy/terraform/vpn-fleet/examples/digitalocean/variables.tf
create mode 100644 deploy/terraform/vpn-fleet/examples/hetzner/.terraform.lock.hcl
create mode 100644 deploy/terraform/vpn-fleet/examples/hetzner/main.tf
create mode 100644 deploy/terraform/vpn-fleet/examples/hetzner/variables.tf
create mode 100644 deploy/terraform/vpn-fleet/examples/multi-provider/.terraform.lock.hcl
create mode 100644 deploy/terraform/vpn-fleet/examples/multi-provider/main.tf
create mode 100644 deploy/terraform/vpn-fleet/examples/multi-provider/terraform.tfvars.example
create mode 100644 deploy/terraform/vpn-fleet/examples/multi-provider/variables.tf
create mode 100644 deploy/terraform/vpn-fleet/modules/digitalocean/main.tf
create mode 100644 deploy/terraform/vpn-fleet/modules/digitalocean/outputs.tf
create mode 100644 deploy/terraform/vpn-fleet/modules/digitalocean/variables.tf
create mode 100644 deploy/terraform/vpn-fleet/modules/hetzner/main.tf
create mode 100644 deploy/terraform/vpn-fleet/modules/hetzner/outputs.tf
create mode 100644 deploy/terraform/vpn-fleet/modules/hetzner/variables.tf
create mode 100644 deploy/terraform/vpn-fleet/modules/user-data/main.tf
create mode 100644 deploy/terraform/vpn-fleet/modules/user-data/outputs.tf
create mode 100644 deploy/terraform/vpn-fleet/modules/user-data/templates/cloud-init.yaml.tftpl
create mode 100644 deploy/terraform/vpn-fleet/modules/user-data/variables.tf
create mode 100644 deploy/terraform/vpn-fleet/modules/vultr/main.tf
create mode 100644 deploy/terraform/vpn-fleet/modules/vultr/outputs.tf
create mode 100644 deploy/terraform/vpn-fleet/modules/vultr/variables.tf
create mode 100644 docs/addons/onion.md
create mode 100644 docs/api/external-api.md
create mode 100644 docs/arblarg-1.0.md
create mode 100644 docs/architecture/composable-platform.md
create mode 100644 docs/architecture/dns-module.md
create mode 100644 docs/clients/mcp.md
create mode 100644 docs/clients/nerve-extension.md
create mode 100644 docs/clients/static-site-github-deploys.md
create mode 100644 docs/security/warrant-canary.md
create mode 100644 docs/self-hosting/README.md
create mode 100644 docs/self-hosting/baremetal.md
create mode 100644 docs/self-hosting/caddy.md
create mode 100644 docs/self-hosting/core.md
create mode 100644 docs/self-hosting/deploy-runbook.md
create mode 100644 docs/self-hosting/docker.md
create mode 100644 docs/self-hosting/mail.md
create mode 100644 docs/self-hosting/turn.md
create mode 100644 docs/self-hosting/vpn.md
create mode 100644 elixir_buildpack.config
create mode 100644 env/caddy.env.example
create mode 100644 env/core.env.example
create mode 100644 env/dev.env.example
create mode 100644 env/mail.env.example
create mode 100644 env/onion.env.example
create mode 100644 env/presets/bluesky.env
create mode 100644 env/presets/dns.env
create mode 100644 env/presets/mail.env
create mode 100644 env/presets/s3.env
create mode 100644 env/presets/tor.env
create mode 100644 env/presets/turn.env
create mode 100644 env/presets/vpn.env
create mode 100644 env/presets/wildcard-tls.env
create mode 100644 env/vpn.env.example
create mode 100644 external/arblarg/README.md
create mode 100644 external/arblarg/benchmarks/latest.json
create mode 100644 external/arblarg/interop/smoke.json
create mode 100644 external/arblarg/schemas/v1/ban.upsert.json
create mode 100644 external/arblarg/schemas/v1/dm.call.accept.json
create mode 100644 external/arblarg/schemas/v1/dm.call.end.json
create mode 100644 external/arblarg/schemas/v1/dm.call.invite.json
create mode 100644 external/arblarg/schemas/v1/dm.call.reject.json
create mode 100644 external/arblarg/schemas/v1/dm.call.signal.json
create mode 100644 external/arblarg/schemas/v1/dm.message.create.json
create mode 100644 external/arblarg/schemas/v1/envelope.json
create mode 100644 external/arblarg/schemas/v1/invite.upsert.json
create mode 100644 external/arblarg/schemas/v1/membership.upsert.json
create mode 100644 external/arblarg/schemas/v1/message.create.json
create mode 100644 external/arblarg/schemas/v1/message.delete.json
create mode 100644 external/arblarg/schemas/v1/message.update.json
create mode 100644 external/arblarg/schemas/v1/moderation.action.recorded.json
create mode 100644 external/arblarg/schemas/v1/permission.overwrite.upsert.json
create mode 100644 external/arblarg/schemas/v1/pin.upsert.json
create mode 100644 external/arblarg/schemas/v1/presence.update.json
create mode 100644 external/arblarg/schemas/v1/reaction.add.json
create mode 100644 external/arblarg/schemas/v1/reaction.remove.json
create mode 100644 external/arblarg/schemas/v1/read.cursor.json
create mode 100644 external/arblarg/schemas/v1/role.assignment.upsert.json
create mode 100644 external/arblarg/schemas/v1/role.upsert.json
create mode 100644 external/arblarg/schemas/v1/server.upsert.json
create mode 100644 external/arblarg/schemas/v1/thread.archive.json
create mode 100644 external/arblarg/schemas/v1/thread.upsert.json
create mode 100644 external/arblarg/schemas/v1/typing.start.json
create mode 100644 external/arblarg/schemas/v1/typing.stop.json
create mode 100644 external/arblarg/test_vectors/v1/error_cases.json
create mode 100644 external/arblarg/test_vectors/v1/event_message_create.json
create mode 100644 external/arblarg/test_vectors/v1/extension_events.json
create mode 100644 external/arblarg/test_vectors/v1/request_signature.json
create mode 120000 external/haraka-elektrine
create mode 100644 mise.toml
create mode 100644 mix.exs
create mode 100644 mix.lock
create mode 100644 phoenix_static_buildpack.config
create mode 100644 priv/repo/seeds.exs
create mode 100644 release_builder/README.md
create mode 100644 release_builder/config/config.exs
create mode 100644 release_builder/config/runtime.exs
create mode 100644 release_builder/lib/elektrine_release_builder.ex
create mode 100644 release_builder/mix.exs
create mode 100644 release_builder/module_selection.exs
create mode 100755 scripts/acme/dns_elektrine.sh
create mode 100755 scripts/acme/issue_elektrine_wildcard_cert.sh
create mode 100755 scripts/acme/setup_haraka_mail_tls.sh
create mode 100755 scripts/check_design_consistency.sh
create mode 100755 scripts/check_legacy_marker_budget.sh
create mode 100755 scripts/check_maintainability_budgets.sh
create mode 100755 scripts/check_tracked_generated_artifacts.sh
create mode 100755 scripts/deploy/build_and_push_image.sh
create mode 100755 scripts/deploy/configure_docker_source_ips.sh
create mode 100755 scripts/deploy/configure_haraka_wildcard_tls.sh
create mode 100755 scripts/deploy/deploy_pushed_image.sh
create mode 100755 scripts/deploy/docker_deploy.sh
create mode 100755 scripts/deploy/doctor.sh
create mode 100755 scripts/deploy/explain_deploy.sh
create mode 100755 scripts/deploy/generate_env.sh
create mode 100755 scripts/deploy/prune_old_images.sh
create mode 100755 scripts/deploy/render_docker_compose.sh
create mode 100755 scripts/deploy/self_host.sh
create mode 100644 scripts/lib/deploy_simplify.sh
create mode 100755 scripts/lib/module_selection.sh
create mode 100755 scripts/onion/sync-onion-key-secrets.sh
create mode 100755 scripts/release/deploy_release.sh
create mode 100644 scripts/vpn/README.md
create mode 100755 scripts/vpn/bootstrap-node.sh
create mode 100644 scripts/vpn/elektrine-vpn-agent.env.example
create mode 100644 scripts/vpn/elektrine-vpn-agent.service
create mode 100755 scripts/vpn/elektrine-vpn-agent.sh
create mode 100755 scripts/vpn/gen-cloud-init.sh
diff --git a/.credo.exs b/.credo.exs
new file mode 100644
index 0000000..b828da6
--- /dev/null
+++ b/.credo.exs
@@ -0,0 +1,170 @@
+# 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 `. 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/",
+ "web/",
+ "apps/*/lib/",
+ "apps/*/src/",
+ "apps/*/test/",
+ "apps/*/web/"
+ ],
+ 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: true,
+ #
+ # 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: %{
+ enabled: [
+ #
+ ## 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,
+ [if_nested_deeper_than: 99, if_called_more_often_than: 999]},
+ {Credo.Check.Design.TagFIXME, []},
+ # 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]},
+
+ #
+ ## 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.PipeIntoAnonymousFunctions, []},
+ {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, []},
+ {Credo.Check.Readability.WithSingleClause, []},
+
+ #
+ ## Refactoring Opportunities
+ #
+ {Credo.Check.Refactor.Apply, []},
+ {Credo.Check.Refactor.CondStatements, []},
+ {Credo.Check.Refactor.CyclomaticComplexity, [max_complexity: 120]},
+ {Credo.Check.Refactor.FilterCount, []},
+ {Credo.Check.Refactor.FilterFilter, []},
+ {Credo.Check.Refactor.FunctionArity, [max_arity: 20]},
+ {Credo.Check.Refactor.LongQuoteBlocks, [max_line_count: 1_000]},
+ {Credo.Check.Refactor.MapJoin, []},
+ {Credo.Check.Refactor.MatchInCondition, []},
+ {Credo.Check.Refactor.NegatedConditionsInUnless, []},
+ {Credo.Check.Refactor.NegatedConditionsWithElse, []},
+ {Credo.Check.Refactor.Nesting, [max_nesting: 20]},
+ {Credo.Check.Refactor.RedundantWithClauseResult, []},
+ {Credo.Check.Refactor.RejectReject, []},
+ {Credo.Check.Refactor.UnlessWithElse, []},
+ {Credo.Check.Refactor.WithClauses, []},
+
+ #
+ ## Warnings
+ #
+ {Credo.Check.Warning.ApplicationConfigInModuleAttribute, []},
+ {Credo.Check.Warning.BoolOperationOnSameValues, []},
+ {Credo.Check.Warning.Dbg, []},
+ {Credo.Check.Warning.ExpensiveEmptyEnumCheck, []},
+ {Credo.Check.Warning.IExPry, []},
+ {Credo.Check.Warning.IoInspect, []},
+ {Credo.Check.Warning.MissedMetadataKeyInLoggerConfig, []},
+ {Credo.Check.Warning.OperationOnSameValues, []},
+ {Credo.Check.Warning.OperationWithConstantResult, []},
+ {Credo.Check.Warning.RaiseInsideRescue, []},
+ {Credo.Check.Warning.SpecWithStruct, []},
+ {Credo.Check.Warning.StructFieldAmount, []},
+ {Credo.Check.Warning.UnsafeExec, []},
+ {Credo.Check.Warning.UnusedEnumOperation, []},
+ {Credo.Check.Warning.UnusedFileOperation, []},
+ {Credo.Check.Warning.UnusedKeywordOperation, []},
+ {Credo.Check.Warning.UnusedListOperation, []},
+ {Credo.Check.Warning.UnusedMapOperation, []},
+ {Credo.Check.Warning.UnusedPathOperation, []},
+ {Credo.Check.Warning.UnusedRegexOperation, []},
+ {Credo.Check.Warning.UnusedStringOperation, []},
+ {Credo.Check.Warning.UnusedTupleOperation, []},
+ {Credo.Check.Warning.WrongTestFileExtension, []}
+ ],
+ disabled: []
+ }
+ }
+ ]
+}
diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 0000000..2c7f7aa
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,71 @@
+# This file excludes paths from the Docker build context.
+#
+# By default, Docker's build context includes all files (and folders) in the
+# current directory. Even if a file isn't copied into the container it is still sent to
+# the Docker daemon.
+#
+# There are multiple reasons to exclude files from the build context:
+#
+# 1. Prevent nested folders from being copied into the container (ex: exclude
+# /assets/node_modules when copying /assets)
+# 2. Reduce the size of the build context and improve build time (ex. /build, /deps, /doc)
+# 3. Avoid sending files containing sensitive information
+#
+# More information on using .dockerignore is available here:
+# https://docs.docker.com/engine/reference/builder/#dockerignore-file
+
+.dockerignore
+
+# Ignore git, but keep git HEAD and refs to access current commit hash if needed:
+#
+# $ cat .git/HEAD | awk '{print ".git/"$2}' | xargs cat
+# d0b8727759e1e0e7aa3d41707d12376e373d5ecc
+.git
+!.git/HEAD
+!.git/refs
+
+# Common development/test artifacts
+/cover/
+/doc/
+/test/
+/tmp/
+.elixir_ls
+
+# Environment files and local secrets must never be sent to the Docker daemon.
+.env
+.env.*
+!.env.example
+env/*.env
+env/*.env.*
+
+# Private key and certificate material
+*.key
+*.pem
+*.p12
+*.pfx
+*.crt
+*.csr
+*.asc
+/certs/
+/secrets/
+
+# Mix artifacts
+/_build/
+/deps/
+apps/**/_build/
+apps/**/deps/
+*.ez
+
+# Generated on crash by the VM
+erl_crash.dump
+
+# Static artifacts - These should be fetched and built inside the Docker image
+/assets/node_modules/
+/priv/static/assets/
+/priv/static/cache_manifest.json
+apps/**/assets/node_modules/
+apps/**/priv/static/assets/
+apps/**/priv/static/cache_manifest.json
+
+# Local release artifacts
+/_deploy_release/
diff --git a/.env.example b/.env.example
new file mode 100644
index 0000000..491a208
--- /dev/null
+++ b/.env.example
@@ -0,0 +1,292 @@
+# Advanced example env file.
+# For the easiest first setup, run:
+# scripts/deploy/self_host.sh init --domain example.com --email admin@example.com
+# Copy this file to `.env.production` only if you want the larger kitchen-sink template.
+
+# -----------------------------------------------------------------------------
+# Core domains / hostnames
+# -----------------------------------------------------------------------------
+
+PRIMARY_DOMAIN=example.com
+
+# Optional overrides for split-domain setups. If unset, deploy tooling derives
+# them from PRIMARY_DOMAIN automatically.
+# PHX_HOST=example.com
+# INSTANCE_DOMAIN=example.com
+# EMAIL_DOMAIN=example.com
+# EMAIL_RAW_SOURCE_MAX_BYTES=10485760
+# SUPPORTED_DOMAINS=example.com
+# PROFILE_BASE_DOMAINS=example.com
+
+# Optional DNS branding overrides. If unset, the DNS service derives these from
+# PRIMARY_DOMAIN automatically.
+# DNS_NAMESERVERS=ns1.example.com,ns2.example.com
+# DNS_SOA_RNAME=admin.example.com
+
+# -----------------------------------------------------------------------------
+# Phoenix runtime
+# -----------------------------------------------------------------------------
+
+PHX_SERVER=true
+PORT=8080
+
+# Optional runtime overrides.
+# FORCE_SSL=false
+# TRUSTED_PROXY_CIDRS=172.30.0.0/24
+# PROXY_PROTOCOL_TRUSTED_CIDRS=203.0.113.10/32
+# CADDY_ASK_TRUSTED_CIDRS=172.30.0.0/24
+# ELEKTRINE_AUTO_CONFIGURE_DOCKER_SOURCE_IPS=true
+# CADDY_TRUSTED_PROXY_CIDRS=203.0.113.10/32
+# CADDY_PROXY_PROTOCOL_ALLOWED_CIDRS=203.0.113.10/32
+# EXTRA_CHECK_ORIGINS=http://203.0.113.10
+# ATOMINE_DM_CREDIT_GATE_ENABLED=false
+# ATOMINE_EMAIL_CREDIT_GATE_ENABLED=false
+
+# Require NetBird for login/admin surfaces and restrict admin. at Caddy.
+# NETBIRD_ENABLED=true
+# Set this to your exact NetBird peer IP(s) or private network CIDR(s), not all CGNAT.
+# NETBIRD_ALLOWED_CIDRS=100.64.12.34/32
+# CADDY_ADMIN_HOST=admin.example.com
+
+# -----------------------------------------------------------------------------
+# Database / secrets
+# -----------------------------------------------------------------------------
+
+# DB_PASSWORD=
+
+# For the default Docker stack, `DB_PASSWORD` is enough. Set `DATABASE_URL`
+# only when you use an external Postgres instance or non-Docker deploy.
+# DATABASE_URL=ecto://elektrine:@postgres/elektrine_prod
+# TLS is enabled by default for external DATABASE_URL connections. Only set this
+# false for an internal Docker/private-network database that does not support TLS.
+# DATABASE_SSL_ENABLED=false
+
+# Minimal root secret for self-hosting. Elektrine derives internal API keys,
+# salts, and other internal-only secrets from this automatically.
+# ELEKTRINE_MASTER_SECRET=
+
+# Optional explicit override. If omitted, Elektrine derives it from
+# ELEKTRINE_MASTER_SECRET.
+# SECRET_KEY_BASE=
+
+# -----------------------------------------------------------------------------
+# Modules / deploy profiles
+# -----------------------------------------------------------------------------
+
+ELEKTRINE_ENABLED_MODULES=all
+# Advanced build override only. Usually leave this unset.
+# ELEKTRINE_RELEASE_MODULES=chat,social,nerve,atomine
+DOCKER_PROFILES=caddy dns email tor turn bluesky vpn
+
+# After each successful docker_deploy up, prune old app image tags on the host
+# so deploys do not fill the disk. Keep the N newest tags of the app image.
+# ELEKTRINE_IMAGE_KEEP_COUNT=3
+# ELEKTRINE_IMAGE_REPO=ghcr.io/atomine-elektrine/elektrine
+# ELEKTRINE_PRUNE_DANGLING_IMAGES=true
+# ELEKTRINE_PRUNE_BUILD_CACHE=true
+# ELEKTRINE_SKIP_IMAGE_PRUNE=false
+
+# -----------------------------------------------------------------------------
+# Database pool tuning
+# -----------------------------------------------------------------------------
+
+# Advanced DB tuning only.
+# POOL_SIZE=10
+# APP_POOL_SIZE=10
+# WORKER_POOL_SIZE=5
+# MAIL_POOL_SIZE=5
+# DNS_POOL_SIZE=3
+# VPN_POOL_SIZE=2
+# MIGRATION_POOL_SIZE=2
+# DB_QUEUE_TARGET_MS=2000
+# DB_QUEUE_INTERVAL_MS=5000
+# DB_TIMEOUT_MS=30000
+# DB_POOL_TIMEOUT_MS=15000
+# DB_CONNECT_TIMEOUT_MS=15000
+# POSTGRES_EXTENSIONS=vector
+
+# -----------------------------------------------------------------------------
+# Federation / remote enrichment
+# -----------------------------------------------------------------------------
+
+# Advanced federation/remoting only.
+# MESSAGING_FEDERATION_ENABLED=true
+# MESSAGING_FEDERATION_IDENTITY_KEY_ID=default
+# MESSAGING_FEDERATION_OFFICIAL_RELAY_OPERATOR=Community-operated
+# MESSAGING_FEDERATION_OFFICIAL_RELAYS_JSON='[]'
+# MESSAGING_FEDERATION_PEERS_JSON='[]'
+
+# Optional Paige search providers.
+# PAIGE_BRAVE_API_KEY=
+# PAIGE_GITHUB_TOKEN=
+# Comma-separated first-party HTML scrapers: wiby,duckduckgo (default: wiby).
+# PAIGE_SCRAPERS=wiby,duckduckgo
+
+# Paige's independent PostgreSQL-backed index. Seeds are comma-separated URLs;
+# same-host links are discovered up to PAIGE_INDEX_MAX_DEPTH.
+# PAIGE_INDEX_ENABLED=true
+# PAIGE_INDEX_SEEDS=https://example.com/,https://hexdocs.pm/elixir/
+# PAIGE_INDEX_MAX_DEPTH=2
+# PAIGE_INDEX_RECRAWL_SECONDS=604800
+# PAIGE_INDEX_BATCH_SIZE=100
+# OBAN_QUEUE_CRAWLER=2
+
+# Optional IFTAS CARIAD denylist sync. Set either a full URL or an API key.
+# IFTAS_BLOCKLIST_ENABLED=true
+# IFTAS_BLOCKLIST_THRESHOLD=66
+# IFTAS_BLOCKLIST_API_KEY=
+# IFTAS_BLOCKLIST_URL=
+
+# -----------------------------------------------------------------------------
+# Optional but recommended crypto secrets
+# -----------------------------------------------------------------------------
+
+# Optional explicit overrides. If omitted, Elektrine derives them from
+# ELEKTRINE_MASTER_SECRET.
+# ENCRYPTION_MASTER_SECRET=
+# ENCRYPTION_KEY_SALT=
+# ENCRYPTION_SEARCH_SALT=
+
+# -----------------------------------------------------------------------------
+# Email / Haraka integration
+# -----------------------------------------------------------------------------
+
+# Enable only if you actually use the `email` module/profile.
+# USE_LOCAL_EMAIL=false
+# EMAIL_AUTO_SUPPRESSION=true
+# HARAKA_ASYNC_INGEST=true
+# MAIL_SERVICE_HOST=mail.example.com
+# IMAP_HOST=mail.example.com
+# SMTP_HOST=mail.example.com
+
+# Optional overrides. If omitted, Elektrine derives these from your domain and
+# ELEKTRINE_MASTER_SECRET automatically.
+# HARAKA_BASE_URL=https://mail.example.com
+# CUSTOM_DOMAIN_MX_HOST=mail.example.com
+# INTERNAL_API_KEY=
+# HARAKA_HTTP_API_KEY=
+# PHOENIX_API_KEY=
+# HARAKA_INTERNAL_SIGNING_SECRET=
+# EMAIL_RECEIVER_WEBHOOK_SECRET=
+
+# CUSTOM_DOMAIN_MX_PRIORITY=10
+# CUSTOM_DOMAIN_SPF_INCLUDE=_spf.example.com
+# CUSTOM_DOMAIN_DKIM_SELECTOR=default
+# CUSTOM_DOMAIN_DKIM_SYNC_ENABLED=true
+# CUSTOM_DOMAIN_HARAKA_BASE_URL=https://mail.example.com
+# CUSTOM_DOMAIN_HARAKA_API_KEY=
+# CUSTOM_DOMAIN_HARAKA_TIMEOUT_MS=10000
+# CUSTOM_DOMAIN_HARAKA_DKIM_PATH=/api/v1/dkim/domains
+# CUSTOM_DOMAIN_DMARC_POLICY=quarantine
+# CUSTOM_DOMAIN_DMARC_RUA=mailto:dmarc@example.com
+# CUSTOM_DOMAIN_DMARC_ADKIM=s
+# CUSTOM_DOMAIN_DMARC_ASPF=s
+
+# -----------------------------------------------------------------------------
+# Edge / certificates / anti-abuse
+# -----------------------------------------------------------------------------
+
+ACME_EMAIL=admin@example.com
+# Minimal wildcard setup for large/dynamic username subdomains:
+# CADDY_MANAGED_SITE_1="example.com *.example.com"
+# Leave CADDY_MANAGED_SITE_2 unset unless you have a second disjoint site list.
+# CADDY_MANAGED_SITE_2="alt.example.net *.alt.example.net"
+# Deploy tooling auto-selects the wildcard-external Caddyfile when wildcard
+# sites and mounted cert paths are present. For raw `docker compose` usage,
+# set `CADDY_CONFIG_PATH` explicitly.
+# With the stock Caddyfile, keep these lists to explicit hosts only.
+# CADDY_MANAGED_SITE_1="example.com mail.example.com imap.example.com pop.example.com smtp.example.com"
+# CADDY_MANAGED_SITE_2="alt.example.net mail.alt.example.net imap.alt.example.net pop.alt.example.net smtp.alt.example.net"
+# CADDY_TLS_MOUNT_DIR=/opt/elektrine/certs
+# CADDY_MANAGED_SITE_1_CERT_PATH=/opt/elektrine/certs/example.com.fullchain.pem
+# CADDY_MANAGED_SITE_1_KEY_PATH=/opt/elektrine/certs/example.com.key.pem
+# CADDY_MANAGED_SITE_2_CERT_PATH=/opt/elektrine/certs/alt.example.net.fullchain.pem
+# CADDY_MANAGED_SITE_2_KEY_PATH=/opt/elektrine/certs/alt.example.net.key.pem
+# CADDY_ORIGIN_UPSTREAM=app_proxy:8080
+# MAIL_TLS_CERT_PATH=/opt/elektrine/certs/mail.crt
+# MAIL_TLS_KEY_PATH=/opt/elektrine/certs/mail.key
+# MAIL_TLS_MOUNT_DIR=/opt/elektrine/certs
+# IMAP_TLS_CERT_PATH=/opt/elektrine/certs/imap.crt
+# IMAP_TLS_KEY_PATH=/opt/elektrine/certs/imap.key
+# POP3_TLS_CERT_PATH=/opt/elektrine/certs/pop.crt
+# POP3_TLS_KEY_PATH=/opt/elektrine/certs/pop.key
+# TURN_HOST defaults to PRIMARY_DOMAIN when the `turn` profile is enabled.
+# TURN_PORT=3478
+# TURN_MIN_PORT=49160
+# TURN_MAX_PORT=49200
+# TURN_EXTERNAL_IP=
+# TURN_RELAY_IP=
+# TURN_SHARED_SECRET=
+# ATOMINE_POW_DIFFICULTY=20
+# ATOMINE_POW_SKIP_VERIFICATION=false
+# STRIPE_SECRET_KEY=
+# STRIPE_WEBHOOK_SECRET=
+# DNS_RECURSIVE_ENABLED=true
+
+# -----------------------------------------------------------------------------
+# VPN
+# -----------------------------------------------------------------------------
+
+# Single-server self-hosted WireGuard (recommended for most installs):
+# VPN_SELFHOST_ENDPOINT_HOST=vpn.example.com
+# VPN_SELFHOST_PUBLIC_IP=203.0.113.10
+# VPN_SELFHOST_PRIVATE_KEY=optional; auto-generated and persisted by the vpn container when omitted
+# VPN_SELFHOST_ADDRESS=10.8.0.1/24
+# VPN_SELFHOST_LISTEN_PORT=51820
+# VPN_SELFHOST_ENDPOINT_PORT=51820
+# VPN_SELFHOST_PUBLIC_KEY=optional override; normally derived automatically from the private key
+# VPN_SELFHOST_INTERNAL_IP_RANGE=10.8.0.0/24
+# VPN_SELFHOST_DNS_SERVERS=1.1.1.1, 1.0.0.1
+# VPN_SELFHOST_WG_INTERFACE=wg0
+
+# Multi-server fleet bootstrap:
+# VPN_FLEET_REGISTRATION_KEY=replace-with-base64-registration-key
+# Private supernet each node's /24 is carved from. The default holds 256 nodes;
+# widen it BEFORE nodes claim ranges, since changing it later strands existing
+# allocations.
+# VPN_WG_SUPERNET=10.8.0.0/16
+
+# -----------------------------------------------------------------------------
+# Onion / Tor
+# -----------------------------------------------------------------------------
+
+# ELEKTRINE_ENABLE_TOR=true
+# ONION_TLS_ENABLED=true
+# ONION_TLS_PORT=8443
+# ONION_TLS_CERTFILE=/data/certs/live/onion-cert.pem
+# ONION_TLS_KEYFILE=/data/certs/live/onion-key.pem
+# ONION_HOST=
+# ONION_HS_SECRET_KEY_B64=
+# ONION_HS_PUBLIC_KEY_B64=
+
+# -----------------------------------------------------------------------------
+# Bluesky
+# -----------------------------------------------------------------------------
+
+# BLUESKY_ENABLED=false
+# BLUESKY_INBOUND_ENABLED=false
+# BLUESKY_SERVICE_URL=https://bsky.social
+# BLUESKY_TIMEOUT_MS=12000
+# BLUESKY_MAX_CHARS=300
+# BLUESKY_INBOUND_LIMIT=50
+# BLUESKY_MANAGED_ENABLED=false
+# BLUESKY_MANAGED_SERVICE_URL=http://bluesky_pds:3000
+# BLUESKY_MANAGED_DOMAIN=bsky.example.com
+# BLUESKY_MANAGED_ADMIN_PASSWORD=
+# BLUESKY_PDS_PORT=3000
+# BLUESKY_PDS_JWT_SECRET=
+# BLUESKY_PDS_ROTATION_KEY_HEX=
+
+# -----------------------------------------------------------------------------
+# Optional object storage (Magpie / S3-compatible)
+# -----------------------------------------------------------------------------
+
+# Leave these unset to keep uploads on local disk.
+# S3_ACCESS_KEY_ID=
+# S3_SECRET_ACCESS_KEY=
+# Local Magpie examples use HTTP loopback. Use HTTPS for any remote object store.
+# S3_ENDPOINT=127.0.0.1:8090
+# S3_BUCKET_NAME=elektrine-uploads
+# S3_PUBLIC_URL=http://127.0.0.1:8090/elektrine-uploads
+# S3_SCHEME=http://
+# S3_PORT=8090
diff --git a/.forgejo/README.md b/.forgejo/README.md
new file mode 100644
index 0000000..2377e2d
--- /dev/null
+++ b/.forgejo/README.md
@@ -0,0 +1,41 @@
+# Forgejo Actions
+
+Workflows in this directory run on the self-hosted Forgejo instance
+(`https://git.elektrine.com`) via `forgejo-runner`.
+
+| Workflow | Purpose |
+|----------|---------|
+| `smoke.yml` | Minimal runner health check |
+| `ci.yml` | Quality gate (format, compile, credo, tests) |
+| `docker-deploy.yml` | Build image + SSH deploy (port of `.github/workflows/docker-deploy.yml`) |
+
+## Runner
+
+- Host: Forgejo droplet (`git.elektrine.com`)
+- Path: `/opt/forgejo-runner`
+- Labels: `ubuntu-latest`, `ubuntu-22.04`, `docker`
+
+## Repo secrets / variables
+
+Set under **Settings → Actions** on `elektrine/elektrine`:
+
+**Secrets**
+
+- `DEPLOY_SSH_KEY` — private key for production deploy (required)
+- `REGISTRY_USERNAME` / `REGISTRY_TOKEN` — optional Forgejo package credentials
+ for optional registry push and Haraka image pulls. Legacy aliases
+ `GHCR_USERNAME` / `GHCR_TOKEN` still work.
+
+ The main Elektrine app image is **built on the runner and transferred over
+ SSH** (`docker save` → `rsync` → `docker load`). Production deploy no longer
+ requires a registry pull of the app image, which avoids
+ `unauthorized: authRequired.Verify` on `git.elektrine.com`.
+
+**Variables**
+
+- `DEPLOY_HOST`, `DEPLOY_USER`, `DEPLOY_SSH_HOST_KEY`
+- Optional: `DEPLOY_PORT`, `DEPLOY_PATH`, `DEPLOY_BRANCH`
+- Optional: `REGISTRY` (default `git.elektrine.com`), `IMAGE_NAME`, `DOCKER_BUILD_*_DOMAIN`
+
+GitHub Actions under `.github/workflows/` remain for the GitHub remote until
+you cut over production deploys fully.
diff --git a/.forgejo/workflows/ci.yml b/.forgejo/workflows/ci.yml
new file mode 100644
index 0000000..c8e7f29
--- /dev/null
+++ b/.forgejo/workflows/ci.yml
@@ -0,0 +1,129 @@
+# Port of .github/workflows/ci.yml for Forgejo Actions.
+# Prefer data.forgejo.org action mirrors where available.
+name: Quality Checks
+
+on:
+ pull_request:
+ branches: [main]
+ workflow_call:
+ inputs:
+ allow_format_failure:
+ description: Allow formatting check to fail without failing the workflow
+ required: false
+ type: boolean
+ default: false
+ allow_test_failure:
+ description: Allow the test suite to fail without blocking deployment
+ required: false
+ type: boolean
+ default: true
+ workflow_dispatch:
+ inputs:
+ allow_format_failure:
+ description: Allow formatting check to fail without failing the workflow
+ required: false
+ type: boolean
+ default: false
+ allow_test_failure:
+ description: Allow the test suite to fail without blocking deployment
+ required: false
+ type: boolean
+ default: true
+
+env:
+ MIX_ENV: test
+ ELIXIR_VERSION: '1.19.5'
+ OTP_VERSION: '28.1.1'
+ DB_HOST: localhost
+ DB_NAME: elektrine_test
+ DB_USER: postgres
+ DB_PASSWORD: postgres
+
+jobs:
+ quality_checks:
+ name: Code Quality (Format + Compile)
+ runs-on: ubuntu-latest
+ services:
+ postgres:
+ image: postgres:16
+ env:
+ POSTGRES_DB: elektrine_test
+ POSTGRES_USER: postgres
+ POSTGRES_PASSWORD: postgres
+ ports:
+ - 5432:5432
+ options: >-
+ --health-cmd "pg_isready -U postgres -d elektrine_test"
+ --health-interval 10s
+ --health-timeout 5s
+ --health-retries 5
+
+ steps:
+ - uses: https://data.forgejo.org/actions/checkout@v4
+
+ - name: Set up Elixir
+ uses: https://github.com/erlef/setup-beam@v1
+ with:
+ elixir-version: ${{ env.ELIXIR_VERSION }}
+ otp-version: ${{ env.OTP_VERSION }}
+
+ - name: Restore deps cache
+ uses: https://data.forgejo.org/actions/cache@v4
+ with:
+ path: |
+ deps
+ _build
+ key: ${{ runner.os }}-mix-${{ env.ELIXIR_VERSION }}-${{ env.OTP_VERSION }}-${{ hashFiles('**/mix.lock') }}
+ restore-keys: |
+ ${{ runner.os }}-mix-${{ env.ELIXIR_VERSION }}-${{ env.OTP_VERSION }}-
+
+ - name: Install dependencies
+ run: mix deps.get
+
+ - name: Install JavaScript dependencies
+ run: npm --prefix apps/elektrine/assets ci
+
+ - name: Build assets
+ run: cd apps/elektrine && mix assets.setup && mix assets.build
+
+ - name: Verify formatting
+ id: format_check
+ continue-on-error: ${{ inputs.allow_format_failure }}
+ run: mix format --check-formatted
+
+ - name: Check generated artifacts are not tracked
+ run: scripts/check_tracked_generated_artifacts.sh
+
+ - name: Check JavaScript assets
+ run: npm --prefix apps/elektrine/assets run check
+
+ - name: Run JavaScript dependency audit
+ run: npm --prefix apps/elektrine/assets audit --omit=dev
+
+ - name: Emit warning when formatting is allowed to fail
+ if: ${{ inputs.allow_format_failure && steps.format_check.outcome == 'failure' }}
+ run: echo "::warning::Formatting check failed, but deploy can continue."
+
+ - name: Compile with warnings treated as errors
+ run: mix compile --warnings-as-errors
+
+ - name: Run Credo
+ run: mix credo --strict
+
+ - name: Run tests
+ id: test_check
+ continue-on-error: ${{ inputs.allow_test_failure != false }}
+ run: mix test
+
+ - name: Emit warning when tests are allowed to fail
+ if: ${{ inputs.allow_test_failure != false && steps.test_check.outcome == 'failure' }}
+ run: echo "::warning::Test suite failed, but deploy can continue."
+
+ - name: Audit Nerve extension dependencies
+ run: npm --prefix clients/nerve-extension audit --omit=dev
+
+ - name: Run dependency audit
+ run: mix deps.audit
+
+ - name: Run Hex package retirement audit
+ run: mix hex.audit
diff --git a/.forgejo/workflows/docker-deploy.yml b/.forgejo/workflows/docker-deploy.yml
new file mode 100644
index 0000000..e8f957d
--- /dev/null
+++ b/.forgejo/workflows/docker-deploy.yml
@@ -0,0 +1,373 @@
+# Production deploy for self-hosted Forgejo Actions (no setup-beam / node24 actions).
+#
+# The app image is built on the runner and transferred over SSH (docker save/load),
+# so production does not need to pull from the Forgejo package registry.
+#
+# Secrets: DEPLOY_SSH_KEY
+# Optional secrets (registry push + Haraka pulls only):
+# REGISTRY_USERNAME + REGISTRY_TOKEN (or legacy GHCR_USERNAME / GHCR_TOKEN)
+# Variables: DEPLOY_HOST, DEPLOY_USER, DEPLOY_SSH_HOST_KEY, REGISTRY, IMAGE_NAME, DOCKER_BUILD_*
+name: Deploy Docker Images
+
+on:
+ push:
+ branches:
+ - main
+ workflow_dispatch:
+ inputs:
+ repair_indexes:
+ description: Rebuild all PostgreSQL indexes during deploy
+ required: false
+ default: false
+ type: boolean
+
+env:
+ REGISTRY: ${{ vars.REGISTRY || 'git.elektrine.com' }}
+ IMAGE_NAME: ${{ vars.IMAGE_NAME || 'elektrine/elektrine' }}
+
+jobs:
+ build_and_deploy:
+ name: Build, push, and deploy
+ runs-on: ubuntu-latest
+ env:
+ BUILD_PRIMARY_DOMAIN: ${{ vars.DOCKER_BUILD_PRIMARY_DOMAIN || 'elektrine.com' }}
+ BUILD_EMAIL_DOMAIN: ${{ vars.DOCKER_BUILD_EMAIL_DOMAIN || 'elektrine.com' }}
+ BUILD_SUPPORTED_DOMAINS: ${{ vars.DOCKER_BUILD_SUPPORTED_DOMAINS || 'elektrine.com' }}
+ BUILD_PROFILE_BASE_DOMAINS: ${{ vars.DOCKER_BUILD_PROFILE_BASE_DOMAINS || 'elektrine.com' }}
+ ELEKTRINE_ENABLED_MODULES: all
+ ELEKTRINE_RELEASE_MODULES: all
+ DEPLOY_HOST: ${{ vars.DEPLOY_HOST }}
+ DEPLOY_USER: ${{ vars.DEPLOY_USER }}
+ DEPLOY_PORT: ${{ vars.DEPLOY_PORT || '22' }}
+ DEPLOY_PATH: ${{ vars.DEPLOY_PATH || '/opt/elektrine' }}
+ DEPLOY_BRANCH: ${{ vars.DEPLOY_BRANCH || 'main' }}
+ DEPLOY_SSH_HOST_KEY: ${{ vars.DEPLOY_SSH_HOST_KEY }}
+ DEPLOY_SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
+ DOCKER_PROFILES: caddy dns email tor turn bluesky vpn
+ DEPLOY_COMPOSE_OVERRIDES: ${{ vars.DEPLOY_COMPOSE_OVERRIDES || '' }}
+ REPAIR_INDEXES_ON_DEPLOY: ${{ github.event.inputs.repair_indexes || vars.REPAIR_INDEXES_ON_DEPLOY || 'false' }}
+ # Optional registry credentials (push/Haraka only).
+ REG_USER: ${{ secrets.REGISTRY_USERNAME || secrets.GHCR_USERNAME }}
+ REG_PASS: ${{ secrets.REGISTRY_TOKEN || secrets.GHCR_TOKEN }}
+ concurrency:
+ group: deploy-production
+ cancel-in-progress: false
+
+ steps:
+ - uses: https://data.forgejo.org/actions/checkout@v4
+
+ - name: Build image
+ id: build
+ run: |
+ set -euo pipefail
+ image="${REGISTRY}/${IMAGE_NAME}"
+ tag_sha="${image}:${GITHUB_SHA}"
+ tag_latest="${image}:latest"
+ docker build \
+ -f deploy/docker/Dockerfile \
+ --build-arg RELEASE_NAME=elektrine \
+ --build-arg ELEKTRINE_RELEASE_MODULES="${ELEKTRINE_RELEASE_MODULES}" \
+ --build-arg PRIMARY_DOMAIN="${BUILD_PRIMARY_DOMAIN}" \
+ --build-arg EMAIL_DOMAIN="${BUILD_EMAIL_DOMAIN}" \
+ --build-arg SUPPORTED_DOMAINS="${BUILD_SUPPORTED_DOMAINS}" \
+ --build-arg PROFILE_BASE_DOMAINS="${BUILD_PROFILE_BASE_DOMAINS}" \
+ -t "$tag_sha" \
+ -t "$tag_latest" \
+ .
+ echo "target_image=${tag_sha}" >> "$GITHUB_OUTPUT"
+ echo "Built ${tag_sha}"
+
+ - name: Optional registry push
+ run: |
+ set -euo pipefail
+ if [ -z "${REG_USER:-}" ] || [ -z "${REG_PASS:-}" ]; then
+ echo "No registry credentials; skipping push (image will ship over SSH)."
+ exit 0
+ fi
+ REG_USER="$(printf '%s' "$REG_USER" | tr -d '\r\n' | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')"
+ REG_PASS="$(printf '%s' "$REG_PASS" | tr -d '\r\n' | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')"
+ if ! printf '%s\n' "$REG_PASS" | docker login "$REGISTRY" -u "$REG_USER" --password-stdin; then
+ echo "::warning::Registry login failed; continuing with SSH image transfer only."
+ exit 0
+ fi
+ image="${REGISTRY}/${IMAGE_NAME}"
+ if ! docker push "${image}:${GITHUB_SHA}"; then
+ echo "::warning::docker push ${image}:${GITHUB_SHA} failed; continuing with SSH transfer."
+ exit 0
+ fi
+ docker push "${image}:latest" || echo "::warning::docker push latest failed"
+
+ - name: Package image for SSH transfer
+ env:
+ TARGET_IMAGE: ${{ steps.build.outputs.target_image }}
+ run: |
+ set -euo pipefail
+ mkdir -p /tmp
+ docker save "$TARGET_IMAGE" | gzip -1 > /tmp/elektrine-image.tar.gz
+ ls -lh /tmp/elektrine-image.tar.gz
+
+ - name: Validate deploy settings
+ run: |
+ set -euo pipefail
+ missing=()
+ [ -n "${DEPLOY_HOST:-}" ] || missing+=(DEPLOY_HOST)
+ [ -n "${DEPLOY_USER:-}" ] || missing+=(DEPLOY_USER)
+ [ -n "${DEPLOY_SSH_HOST_KEY:-}" ] || missing+=(DEPLOY_SSH_HOST_KEY)
+ [ -n "${DEPLOY_SSH_KEY:-}" ] || missing+=(DEPLOY_SSH_KEY)
+ if [ "${#missing[@]}" -gt 0 ]; then
+ echo "::error::Missing deploy settings: ${missing[*]}"
+ exit 1
+ fi
+
+ - name: Install SSH key
+ run: |
+ set -euo pipefail
+ install -m 700 -d ~/.ssh
+ # Secret may arrive with literal \n — normalize to real newlines
+ printf '%s\n' "$DEPLOY_SSH_KEY" | sed 's/\r$//' | sed 's/\\n/\n/g' > ~/.ssh/id_ed25519
+ chmod 600 ~/.ssh/id_ed25519
+ : > ~/.ssh/known_hosts
+ if [ -n "${DEPLOY_SSH_HOST_KEY:-}" ]; then
+ printf '%s\n' "$DEPLOY_SSH_HOST_KEY" | sed 's/\r$//' | sed 's/\\n/\n/g' >> ~/.ssh/known_hosts
+ fi
+ # Always pin live host keys so BatchMode never prompts
+ ssh-keyscan -p "${DEPLOY_PORT:-22}" -T 5 -t ed25519,rsa \
+ "${DEPLOY_HOST}" 2>/dev/null >> ~/.ssh/known_hosts || true
+ if [ -n "${DEPLOY_HOST:-}" ]; then
+ ip=$(getent ahostsv4 "$DEPLOY_HOST" 2>/dev/null | awk '{print $1; exit}' || true)
+ if [ -n "$ip" ]; then
+ ssh-keyscan -p "${DEPLOY_PORT:-22}" -T 5 -t ed25519,rsa "$ip" 2>/dev/null >> ~/.ssh/known_hosts || true
+ fi
+ fi
+ chmod 600 ~/.ssh/known_hosts
+ echo "known_hosts:"; cat ~/.ssh/known_hosts
+ ssh-keygen -y -f ~/.ssh/id_ed25519 >/dev/null
+
+ - name: Upload image archive
+ run: |
+ set -euo pipefail
+ RSYNC_RSH="ssh -p ${DEPLOY_PORT} -i ${HOME}/.ssh/id_ed25519 -o BatchMode=yes -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes -o UserKnownHostsFile=${HOME}/.ssh/known_hosts"
+ ssh -p "$DEPLOY_PORT" \
+ -i "$HOME/.ssh/id_ed25519" \
+ -o BatchMode=yes \
+ -o IdentitiesOnly=yes \
+ -o StrictHostKeyChecking=yes \
+ -o UserKnownHostsFile="$HOME/.ssh/known_hosts" \
+ "$DEPLOY_USER@$DEPLOY_HOST" \
+ "mkdir -p '${DEPLOY_PATH}/.deploy' && chmod 700 '${DEPLOY_PATH}/.deploy'"
+ rsync -az -e "$RSYNC_RSH" \
+ /tmp/elektrine-image.tar.gz \
+ "$DEPLOY_USER@$DEPLOY_HOST:${DEPLOY_PATH}/.deploy/elektrine-image.tar.gz"
+
+ - name: Deploy over SSH
+ env:
+ TARGET_IMAGE: ${{ steps.build.outputs.target_image }}
+ DEPLOY_REF: ${{ github.sha }}
+ # Haraka image rolled by Elektrine deploy (sibling stack on same host).
+ HARAKA_IMAGE: ${{ vars.HARAKA_IMAGE || 'git.elektrine.com/elektrine/elektrine-haraka' }}
+ HARAKA_IMAGE_TAG: ${{ vars.HARAKA_IMAGE_TAG || 'latest' }}
+ run: |
+ set -euo pipefail
+
+ # Optional registry creds for Haraka pulls only (main app image is loaded from archive).
+ if [ -n "${REG_USER:-}" ] && [ -n "${REG_PASS:-}" ]; then
+ REG_USER="$(printf '%s' "$REG_USER" | tr -d '\r\n' | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')"
+ REG_PASS="$(printf '%s' "$REG_PASS" | tr -d '\r\n' | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')"
+ else
+ REG_USER=""
+ REG_PASS=""
+ fi
+
+ remote_env="$(mktemp)"
+ chmod 600 "$remote_env"
+ {
+ printf 'export DEPLOY_PATH=%q\n' "$DEPLOY_PATH"
+ printf 'export DEPLOY_REF=%q\n' "$DEPLOY_REF"
+ printf 'export DEPLOY_BRANCH=%q\n' "$DEPLOY_BRANCH"
+ printf 'export TARGET_IMAGE=%q\n' "$TARGET_IMAGE"
+ printf 'export ELEKTRINE_ENABLED_MODULES=%q\n' "$ELEKTRINE_ENABLED_MODULES"
+ printf 'export ELEKTRINE_RELEASE_MODULES=%q\n' "$ELEKTRINE_RELEASE_MODULES"
+ printf 'export DOCKER_PROFILES=%q\n' "$DOCKER_PROFILES"
+ printf 'export DEPLOY_COMPOSE_OVERRIDES=%q\n' "$DEPLOY_COMPOSE_OVERRIDES"
+ printf 'export REPAIR_INDEXES_ON_DEPLOY=%q\n' "$REPAIR_INDEXES_ON_DEPLOY"
+ printf 'export REG_USER=%q\n' "$REG_USER"
+ printf 'export REG_PASS=%q\n' "$REG_PASS"
+ printf 'export HARAKA_IMAGE=%q\n' "$HARAKA_IMAGE"
+ printf 'export HARAKA_IMAGE_TAG=%q\n' "$HARAKA_IMAGE_TAG"
+ printf 'export REGISTRY=%q\n' "${REGISTRY:-git.elektrine.com}"
+ printf 'export IMAGE_ARCHIVE=%q\n' "${DEPLOY_PATH}/.deploy/elektrine-image.tar.gz"
+ } >"$remote_env"
+
+ remote_env_path="/tmp/elektrine-deploy-env.$$"
+ scp -P "$DEPLOY_PORT" \
+ -i "$HOME/.ssh/id_ed25519" \
+ -o BatchMode=yes \
+ -o IdentitiesOnly=yes \
+ -o StrictHostKeyChecking=yes \
+ -o UserKnownHostsFile="$HOME/.ssh/known_hosts" \
+ "$remote_env" \
+ "$DEPLOY_USER@$DEPLOY_HOST:$remote_env_path"
+ rm -f "$remote_env"
+
+ ssh -p "$DEPLOY_PORT" \
+ -i "$HOME/.ssh/id_ed25519" \
+ -o BatchMode=yes \
+ -o IdentitiesOnly=yes \
+ -o StrictHostKeyChecking=yes \
+ -o UserKnownHostsFile="$HOME/.ssh/known_hosts" \
+ "$DEPLOY_USER@$DEPLOY_HOST" \
+ "bash -s -- $remote_env_path" <<'EOF'
+ set -euo pipefail
+
+ ENV_FILE="${1:?missing remote env file}"
+ # shellcheck disable=SC1090
+ source "$ENV_FILE"
+ rm -f "$ENV_FILE"
+
+ export REGISTRY_USER="${REG_USER:-}"
+ export REGISTRY_TOKEN="${REG_PASS:-}"
+ export REGISTRY="${REGISTRY:-git.elektrine.com}"
+ export HARAKA_IMAGE="${HARAKA_IMAGE:-git.elektrine.com/elektrine/elektrine-haraka}"
+ export HARAKA_IMAGE_TAG="${HARAKA_IMAGE_TAG:-latest}"
+ # Prefer local image (loaded from archive); never force a registry pull of ELEKTRINE_IMAGE.
+ export ELEKTRINE_PULL_POLICY=never
+
+ if docker info >/dev/null 2>&1; then
+ DOCKER=(docker)
+ elif sudo -n docker info >/dev/null 2>&1; then
+ DOCKER=(sudo -n docker)
+ else
+ echo "No Docker access for deploy user" >&2
+ exit 1
+ fi
+
+ IMAGE_ARCHIVE="${IMAGE_ARCHIVE:?missing IMAGE_ARCHIVE}"
+ test -s "$IMAGE_ARCHIVE"
+
+ echo "Loading $TARGET_IMAGE from $IMAGE_ARCHIVE"
+ gzip -dc "$IMAGE_ARCHIVE" | "${DOCKER[@]}" load
+ "${DOCKER[@]}" image inspect "$TARGET_IMAGE" >/dev/null
+ rm -f "$IMAGE_ARCHIVE"
+
+ # Optional login only helps Haraka (and other private pulls). Failures are non-fatal.
+ if [ -n "${REG_USER:-}" ] && [ -n "${REG_PASS:-}" ]; then
+ echo "Logging into $REGISTRY as $REG_USER (optional, for Haraka)"
+ if [ "${#DOCKER[@]}" -gt 1 ]; then
+ "${DOCKER[@]}" login "$REGISTRY" -u "$REG_USER" --password-stdin <<<"$REG_PASS" \
+ || echo "Warn: registry login failed; Haraka pull may be skipped" >&2
+ else
+ printf '%s\n' "$REG_PASS" | docker login "$REGISTRY" -u "$REG_USER" --password-stdin \
+ || echo "Warn: registry login failed; Haraka pull may be skipped" >&2
+ fi
+ fi
+
+ if [ ! -f "$DEPLOY_PATH/.env.production" ]; then
+ echo "Remote env file missing: $DEPLOY_PATH/.env.production" >&2
+ exit 1
+ fi
+
+ cd "$DEPLOY_PATH"
+ git fetch --all --tags || true
+
+ if [ -z "$(git status --porcelain)" ]; then
+ current_branch="$(git branch --show-current || true)"
+ if [ "$current_branch" != "$DEPLOY_BRANCH" ]; then
+ git checkout "$DEPLOY_BRANCH" || true
+ fi
+ git merge --ff-only "origin/$DEPLOY_BRANCH" || true
+ else
+ echo "Skipping checkout sync for $DEPLOY_PATH: local changes present"
+ fi
+
+ if git rev-parse --verify "${DEPLOY_REF}^{commit}" >/dev/null 2>&1; then
+ REF="$DEPLOY_REF"
+ else
+ REF="origin/${DEPLOY_BRANCH}"
+ fi
+
+ WORKTREE_PATH="$DEPLOY_PATH/.deploy-worktree.$(date +%s).$$"
+ OUTPUT_FILE="$DEPLOY_PATH/deploy/generated/generated.docker.yml"
+ CADDY_OUTPUT_FILE="$DEPLOY_PATH/deploy/generated/generated.Caddyfile"
+ trap 'git -C "$DEPLOY_PATH" worktree remove --force "$WORKTREE_PATH" >/dev/null 2>&1 || rm -rf "$WORKTREE_PATH"' EXIT
+
+ git worktree add --force --detach "$WORKTREE_PATH" "$REF"
+ WORKTREE_ENV_FILE="$WORKTREE_PATH/.env.production"
+ if [ -r "$DEPLOY_PATH/.env.production" ]; then
+ ln -sfn "$DEPLOY_PATH/.env.production" "$WORKTREE_ENV_FILE"
+ else
+ umask 077
+ sudo -n cat "$DEPLOY_PATH/.env.production" > "$WORKTREE_ENV_FILE"
+ fi
+ cd "$WORKTREE_PATH"
+
+ EXTRA_OVERRIDE_ARGS=()
+ for override_file in $DEPLOY_COMPOSE_OVERRIDES; do
+ [ -z "$override_file" ] && continue
+ case "$override_file" in
+ /*) resolved_override="$override_file" ;;
+ *) resolved_override="$WORKTREE_PATH/$override_file" ;;
+ esac
+ if [ -f "$resolved_override" ]; then
+ EXTRA_OVERRIDE_ARGS+=(--compose-override "$resolved_override")
+ fi
+ done
+
+ PROFILE_ARGS=()
+ for profile in $DOCKER_PROFILES; do
+ PROFILE_ARGS+=(--profile "$profile")
+ done
+
+ REPAIR_INDEX_ARGS=()
+ case "$REPAIR_INDEXES_ON_DEPLOY" in
+ 1|true|TRUE|yes|YES) REPAIR_INDEX_ARGS+=(--repair-indexes) ;;
+ esac
+
+ # Image already loaded locally — do not --pull (that re-hits the registry).
+ ELEKTRINE_IMAGE="$TARGET_IMAGE" \
+ COMPOSE_PROJECT_NAME="docker" \
+ COMPOSE_PROJECT_DIRECTORY="$DEPLOY_PATH/deploy/docker" \
+ CADDY_RENDERED_CONFIG_PATH="$CADDY_OUTPUT_FILE" \
+ bash scripts/deploy/docker_deploy.sh \
+ --modules "$ELEKTRINE_ENABLED_MODULES" \
+ --env-file "$WORKTREE_ENV_FILE" \
+ --output "$OUTPUT_FILE" \
+ --skip-build \
+ "${REPAIR_INDEX_ARGS[@]}" \
+ "${EXTRA_OVERRIDE_ARGS[@]}" \
+ "${PROFILE_ARGS[@]}"
+
+ expected_image="$TARGET_IMAGE"
+ expected_containers=(elektrine_app elektrine_worker)
+ case " $DOCKER_PROFILES " in *" email "*) expected_containers+=(elektrine_mail) ;; esac
+ case " $DOCKER_PROFILES " in *" dns "*) expected_containers+=(elektrine_dns) ;; esac
+ case " $DOCKER_PROFILES " in *" vpn "*) expected_containers+=(elektrine_vpn) ;; esac
+
+ for container in "${expected_containers[@]}"; do
+ if ! "${DOCKER[@]}" inspect "$container" >/dev/null 2>&1; then
+ echo "Expected container $container was not created" >&2
+ exit 1
+ fi
+ actual_image="$("${DOCKER[@]}" inspect "$container" --format '{{.Config.Image}}')"
+ if [ "$actual_image" != "$expected_image" ]; then
+ echo "Container $container is running $actual_image, expected $expected_image" >&2
+ exit 1
+ fi
+ done
+ EOF
+
+ - name: Health check
+ run: |
+ set -euo pipefail
+ ssh -p "$DEPLOY_PORT" \
+ -i "$HOME/.ssh/id_ed25519" \
+ -o BatchMode=yes \
+ -o IdentitiesOnly=yes \
+ -o StrictHostKeyChecking=yes \
+ -o UserKnownHostsFile="$HOME/.ssh/known_hosts" \
+ "$DEPLOY_USER@$DEPLOY_HOST" "bash -s" </dev/null 2>&1; then DOCKER_CMD="docker"; else DOCKER_CMD="sudo -n docker"; fi
+ \$DOCKER_CMD ps --format 'table {{.Names}}\t{{.Status}}\t{{.Image}}' | head -40
+ echo "--- haraka ---"
+ \$DOCKER_CMD ps --format 'table {{.Names}}\t{{.Status}}\t{{.Image}}' | grep -i haraka || true
+ EOF
diff --git a/.forgejo/workflows/smoke.yml b/.forgejo/workflows/smoke.yml
new file mode 100644
index 0000000..8e7d35a
--- /dev/null
+++ b/.forgejo/workflows/smoke.yml
@@ -0,0 +1,21 @@
+# Minimal job to verify the Forgejo runner is online.
+name: Smoke
+
+on:
+ push:
+ branches: [main]
+ paths:
+ - '.forgejo/workflows/smoke.yml'
+ workflow_dispatch:
+
+jobs:
+ hello:
+ name: Hello from Forgejo Actions
+ runs-on: ubuntu-latest
+ steps:
+ - name: Runner info
+ run: |
+ echo "Hello from Forgejo Actions"
+ uname -a
+ id
+ df -h .
diff --git a/.formatter.exs b/.formatter.exs
new file mode 100644
index 0000000..4427620
--- /dev/null
+++ b/.formatter.exs
@@ -0,0 +1,12 @@
+[
+ import_deps: [:ecto, :ecto_sql, :phoenix],
+ subdirectories: ["apps/*/priv/*/migrations"],
+ plugins: [Phoenix.LiveView.HTMLFormatter],
+ inputs: [
+ "*.{heex,ex,exs}",
+ "config/**/*.{heex,ex,exs}",
+ "apps/*/mix.exs",
+ "apps/*/{lib,test}/**/*.{heex,ex,exs}",
+ "apps/*/priv/*/seeds.exs"
+ ]
+]
diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml
new file mode 100644
index 0000000..4edb2fd
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/bug_report.yml
@@ -0,0 +1,61 @@
+name: Bug report
+description: Report a reproducible defect in any umbrella app
+title: "[Bug]: "
+labels: ["bug"]
+body:
+ - type: markdown
+ attributes:
+ value: |
+ Thanks for reporting. Please include enough detail to reproduce.
+
+ - type: dropdown
+ id: app
+ attributes:
+ label: Affected app
+ options:
+ - apps/elektrine
+ - apps/elektrine_web
+ - apps/elektrine_email
+ - apps/elektrine_social
+ - apps/elektrine_vpn
+ - apps/elektrine_nerve
+ - multiple apps
+ validations:
+ required: true
+
+ - type: textarea
+ id: steps
+ attributes:
+ label: Steps to reproduce
+ placeholder: |
+ 1. ...
+ 2. ...
+ 3. ...
+ validations:
+ required: true
+
+ - type: textarea
+ id: expected
+ attributes:
+ label: Expected behavior
+ validations:
+ required: true
+
+ - type: textarea
+ id: actual
+ attributes:
+ label: Actual behavior
+ validations:
+ required: true
+
+ - type: textarea
+ id: logs
+ attributes:
+ label: Logs / stack traces
+ render: shell
+
+ - type: input
+ id: version
+ attributes:
+ label: Commit SHA or branch
+ placeholder: e.g. main @ abc1234
diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml
new file mode 100644
index 0000000..373e7b0
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/config.yml
@@ -0,0 +1,5 @@
+blank_issues_enabled: false
+contact_links:
+ - name: Security report
+ url: mailto:security@elektrine.com
+ about: Please do not report vulnerabilities in public issues.
diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml
new file mode 100644
index 0000000..59671e7
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/feature_request.yml
@@ -0,0 +1,45 @@
+name: Feature request
+description: Propose an enhancement for one or more umbrella apps
+title: "[Feature]: "
+labels: ["enhancement"]
+body:
+ - type: dropdown
+ id: app
+ attributes:
+ label: Target app
+ options:
+ - apps/elektrine
+ - apps/elektrine_web
+ - apps/elektrine_email
+ - apps/elektrine_social
+ - apps/elektrine_vpn
+ - apps/elektrine_nerve
+ - multiple apps
+ validations:
+ required: true
+
+ - type: textarea
+ id: problem
+ attributes:
+ label: Problem statement
+ description: What pain point are you trying to solve?
+ validations:
+ required: true
+
+ - type: textarea
+ id: proposal
+ attributes:
+ label: Proposed solution
+ validations:
+ required: true
+
+ - type: textarea
+ id: alternatives
+ attributes:
+ label: Alternatives considered
+
+ - type: textarea
+ id: impact
+ attributes:
+ label: Expected impact
+ description: Performance, security, UX, maintenance, migration notes.
diff --git a/.github/dependabot.yml b/.github/dependabot.yml
new file mode 100644
index 0000000..0186bc8
--- /dev/null
+++ b/.github/dependabot.yml
@@ -0,0 +1,36 @@
+version: 2
+updates:
+ # Elixir dependencies
+ - package-ecosystem: "mix"
+ directory: "/"
+ schedule:
+ interval: "weekly"
+ open-pull-requests-limit: 5
+ groups:
+ elixir-minor:
+ patterns:
+ - "*"
+ update-types:
+ - "minor"
+ - "patch"
+
+ # GitHub Actions
+ - package-ecosystem: "github-actions"
+ directory: "/"
+ schedule:
+ interval: "weekly"
+ open-pull-requests-limit: 3
+
+ # npm (if you have assets)
+ - package-ecosystem: "npm"
+ directory: "/apps/elektrine/assets"
+ schedule:
+ interval: "weekly"
+ open-pull-requests-limit: 5
+ groups:
+ npm-minor:
+ patterns:
+ - "*"
+ update-types:
+ - "minor"
+ - "patch"
diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md
new file mode 100644
index 0000000..705e66b
--- /dev/null
+++ b/.github/pull_request_template.md
@@ -0,0 +1,35 @@
+## Summary
+
+Describe what changed and why.
+
+## Scope
+
+- [ ] `apps/elektrine`
+- [ ] `apps/elektrine_web`
+- [ ] `apps/arblarg`
+- [ ] `apps/elektrine_email`
+- [ ] `apps/elektrine_social`
+- [ ] `apps/elektrine_vpn`
+- [ ] `apps/elektrine_nerve`
+- [ ] `apps/elektrine_dns`
+- [ ] Other (describe below)
+
+## Validation
+
+- [ ] `mix check`
+- [ ] Added or updated tests for behavior changes
+
+## Breaking Changes
+
+- [ ] None
+- [ ] Yes (describe migration or rollout steps)
+
+## Security / Privacy
+
+- [ ] No sensitive data added to repo
+- [ ] Secrets are still environment-driven
+- [ ] New external dependencies reviewed
+
+## Notes for Reviewers
+
+Any context that helps review quickly.
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..ab4b1f2
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,119 @@
+name: Quality Checks
+
+on:
+ pull_request:
+ branches: [main]
+ workflow_call:
+ inputs:
+ allow_format_failure:
+ description: Allow formatting check to fail without failing the workflow
+ required: false
+ type: boolean
+ default: false
+ allow_test_failure:
+ description: Allow the test suite to fail without blocking deployment
+ required: false
+ type: boolean
+ default: true
+env:
+ MIX_ENV: test
+ ELIXIR_VERSION: '1.19.5'
+ OTP_VERSION: '28.1.1'
+ DB_HOST: localhost
+ DB_NAME: elektrine_test
+ DB_USER: postgres
+ DB_PASSWORD: postgres
+
+jobs:
+ quality_checks:
+ name: Code Quality (Format + Compile)
+ runs-on: ubuntu-latest
+ services:
+ postgres:
+ image: postgres:16
+ env:
+ POSTGRES_DB: elektrine_test
+ POSTGRES_USER: postgres
+ POSTGRES_PASSWORD: postgres
+ ports:
+ - 5432:5432
+ options: >-
+ --health-cmd "pg_isready -U postgres -d elektrine_test"
+ --health-interval 10s
+ --health-timeout 5s
+ --health-retries 5
+
+ steps:
+ - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
+
+ - name: Set up Elixir
+ uses: erlef/setup-beam@54075bcc5e249e4758d363f27d099f55d843f124 # v1.24.1
+ with:
+ elixir-version: ${{ env.ELIXIR_VERSION }}
+ otp-version: ${{ env.OTP_VERSION }}
+
+ - name: Restore deps cache
+ uses: actions/cache@caa296126883cff596d87d8935842f9db880ef25 # v5
+ with:
+ path: |
+ deps
+ _build
+ key: ${{ runner.os }}-mix-${{ env.ELIXIR_VERSION }}-${{ env.OTP_VERSION }}-${{ hashFiles('**/mix.lock') }}
+ restore-keys: |
+ ${{ runner.os }}-mix-${{ env.ELIXIR_VERSION }}-${{ env.OTP_VERSION }}-
+
+ - name: Install dependencies
+ run: mix deps.get
+
+ - name: Install JavaScript dependencies
+ run: npm --prefix apps/elektrine/assets ci
+
+ - name: Build assets
+ # Feature (Wallaby) tests exercise the app in a real browser and need
+ # the compiled CSS/JS bundle in priv/static.
+ run: cd apps/elektrine && mix assets.setup && mix assets.build
+
+ - name: Verify formatting
+ id: format_check
+ continue-on-error: ${{ inputs.allow_format_failure }}
+ run: mix format --check-formatted
+
+ - name: Check generated artifacts are not tracked
+ run: scripts/check_tracked_generated_artifacts.sh
+
+ - name: Check JavaScript assets
+ run: npm --prefix apps/elektrine/assets run check
+
+ - name: Run JavaScript dependency audit
+ run: npm --prefix apps/elektrine/assets audit --omit=dev
+
+ - name: Emit warning when formatting is allowed to fail
+ if: ${{ inputs.allow_format_failure && steps.format_check.outcome == 'failure' }}
+ run: echo "::warning::Formatting check failed, but deploy can continue."
+
+ - name: Compile with warnings treated as errors
+ run: mix compile --warnings-as-errors
+
+ - name: Run Credo
+ run: mix credo --strict
+
+ - name: Run tests
+ id: test_check
+ # Always continue: tests still run and report, but red suites do not
+ # block Quality Gate / deploy. workflow_call may pass allow_test_failure=false
+ # to re-enable fail-closed behavior.
+ continue-on-error: ${{ inputs.allow_test_failure != false }}
+ run: mix test
+
+ - name: Emit warning when tests are allowed to fail
+ if: ${{ inputs.allow_test_failure != false && steps.test_check.outcome == 'failure' }}
+ run: echo "::warning::Test suite failed, but deploy can continue."
+
+ - name: Audit Nerve extension dependencies
+ run: npm --prefix clients/nerve-extension audit --omit=dev
+
+ - name: Run dependency audit
+ run: mix deps.audit
+
+ - name: Run Hex package retirement audit
+ run: mix hex.audit
diff --git a/.github/workflows/docker-deploy.yml b/.github/workflows/docker-deploy.yml
new file mode 100644
index 0000000..b1f7237
--- /dev/null
+++ b/.github/workflows/docker-deploy.yml
@@ -0,0 +1,314 @@
+name: Deploy Docker Images
+
+on:
+ push:
+ branches:
+ - main
+ workflow_dispatch:
+ inputs:
+ repair_indexes:
+ description: Rebuild all PostgreSQL indexes during deploy
+ required: false
+ default: false
+ type: boolean
+
+env:
+ REGISTRY: ghcr.io
+ IMAGE_NAME: ${{ github.repository }}
+
+jobs:
+ quality_gate:
+ name: Quality Gate
+ uses: ./.github/workflows/ci.yml
+ with:
+ allow_format_failure: false
+ allow_test_failure: true
+
+ build_and_publish_image:
+ name: Build and Publish Image
+ needs: quality_gate
+ runs-on: ubuntu-latest
+ permissions:
+ contents: read
+ packages: write
+ env:
+ BUILD_PRIMARY_DOMAIN: ${{ vars.DOCKER_BUILD_PRIMARY_DOMAIN || secrets.DOCKER_BUILD_PRIMARY_DOMAIN || 'example.com' }}
+ BUILD_EMAIL_DOMAIN: ${{ vars.DOCKER_BUILD_EMAIL_DOMAIN || secrets.DOCKER_BUILD_EMAIL_DOMAIN || 'example.com' }}
+ BUILD_SUPPORTED_DOMAINS: ${{ vars.DOCKER_BUILD_SUPPORTED_DOMAINS || secrets.DOCKER_BUILD_SUPPORTED_DOMAINS || 'example.com' }}
+ BUILD_PROFILE_BASE_DOMAINS: ${{ vars.DOCKER_BUILD_PROFILE_BASE_DOMAINS || secrets.DOCKER_BUILD_PROFILE_BASE_DOMAINS || 'example.com' }}
+ ELEKTRINE_ENABLED_MODULES: all
+ ELEKTRINE_RELEASE_MODULES: all
+
+ steps:
+ - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
+
+ - name: Log in to GHCR
+ uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4
+ with:
+ registry: ${{ env.REGISTRY }}
+ username: ${{ github.actor }}
+ password: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: Extract image metadata
+ id: meta
+ uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6
+ with:
+ images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
+ tags: |
+ type=raw,value=latest,enable={{is_default_branch}}
+ type=sha,prefix=,format=long
+
+ - name: Build and push image
+ uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7
+ with:
+ context: .
+ file: deploy/docker/Dockerfile
+ push: true
+ tags: ${{ steps.meta.outputs.tags }}
+ labels: ${{ steps.meta.outputs.labels }}
+ build-args: |
+ RELEASE_NAME=elektrine
+ ELEKTRINE_RELEASE_MODULES=${{ env.ELEKTRINE_RELEASE_MODULES || env.ELEKTRINE_ENABLED_MODULES }}
+ PRIMARY_DOMAIN=${{ env.BUILD_PRIMARY_DOMAIN }}
+ EMAIL_DOMAIN=${{ env.BUILD_EMAIL_DOMAIN }}
+ SUPPORTED_DOMAINS=${{ env.BUILD_SUPPORTED_DOMAINS }}
+ PROFILE_BASE_DOMAINS=${{ env.BUILD_PROFILE_BASE_DOMAINS }}
+
+ deploy_to_docker:
+ name: Deploy Application
+ needs: build_and_publish_image
+ runs-on: ubuntu-latest
+ permissions:
+ contents: read
+ packages: read
+ env:
+ # Non-secret deploy settings live in repository Variables.
+ # Only the private key is a Secret.
+ DEPLOY_HOST: ${{ vars.DEPLOY_HOST }}
+ DEPLOY_USER: ${{ vars.DEPLOY_USER }}
+ DEPLOY_PORT: ${{ vars.DEPLOY_PORT || '22' }}
+ DEPLOY_PATH: ${{ vars.DEPLOY_PATH || '/opt/elektrine' }}
+ DEPLOY_BRANCH: ${{ vars.DEPLOY_BRANCH || 'main' }}
+ DEPLOY_SSH_HOST_KEY: ${{ vars.DEPLOY_SSH_HOST_KEY }}
+ DEPLOY_SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
+ ELEKTRINE_ENABLED_MODULES: all
+ ELEKTRINE_RELEASE_MODULES: all
+ DOCKER_PROFILES: caddy dns email tor turn bluesky vpn
+ DEPLOY_COMPOSE_OVERRIDES: ${{ vars.DEPLOY_COMPOSE_OVERRIDES || '' }}
+ REPAIR_INDEXES_ON_DEPLOY: ${{ github.event.inputs.repair_indexes || vars.REPAIR_INDEXES_ON_DEPLOY || 'false' }}
+ TARGET_IMAGE: ghcr.io/${{ github.repository }}:${{ github.sha }}
+ DEPLOY_REF: ${{ github.sha }}
+ concurrency:
+ group: deploy-production
+ cancel-in-progress: false
+
+ steps:
+ - name: Validate deploy settings
+ run: |
+ missing=()
+ [ -n "${DEPLOY_HOST}" ] || missing+=(DEPLOY_HOST)
+ [ -n "${DEPLOY_USER}" ] || missing+=(DEPLOY_USER)
+ [ -n "${DEPLOY_SSH_HOST_KEY}" ] || missing+=(DEPLOY_SSH_HOST_KEY)
+ [ -n "${DEPLOY_SSH_KEY}" ] || missing+=(DEPLOY_SSH_KEY)
+
+ if [ "${#missing[@]}" -gt 0 ]; then
+ printf '::error::Missing deploy settings: %s. Use repository Variables for host/user/path/host-key; DEPLOY_SSH_KEY must remain a Secret.\n' "${missing[*]}"
+ exit 1
+ fi
+
+ if [[ "$DEPLOY_HOST" == *@* ]]; then
+ echo "::warning::DEPLOY_HOST should be a hostname only. Using the user/host split from DEPLOY_HOST."
+ echo "DEPLOY_USER=${DEPLOY_HOST%@*}" >> "$GITHUB_ENV"
+ echo "DEPLOY_HOST=${DEPLOY_HOST##*@}" >> "$GITHUB_ENV"
+ fi
+
+ - name: Install SSH key
+ run: |
+ install -m 700 -d ~/.ssh
+ printf '%s\n' "$DEPLOY_SSH_KEY" > ~/.ssh/id_ed25519
+ chmod 600 ~/.ssh/id_ed25519
+
+ {
+ printf '%s\n' "$DEPLOY_SSH_HOST_KEY"
+ while IFS= read -r line; do
+ case "$line" in
+ ''|'#'*) continue ;;
+ esac
+
+ host_field="${line%% *}"
+ rest="${line#* }"
+ if [[ "$host_field" == *@* ]]; then
+ printf '%s %s\n' "${host_field##*@}" "$rest"
+ fi
+ done <<< "$DEPLOY_SSH_HOST_KEY"
+ } > ~/.ssh/known_hosts
+ chmod 600 ~/.ssh/known_hosts
+
+ - name: Deploy over SSH
+ run: |
+ ssh -p "$DEPLOY_PORT" "$DEPLOY_USER@$DEPLOY_HOST" \
+ bash -s -- "$DEPLOY_PATH" "$DEPLOY_REF" "$DEPLOY_BRANCH" "$TARGET_IMAGE" "$ELEKTRINE_ENABLED_MODULES" "$ELEKTRINE_RELEASE_MODULES" "$DOCKER_PROFILES" "$DEPLOY_COMPOSE_OVERRIDES" "$REPAIR_INDEXES_ON_DEPLOY" <<'EOF'
+ set -euo pipefail
+
+ DEPLOY_PATH="$1"
+ DEPLOY_REF="$2"
+ DEPLOY_BRANCH="$3"
+ TARGET_IMAGE="$4"
+ ELEKTRINE_ENABLED_MODULES="$5"
+ ELEKTRINE_RELEASE_MODULES="$6"
+ DOCKER_PROFILES="$7"
+ DEPLOY_COMPOSE_OVERRIDES="$8"
+ REPAIR_INDEXES_ON_DEPLOY="$9"
+
+ if docker info >/dev/null 2>&1; then
+ DEPLOY_PREFIX=""
+ elif sudo -n docker info >/dev/null 2>&1; then
+ DEPLOY_PREFIX="sudo -n"
+ else
+ echo "No Docker access for deploy user"
+ exit 1
+ fi
+
+ env_grep() {
+ local pattern="$1"
+ local env_file="$2"
+
+ if [ -r "$env_file" ]; then
+ grep -q "$pattern" "$env_file"
+ else
+ sudo -n grep -q "$pattern" "$env_file"
+ fi
+ }
+
+ if [ ! -f "$DEPLOY_PATH/.env.production" ]; then
+ echo "Remote env file is missing: $DEPLOY_PATH/.env.production" >&2
+ exit 1
+ fi
+
+ if [ ! -r "$DEPLOY_PATH/.env.production" ] && ! sudo -n test -r "$DEPLOY_PATH/.env.production"; then
+ deploy_user_name="$(id -un)"
+ echo "Remote env file is not readable by $deploy_user_name and sudo cannot read it: $DEPLOY_PATH/.env.production" >&2
+ echo "Fix ownership/permissions, for example: sudo chown $deploy_user_name:$deploy_user_name $DEPLOY_PATH/.env.production && sudo chmod 600 $DEPLOY_PATH/.env.production" >&2
+ exit 1
+ fi
+
+ if [[ " $DOCKER_PROFILES " == *" turn "* ]]; then
+ if ! env_grep '^TURN_SHARED_SECRET=.' "$DEPLOY_PATH/.env.production"; then
+ echo "TURN profile is enabled, but TURN_SHARED_SECRET is missing from $DEPLOY_PATH/.env.production" >&2
+ echo "Add TURN_SHARED_SECRET to the remote .env.production file." >&2
+ exit 1
+ fi
+ fi
+
+ cd "$DEPLOY_PATH"
+ git fetch --all --tags
+
+ if [ -z "$(git status --porcelain)" ]; then
+ current_branch="$(git branch --show-current || true)"
+ if [ "$current_branch" != "$DEPLOY_BRANCH" ]; then
+ git checkout "$DEPLOY_BRANCH"
+ fi
+ git merge --ff-only "origin/$DEPLOY_BRANCH"
+ else
+ echo "Skipping checkout sync for $DEPLOY_PATH: local changes present"
+ fi
+
+ WORKTREE_PATH="$DEPLOY_PATH/.deploy-worktree.$(date +%s).$$"
+
+ OUTPUT_FILE="$DEPLOY_PATH/deploy/generated/generated.docker.yml"
+ CADDY_OUTPUT_FILE="$DEPLOY_PATH/deploy/generated/generated.Caddyfile"
+ trap 'git -C "$DEPLOY_PATH" worktree remove --force "$WORKTREE_PATH" >/dev/null 2>&1 || rm -rf "$WORKTREE_PATH"' EXIT
+
+ git worktree add --force --detach "$WORKTREE_PATH" "$DEPLOY_REF"
+ WORKTREE_ENV_FILE="$WORKTREE_PATH/.env.production"
+ if [ -r "$DEPLOY_PATH/.env.production" ]; then
+ ln -sfn "$DEPLOY_PATH/.env.production" "$WORKTREE_ENV_FILE"
+ else
+ umask 077
+ sudo -n cat "$DEPLOY_PATH/.env.production" > "$WORKTREE_ENV_FILE"
+ fi
+ cd "$WORKTREE_PATH"
+
+ EXTRA_OVERRIDE_ARGS=()
+ for override_file in $DEPLOY_COMPOSE_OVERRIDES; do
+ case "$override_file" in
+ /*) resolved_override="$override_file" ;;
+ *) resolved_override="$WORKTREE_PATH/$override_file" ;;
+ esac
+ if [ ! -f "$resolved_override" ]; then
+ case " $DOCKER_PROFILES " in
+ *" $override_file "*)
+ echo "Ignoring DEPLOY_COMPOSE_OVERRIDES entry '$override_file' because it is already enabled in DOCKER_PROFILES."
+ continue
+ ;;
+ *)
+ case "$override_file" in
+ bluesky|caddy|dns|email|tor|turn|vpn)
+ echo "Treating DEPLOY_COMPOSE_OVERRIDES entry '$override_file' as a Docker profile. Prefer setting DOCKER_PROFILES instead."
+ DOCKER_PROFILES="$DOCKER_PROFILES $override_file"
+ continue
+ ;;
+ esac
+ ;;
+ esac
+
+ echo "Compose override not found: $resolved_override"
+ exit 1
+ fi
+ EXTRA_OVERRIDE_ARGS+=(--compose-override "$resolved_override")
+ done
+
+ PROFILE_ARGS=()
+ for profile in $DOCKER_PROFILES; do
+ PROFILE_ARGS+=(--profile "$profile")
+ done
+
+ REPAIR_INDEX_ARGS=()
+ case "$REPAIR_INDEXES_ON_DEPLOY" in
+ 1|true|TRUE|yes|YES) REPAIR_INDEX_ARGS+=(--repair-indexes) ;;
+ esac
+
+ ELEKTRINE_IMAGE="$TARGET_IMAGE" COMPOSE_PROJECT_NAME="docker" COMPOSE_PROJECT_DIRECTORY="$DEPLOY_PATH/deploy/docker" CADDY_RENDERED_CONFIG_PATH="$CADDY_OUTPUT_FILE" bash scripts/deploy/docker_deploy.sh \
+ --modules "$ELEKTRINE_ENABLED_MODULES" \
+ --env-file "$WORKTREE_ENV_FILE" \
+ --output "$OUTPUT_FILE" \
+ --pull \
+ --skip-build \
+ "${REPAIR_INDEX_ARGS[@]}" \
+ "${EXTRA_OVERRIDE_ARGS[@]}" \
+ "${PROFILE_ARGS[@]}"
+
+ expected_image="$TARGET_IMAGE"
+ expected_containers=(elektrine_app elektrine_worker)
+ case " $DOCKER_PROFILES " in *" email "*) expected_containers+=(elektrine_mail) ;; esac
+ case " $DOCKER_PROFILES " in *" dns "*) expected_containers+=(elektrine_dns) ;; esac
+ case " $DOCKER_PROFILES " in *" vpn "*) expected_containers+=(elektrine_vpn) ;; esac
+
+ for container in "${expected_containers[@]}"; do
+ if ! ${DEPLOY_PREFIX} docker inspect "$container" >/dev/null 2>&1; then
+ echo "Expected container $container was not created" >&2
+ exit 1
+ fi
+
+ actual_image="$(${DEPLOY_PREFIX} docker inspect "$container" --format '{{.Config.Image}}')"
+
+ if [ "$actual_image" != "$expected_image" ]; then
+ echo "Container $container is running $actual_image, expected $expected_image" >&2
+ exit 1
+ fi
+ done
+ EOF
+
+ - name: Health check
+ run: |
+ ssh -p "$DEPLOY_PORT" "$DEPLOY_USER@$DEPLOY_HOST" "bash -s" </dev/null 2>&1; then
+ DOCKER_CMD="docker"
+ else
+ DOCKER_CMD="sudo -n docker"
+ fi
+ cd "$DEPLOY_PATH"
+ COMPOSE_FILE="$DEPLOY_PATH/deploy/generated/generated.docker.yml"
+ \$DOCKER_CMD compose --project-directory "$DEPLOY_PATH/deploy/docker" --env-file "$DEPLOY_PATH/.env.production" -f "\$COMPOSE_FILE" --profile caddy --profile dns --profile email --profile tor --profile turn --profile bluesky --profile vpn ps || \$DOCKER_CMD ps
+ EOF
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..1f3c635
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,85 @@
+# =============================================================================
+# Elixir / Phoenix
+
+# Build artifacts
+/_build/
+*.ez
+erl_crash.dump
+# Dependencies
+/deps/
+/.fetch
+# Documentation
+/doc/
+# Test and coverage
+/cover/
+/tmp/
+/apps/*/tmp/
+# Package tarball
+elektrine-*.tar
+# Assets
+# Built assets
+/apps/*/priv/static/assets/
+/apps/*/priv/static/cache_manifest.json
+/apps/*/priv/static/uploads/
+/apps/*/priv/static/**/*.gz
+/apps/*/priv/static/**/*-[0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f].*
+# Node.js
+/apps/*/assets/node_modules/
+/apps/*/assets/.sass-cache/
+/apps/*/assets/npm-debug.log*
+/apps/*/assets/yarn-debug.log*
+/apps/*/assets/yarn-error.log*
+npm-debug.log*
+# Environment / Secrets
+.env
+.env.local
+.env.*.local
+.env.production
+.env.*
+!.env.example
+# Generated / Runtime Files
+# Rate limiting ETS tables
+/apps/*/priv/imap_rate_limit_*.ets
+# ACME certificates
+/apps/*/priv/acme/
+# Export files
+/apps/*/priv/exports/
+# Wallaby screenshots
+/screenshots/
+# Deploy-time generated configs
+/deploy/docker/generated*.yml
+/deploy/docker/generated.Caddyfile
+/deploy/docker/generated.mongooseim.toml
+/deploy/generated/*
+!/deploy/generated/.gitkeep
+# IDE / Editor
+**/.claude/settings.local.json
+# macOS Finder metadata
+.DS_Store
+# Editor / temporary files
+*.tmp
+*.temp
+*.swp
+*.swo
+*~
+# External Projects (separate repos)
+# ARBP conformance badge artifacts (generated by mix arbp.conformance)
+/external/arblarg/conformance/badges/
+# Tooling caches / misc
+/.elixir_ls/
+.mix
+.hex
+_deploy_release
+/certs/
+/generated.mongooseim.toml
+_build_opencode/
+apps/elektrine_web/screenshots/
+# local / agent worktrees and caches (do not ship)
+.claude/worktrees/
+.claude/settings.local.json
+_build/
+deps/
+**/node_modules/
+.elixir_ls/
+!.env*.example
+.claude/**/worktrees/
diff --git a/AGENTS.md b/AGENTS.md
new file mode 100644
index 0000000..114d73e
--- /dev/null
+++ b/AGENTS.md
@@ -0,0 +1,127 @@
+# Working in this repo
+
+Elektrine is an Elixir umbrella app. See `README.md` for the app layout and
+what each umbrella app under `apps/` owns.
+
+## Git commits
+
+- Do **not** add AI attribution of any kind: no `Co-Authored-By: Claude ...`,
+ no `Claude-Session:` trailers, no "🤖 Generated with ..." lines, in commit
+ messages or PR bodies.
+- Use [Conventional Commits](https://www.conventionalcommits.org):
+ `type(scope): subject`. Types in use: `feat`, `fix`, `refactor`, `docs`,
+ `test`, `chore`. Scope is the app or feature area — `mail`, `email`, `vpn`,
+ `calendar`, `booking`, `link-archive`, `portal`, `web`, `ui`.
+- Subject in the imperative, no trailing period. The body explains **why** the
+ change exists, not a restatement of the diff.
+- Each commit should compile on its own. Watch for files that tie features
+ together — `router.ex` and `config/config.exs` are routinely touched by
+ several features at once, so a commit that splits them can leave an
+ intermediate revision that does not build.
+- Do not commit or push unless asked.
+
+## Before you push
+
+Pushing `main` to the `github` remote **deploys to production**
+(`.github/workflows/docker-deploy.yml` runs on push to `main`).
+
+- `allow_format_failure: false` — unformatted code blocks the deploy.
+- `allow_test_failure: true` — failing tests do **not** block it. Run
+ `mix test` yourself; CI will not stop a broken suite from shipping.
+- `main` tracks `origin/main`, a local forge at `ssh://localhost:2222`. The
+ `github` remote is the one that deploys. A bare `git push` is not a deploy.
+
+## Verifying changes
+
+Always run the checks that CI runs before considering a change done — do not
+report work as finished until format, compile, and credo pass.
+
+After editing a file, run the fast per-file checks first:
+
+- `mix format ` — format the file.
+- `mix credo --strict ` — lint just that file (fast).
+
+Then, before finishing, run the broader gate:
+
+- `mix format --check-formatted` — CI fails if anything is unformatted.
+- `mix compile --warnings-as-errors` — warnings fail the build; fix them.
+- `mix credo --strict` — style/consistency must pass (whole project).
+- `mix check` — the full CI gate (all of the above plus generated-artifact +
+ legacy-marker checks, asset check, dep/hex audits, and `test`). Run this for
+ anything non-trivial.
+
+Three budget guards fail in ways that are easy to misread:
+
+- `scripts/check_maintainability_budgets.sh` errors when a budgeted path no
+ longer exists, so moving or deleting a budgeted file must update the budget
+ in the *same* commit.
+- `scripts/check_design_consistency.sh` caps raw `btn`/`card` markup, arbitrary
+ type sizes, off-convention radii, raw hex, and inline styles — per app, for
+ every `*_web` tree. Lower the budget when you remove instances, so the
+ surface cannot grow back. A new `*_web` app fails the check until it gets a
+ `check_app` line.
+- `scripts/check_legacy_marker_budget.sh` caps `legacy|backward|compat|
+ deprecated` markers across `apps`, `config`, `scripts`, and `deploy`.
+
+## Tests
+
+- Run the whole suite from the umbrella root: `mix test`.
+- Run one app's tests from its dir: `cd apps/elektrine_web && mix test`.
+- Run a single file/line: `cd apps/ && mix test test/path/to_test.exs:42`.
+- Prefer adding a regression test with any bug fix.
+- Running one app's tests can poison `_build/test` for the umbrella; if the
+ root suite then behaves oddly, `rm -rf _build/test/lib/elektrine`.
+
+## Conventions
+
+- Match the style and idioms of the surrounding code in each app.
+- Keep domain logic in `apps/elektrine`; keep web/LiveView code in
+ `apps/elektrine_web`. Don't reach across app boundaries casually.
+- Two exceptions, both because `apps/elektrine` is the only app every other
+ app depends on: UI components shared by more than one product app live in
+ `apps/elektrine/lib/elektrine/components` (avatar, skeleton, badges), and the
+ frontend assets live in `apps/elektrine/assets`. Import those modules
+ directly — do not add a re-export shim in `apps/elektrine_web`.
+- The `ElektrineWeb.Components.UI` kit is the design system and stays in
+ `apps/elektrine_web`. Buttons and cards come from it, not hand-written
+ markup; use the `text-3xs`/`text-2xs` and radius tokens rather than
+ arbitrary values.
+- Shared text formatting lives in `Elektrine.TextHelpers` — `truncate/2`,
+ `truncate_to/2`, `abbreviate_count/1`, `time_ago_compact/1`. Call those
+ rather than re-deriving them privately, so the same value renders the same
+ way in every app.
+
+## Federation security
+
+- `Elektrine.ActivityPub.SignatureVerification` is the **only** inbound HTTP
+ signature verifier. Every entrance — the inbox plug and the signature retry
+ queue — goes through `verify/2`; callers differ only in the options they
+ pass. Never add a second verifier or a path that checks the signature
+ without also checking required signed headers, timestamp freshness, digest
+ against the raw body, and replay. A partial verifier reachable from anywhere
+ becomes the way in.
+- `Elektrine.ActivityPub.HTTPSignature` signs outbound requests only.
+- Only key-resolution failures are retryable
+ (`SignatureVerification.retryable_error?/1`). A signature that verified but
+ broke a constraint has been rejected on the merits — do not queue it.
+- Retried requests store the raw body and the arrival time. The activity is
+ re-parsed from those bytes *after* the digest check, so what gets processed
+ is what the signature covered, and freshness is judged against arrival
+ rather than when the job happens to run.
+- `Elektrine.ActivityPub.InboundDeliveryPolicy.permit/4` is the one audience
+ gate. Any new inbox entrance applies it — it is what stops a peer pushing
+ content at an inbox it has no relationship with.
+
+## Mail and Haraka
+
+Haraka is a separate deployment (`elektrine-haraka`) that owns inbound SMTP on
+port 25 and outbound delivery; Elektrine owns mailboxes, JMAP, submission, and
+the webhooks Haraka calls. Two layouts are supported and each has its own TLS
+entry point, which write the same `compose.override.yml` — use exactly one per
+Haraka deployment:
+
+- same host: `scripts/deploy/configure_haraka_wildcard_tls.sh` (the default;
+ `docker_deploy.sh` keeps it configured)
+- separate host: `scripts/acme/setup_haraka_mail_tls.sh`
+
+See `docs/self-hosting/mail.md` before changing either.
diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md
new file mode 100644
index 0000000..77cd88e
--- /dev/null
+++ b/CODE_OF_CONDUCT.md
@@ -0,0 +1,56 @@
+# Code of Conduct
+
+## Our Pledge
+
+We as members, contributors, and maintainers pledge to make participation in
+our community a harassment-free experience for everyone, regardless of age,
+body size, visible or invisible disability, ethnicity, sex characteristics,
+gender identity and expression, level of experience, education, socioeconomic
+status, nationality, personal appearance, race, religion, or sexual identity
+and orientation.
+
+We pledge to act and interact in ways that contribute to an open, welcoming,
+diverse, inclusive, and healthy community.
+
+## Our Standards
+
+Examples of behavior that contributes to a positive environment:
+
+- Demonstrating empathy and kindness.
+- Being respectful of differing opinions and experiences.
+- Giving and gracefully accepting constructive feedback.
+- Taking responsibility, apologizing, and learning from mistakes.
+- Focusing on what is best for the overall community.
+
+Examples of unacceptable behavior:
+
+- The use of sexualized language or imagery.
+- Trolling, insulting or derogatory comments, and personal or political attacks.
+- Public or private harassment.
+- Publishing others' private information without explicit permission.
+- Other conduct that could reasonably be considered inappropriate in a
+ professional setting.
+
+## Enforcement Responsibilities
+
+Project maintainers are responsible for clarifying and enforcing standards of
+acceptable behavior and will take appropriate and fair corrective action in
+response to any behavior they deem inappropriate, threatening, offensive, or
+harmful.
+
+## Scope
+
+This Code of Conduct applies within all project spaces and also applies when an
+individual is officially representing the project in public spaces.
+
+## Enforcement
+
+Report unacceptable behavior to the maintainers via the security/reporting
+process in `SECURITY.md`.
+
+All reports will be reviewed and investigated promptly and fairly.
+
+## Attribution
+
+This Code of Conduct is adapted from the Contributor Covenant, version 2.1:
+https://www.contributor-covenant.org/version/2/1/code_of_conduct.html
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
new file mode 100644
index 0000000..0103ad7
--- /dev/null
+++ b/CONTRIBUTING.md
@@ -0,0 +1,66 @@
+# Contributing
+
+Thanks for contributing to Elektrine.
+
+## Scope
+
+This repository is an umbrella project. All apps in `apps/` are open source
+under the same root `LICENSE`:
+
+- `apps/elektrine`
+- `apps/elektrine_web`
+- `apps/arblarg`
+- `apps/elektrine_email`
+- `apps/elektrine_social`
+- `apps/elektrine_vpn`
+- `apps/elektrine_nerve`
+- `apps/elektrine_dns`
+
+## License
+
+This repository is licensed under `AGPL-3.0-only` (see `LICENSE`).
+By contributing, you agree that your contributions are provided under that same license.
+
+## Development Setup
+
+1. Install Elixir/Erlang and PostgreSQL.
+2. Clone the repo.
+3. Copy `env/dev.env.example` to `.env` and set local values.
+4. Run setup:
+
+```bash
+mix setup
+```
+
+5. Start the app:
+
+```bash
+cd apps/elektrine
+mix phx.server
+```
+
+## Running Checks
+
+Run these before opening a PR:
+
+```bash
+mix check
+```
+
+## Pull Requests
+
+1. Keep PRs focused and small.
+2. Include tests for behavior changes.
+3. Update docs when behavior/config changes.
+4. Fill out the PR template.
+
+## Commit Style
+
+Use clear, imperative commit messages, for example:
+
+- `Add federation outbox retry worker`
+- `Fix sequence gap recovery for messaging events`
+
+## Security
+
+Do not open public issues for vulnerabilities. Use the process in `SECURITY.md`.
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..e638dd3
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,663 @@
+ GNU AFFERO GENERAL PUBLIC LICENSE
+ Version 3, 19 November 2007
+
+ Copyright (C) 2007 Free Software Foundation, Inc.
+ Everyone is permitted to copy and distribute verbatim copies
+ of this license document, but changing it is not allowed.
+
+ Preamble
+
+ The GNU Affero General Public License is a free, copyleft license for
+software and other kinds of works, specifically designed to ensure
+cooperation with the community in the case of network server software.
+
+ The licenses for most software and other practical works are designed
+to take away your freedom to share and change the works. By contrast,
+our General Public Licenses are intended to guarantee your freedom to
+share and change all versions of a program--to make sure it remains free
+software for all its users.
+
+ When we speak of free software, we are referring to freedom, not
+price. Our General Public Licenses are designed to make sure that you
+have the freedom to distribute copies of free software (and charge for
+them if you wish), that you receive source code or can get it if you
+want it, that you can change the software or use pieces of it in new
+free programs, and that you know you can do these things.
+
+ Developers that use our General Public Licenses protect your rights
+with two steps: (1) assert copyright on the software, and (2) offer
+you this License which gives you legal permission to copy, distribute
+and/or modify the software.
+
+ A secondary benefit of defending all users' freedom is that
+improvements made in alternate versions of the program, if they
+receive widespread use, become available for other developers to
+incorporate. Many developers of free software are heartened and
+encouraged by the resulting cooperation. However, in the case of
+software used on network servers, this result may fail to come about.
+The GNU General Public License permits making a modified version and
+letting the public access it on a server without ever releasing its
+source code to the public.
+
+ The GNU Affero General Public License is designed specifically to
+ensure that, in such cases, the modified source code becomes available
+to the community. It requires the operator of a network server to
+provide the source code of the modified version running there to the
+users of that server. Therefore, public use of a modified version, on
+a publicly accessible server, gives the public access to the source
+code of the modified version.
+
+ An older license, called the Affero General Public License and
+published by Affero, was designed to accomplish similar goals. This is
+a different license, not a version of the Affero GPL, but Affero has
+released a new version of the Affero GPL which permits relicensing under
+this license.
+
+ The precise terms and conditions for copying, distribution and
+modification follow.
+
+ TERMS AND CONDITIONS
+
+ 0. Definitions.
+
+ "This License" refers to version 3 of the GNU Affero General Public License.
+
+ "Copyright" also means copyright-like laws that apply to other kinds of
+works, such as semiconductor masks.
+
+ "The Program" refers to any copyrightable work licensed under this
+License. Each licensee is addressed as "you". "Licensees" and
+"recipients" may be individuals or organizations.
+
+ To "modify" a work means to copy from or adapt all or part of the work
+in a fashion requiring copyright permission, other than the making of an
+exact copy. The resulting work is called a "modified version" of the
+earlier work or a work "based on" the earlier work.
+
+ A "covered work" means either the unmodified Program or a work based
+on the Program.
+
+ To "propagate" a work means to do anything with it that, without
+permission, would make you directly or secondarily liable for
+infringement under applicable copyright law, except executing it on a
+computer or modifying a private copy. Propagation includes copying,
+distribution (with or without modification), making available to the
+public, and in some countries other activities as well.
+
+ To "convey" a work means any kind of propagation that enables other
+parties to make or receive copies. Mere interaction with a user through
+a computer network, with no transfer of a copy, is not conveying.
+
+ An interactive user interface displays "Appropriate Legal Notices"
+to the extent that it includes a convenient and prominently visible
+feature that (1) displays an appropriate copyright notice, and (2)
+tells the user that there is no warranty for the work (except to the
+extent that warranties are provided), that licensees may convey the
+work under this License, and how to view a copy of this License. If
+the interface presents a list of user commands or options, such as a
+menu, a prominent item in the list meets this criterion.
+
+ 1. Source Code.
+
+ The "source code" for a work means the preferred form of the work
+for making modifications to it. "Object code" means any non-source
+form of a work.
+
+ A "Standard Interface" means an interface that either is an official
+standard defined by a recognized standards body, or, in the case of
+interfaces specified for a particular programming language, one that
+is widely used among developers working in that language.
+
+ The "System Libraries" of an executable work include anything, other
+than the work as a whole, that (a) is included in the normal form of
+packaging a Major Component, but which is not part of that Major
+Component, and (b) serves only to enable use of the work with that
+Major Component, or to implement a Standard Interface for which an
+implementation is available to the public in source code form. A
+"Major Component", in this context, means a major essential component
+(kernel, window system, and so on) of the specific operating system
+(if any) on which the executable work runs, or a compiler used to
+produce the work, or an object code interpreter used to run it.
+
+ The "Corresponding Source" for a work in object code form means all
+the source code needed to generate, install, and (for an executable
+work) run the object code and to modify the work, including scripts to
+control those activities. However, it does not include the work's
+System Libraries, or general-purpose tools or generally available free
+programs which are used unmodified in performing those activities but
+which are not part of the work. For example, Corresponding Source
+includes interface definition files associated with source files for
+the work, and the source code for shared libraries and dynamically
+linked subprograms that the work is specifically designed to require,
+such as by intimate data communication or control flow between those
+subprograms and other parts of the work.
+
+ The Corresponding Source need not include anything that users
+can regenerate automatically from other parts of the Corresponding
+Source.
+
+ The Corresponding Source for a work in source code form is that
+same work.
+
+ 2. Basic Permissions.
+
+ All rights granted under this License are granted for the term of
+copyright on the Program, and are irrevocable provided the stated
+conditions are met. This License explicitly affirms your unlimited
+permission to run the unmodified Program. The output from running a
+covered work is covered by this License only if the output, given its
+content, constitutes a covered work. This License acknowledges your
+rights of fair use or other equivalent, as provided by copyright law.
+
+ You may make, run and propagate covered works that you do not
+convey, without conditions so long as your license otherwise remains
+in force. You may convey covered works to others for the sole purpose
+of having them make modifications exclusively for you, or provide you
+with facilities for running those works, provided that you comply with
+the terms of this License in conveying all material for which you do
+not control copyright. Those thus making or running the covered works
+for you must do so exclusively on your behalf, under your direction
+and control, on terms that prohibit them from making any copies of
+your copyrighted material outside their relationship with you.
+
+ Conveying under any other circumstances is permitted solely under
+the conditions stated below. Sublicensing is not allowed; section 10
+makes it unnecessary.
+
+ 3. Protecting Users' Legal Rights From Anti-Circumvention Law.
+
+ No covered work shall be deemed part of an effective technological
+measure under any applicable law fulfilling obligations under article
+11 of the WIPO copyright treaty adopted on 20 December 1996, or
+similar laws prohibiting or restricting circumvention of such
+measures.
+
+ When you convey a covered work, you waive any legal power to forbid
+circumvention of technological measures to the extent such circumvention
+is effected by exercising rights under this License with respect to
+the covered work, and you disclaim any intention to limit operation or
+modification of the work as a means of enforcing, against the work's
+users, your or third parties' legal rights to forbid circumvention of
+technological measures.
+
+ 4. Conveying Verbatim Copies.
+
+ You may convey verbatim copies of the Program's source code as you
+receive it, in any medium, provided that you conspicuously and
+appropriately publish on each copy an appropriate copyright notice;
+keep intact all notices stating that this License and any
+non-permissive terms added in accord with section 7 apply to the code;
+keep intact all notices of the absence of any warranty; and give all
+recipients a copy of this License along with the Program.
+
+ You may charge any price or no price for each copy that you convey,
+and you may offer support or warranty protection for a fee.
+
+ 5. Conveying Modified Source Versions.
+
+ You may convey a work based on the Program, or the modifications to
+produce it from the Program, in the form of source code under the
+terms of section 4, provided that you also meet all of these conditions:
+
+ a) The work must carry prominent notices stating that you modified
+ it, and giving a relevant date.
+
+ b) The work must carry prominent notices stating that it is
+ released under this License and any conditions added under section
+ 7. This requirement modifies the requirement in section 4 to
+ "keep intact all notices".
+
+ c) You must license the entire work, as a whole, under this
+ License to anyone who comes into possession of a copy. This
+ License will therefore apply, along with any applicable section 7
+ additional terms, to the whole of the work, and all its parts,
+ regardless of how they are packaged. This License gives no
+ permission to license the work in any other way, but it does not
+ invalidate such permission if you have separately received it.
+
+ d) If the work has interactive user interfaces, each must display
+ Appropriate Legal Notices; however, if the Program has interactive
+ interfaces that do not display Appropriate Legal Notices, your
+ work need not make them do so.
+
+ A compilation of a covered work with other separate and independent
+works, which are not by their nature extensions of the covered work,
+and which are not combined with it such as to form a larger program,
+in or on a volume of a storage or distribution medium, is called an
+"aggregate" if the compilation and its resulting copyright are not
+used to limit the access or legal rights of the compilation's users
+beyond what the individual works permit. Inclusion of a covered work
+in an aggregate does not cause this License to apply to the other
+parts of the aggregate.
+
+ 6. Conveying Non-Source Forms.
+
+ You may convey a covered work in object code form under the terms
+of sections 4 and 5, provided that you also convey the
+machine-readable Corresponding Source under the terms of this License,
+in one of these ways:
+
+ a) Convey the object code in, or embodied in, a physical product
+ (including a physical distribution medium), accompanied by the
+ Corresponding Source fixed on a durable physical medium
+ customarily used for software interchange.
+
+ b) Convey the object code in, or embodied in, a physical product
+ (including a physical distribution medium), accompanied by a
+ written offer, valid for at least three years and valid for as
+ long as you offer spare parts or customer support for that product
+ model, to give anyone who possesses the object code either (1) a
+ copy of the Corresponding Source for all the software in the
+ product that is covered by this License, on a durable physical
+ medium customarily used for software interchange, for a price no
+ more than your reasonable cost of physically performing this
+ conveying of source, or (2) access to copy the
+ Corresponding Source from a network server at no charge.
+
+ c) Convey individual copies of the object code with a copy of the
+ written offer to provide the Corresponding Source. This
+ alternative is allowed only occasionally and noncommercially, and
+ only if you received the object code with such an offer, in accord
+ with subsection 6b.
+
+ d) Convey the object code by offering access from a designated
+ place (gratis or for a charge), and offer equivalent access to the
+ Corresponding Source in the same way through the same place at no
+ further charge. You need not require recipients to copy the
+ Corresponding Source along with the object code. If the place to
+ copy the object code is a network server, the Corresponding Source
+ may be on a different server (operated by you or a third party)
+ that supports equivalent copying facilities, provided you maintain
+ clear directions next to the object code saying where to find the
+ Corresponding Source. Regardless of what server hosts the
+ Corresponding Source, you remain obligated to ensure that it is
+ available for as long as needed to satisfy these requirements.
+
+ e) Convey the object code using peer-to-peer transmission, provided
+ you inform other peers where the object code and Corresponding
+ Source of the work are being offered to the general public at no
+ charge under subsection 6d.
+
+ A separable portion of the object code, whose source code is excluded
+from the Corresponding Source as a System Library, need not be
+included in conveying the object code work.
+
+ A "User Product" is either (1) a "consumer product", which means any
+tangible personal property which is normally used for personal, family,
+or household purposes, or (2) anything designed or sold for incorporation
+into a dwelling. In determining whether a product is a consumer product,
+doubtful cases shall be resolved in favor of coverage. For a particular
+product received by a particular user, "normally used" refers to a
+typical or common use of that class of product, regardless of the status
+of the particular user or of the way in which the particular user
+actually uses, or expects or is expected to use, the product. A product
+is a consumer product regardless of whether the product has substantial
+commercial, industrial or non-consumer uses, unless such uses represent
+the only significant mode of use of the product.
+
+ "Installation Information" for a User Product means any methods,
+procedures, authorization keys, or other information required to install
+and execute modified versions of a covered work in that User Product from
+a modified version of its Corresponding Source. The information must
+suffice to ensure that the continued functioning of the modified object
+code is in no case prevented or interfered with solely because
+modification has been made.
+
+ If you convey an object code work under this section in, or with, or
+specifically for use in, a User Product, and the conveying occurs as
+part of a transaction in which the right of possession and use of the
+User Product is transferred to the recipient in perpetuity or for a
+fixed term (regardless of how the transaction is characterized), the
+Corresponding Source conveyed under this section must be accompanied
+by the Installation Information. But this requirement does not apply
+if neither you nor any third party retains the ability to install
+modified object code on the User Product (for example, the work has
+been installed in ROM).
+
+ The requirement to provide Installation Information does not include a
+requirement to continue to provide support service, warranty, or updates
+for a work that has been modified or installed by the recipient, or for
+the User Product in which it has been modified or installed. Access to a
+network may be denied when the modification itself materially and
+adversely affects the operation of the network or violates the rules and
+protocols for communication across the network.
+
+ Corresponding Source conveyed, and Installation Information provided,
+in accord with this section must be in a format that is publicly
+documented (and with an implementation available to the public in
+source code form), and must require no special password or key for
+unpacking, reading or copying.
+
+ 7. Additional Terms.
+
+ "Additional permissions" are terms that supplement the terms of this
+License by making exceptions from one or more of its conditions.
+Additional permissions that are applicable to the entire Program shall
+be treated as though they were included in this License, to the extent
+that they are valid under applicable law. If additional permissions
+apply only to part of the Program, that part may be used separately
+under those permissions, but the entire Program remains governed by this
+License without regard to the additional permissions.
+
+ When you convey a copy of a covered work, you may at your option
+remove any additional permissions from that copy, or from any part of
+it. (Additional permissions may be written to require their own
+removal in certain cases when you modify the work.) You may place
+additional permissions on material, added by you to a covered work,
+for which you have or can give appropriate copyright permission.
+
+ Notwithstanding any other provision of this License, for material you
+add to a covered work, you may (if authorized by the copyright holders
+of that material) supplement the terms of this License with terms:
+
+ a) Disclaiming warranty or limiting liability differently from the
+ terms of sections 15 and 16 of this License; or
+
+ b) Requiring preservation of specified reasonable legal notices or
+ author attributions in that material or in the Appropriate Legal
+ Notices displayed by works containing it; or
+
+ c) Prohibiting misrepresentation of the origin of that material, or
+ requiring that modified versions of such material be marked in
+ reasonable ways as different from the original version; or
+
+ d) Limiting the use for publicity purposes of names of licensors or
+ authors of the material; or
+
+ e) Declining to grant rights under trademark law for use of some
+ trade names, trademarks, or service marks; or
+
+ f) Requiring indemnification of licensors and authors of that
+ material by anyone who conveys the material (or modified versions of
+ it) with contractual assumptions of liability to the recipient, for
+ any liability that these contractual assumptions directly impose on
+ those licensors and authors.
+
+ All other non-permissive additional terms are considered "further
+restrictions" within the meaning of section 10. If the Program as you
+received it, or any part of it, contains a notice stating that it is
+governed by this License along with a term that is a further
+restriction, you may remove that term. If a license document contains
+a further restriction but permits relicensing or conveying under this
+License, you may add to a covered work material governed by the terms
+of that license document, provided that the further restriction does
+not survive such relicensing or conveying.
+
+ If you add terms to a covered work in accord with this section, you
+must place, in the relevant source files, a statement of the
+additional terms that apply to those files, or a notice indicating
+where to find the applicable terms.
+
+ Additional terms, permissive or non-permissive, may be stated in the
+form of a separately written license, or stated as exceptions;
+the above requirements apply either way.
+
+ 8. Termination.
+
+ You may not propagate or modify a covered work except as expressly
+provided under this License. Any attempt otherwise to propagate or
+modify it is void, and will automatically terminate your rights under
+this License (including any patent licenses granted under the third
+paragraph of section 11).
+
+ However, if you cease all violation of this License, then your
+license from a particular copyright holder is reinstated (a)
+provisionally, unless and until the copyright holder explicitly and
+finally terminates your license, and (b) permanently, if the copyright
+holder fails to notify you of the violation by some reasonable means
+prior to 60 days after the cessation.
+
+ Moreover, your license from a particular copyright holder is
+reinstated permanently if the copyright holder notifies you of the
+violation by some reasonable means, this is the first time you have
+received notice of violation of this License (for any work) from that
+copyright holder, and you cure the violation prior to 30 days after
+your receipt of the notice.
+
+ Termination of your rights under this section does not terminate the
+licenses of parties who have received copies or rights from you under
+this License. If your rights have been terminated and not permanently
+reinstated, you do not qualify to receive new licenses for the same
+material under section 10.
+
+ 9. Acceptance Not Required for Having Copies.
+
+ You are not required to accept this License in order to receive or
+run a copy of the Program. Ancillary propagation of a covered work
+occurring solely as a consequence of using peer-to-peer transmission
+to receive a copy likewise does not require acceptance. However,
+nothing other than this License grants you permission to propagate or
+modify any covered work. These actions infringe copyright if you do
+not accept this License. Therefore, by modifying or propagating a
+covered work, you indicate your acceptance of this License to do so.
+
+ 10. Automatic Licensing of Downstream Recipients.
+
+ Each time you convey a covered work, the recipient automatically
+receives a license from the original licensors, to run, modify and
+propagate that work, subject to this License. You are not responsible
+for enforcing compliance by third parties with this License.
+
+ An "entity transaction" is a transaction transferring control of an
+organization, or substantially all assets of one, or subdividing an
+organization, or merging organizations. If propagation of a covered
+work results from an entity transaction, each party to that
+transaction who receives a copy of the work also receives whatever
+licenses to the work the party's predecessor in interest had or could
+give under the previous paragraph, plus a right to possession of the
+Corresponding Source of the work from the predecessor in interest, if
+the predecessor has it or can get it with reasonable efforts.
+
+ You may not impose any further restrictions on the exercise of the
+rights granted or affirmed under this License. For example, you may
+not impose a license fee, royalty, or other charge for exercise of
+rights granted under this License, and you may not initiate litigation
+(including a cross-claim or counterclaim in a lawsuit) alleging that
+any patent claim is infringed by making, using, selling, offering for
+sale, or importing the Program or any portion of it.
+
+ 11. Patents.
+
+ A "contributor" is a copyright holder who authorizes use under this
+License of the Program or a work on which the Program is based. The
+work thus licensed is called the contributor's "contributor version".
+
+ A contributor's "essential patent claims" are all patent claims
+owned or controlled by the contributor, whether already acquired or
+hereafter acquired, that would be infringed by some manner, permitted
+by this License, of making, using, or selling its contributor version,
+but do not include claims that would be infringed only as a
+consequence of further modification of the contributor version. For
+purposes of this definition, "control" includes the right to grant
+patent sublicenses in a manner consistent with the requirements of this
+License.
+
+ Each contributor grants you a non-exclusive, worldwide, royalty-free
+patent license under the contributor's essential patent claims, to
+make, use, sell, offer for sale, import and otherwise run, modify and
+propagate the contents of its contributor version.
+
+ In the following three paragraphs, a "patent license" is any express
+agreement or commitment, however denominated, not to enforce a patent
+(such as an express permission to practice a patent or covenant not to
+sue for patent infringement). To "grant" such a patent license to a
+party means to make such an agreement or commitment not to enforce a
+patent against the party.
+
+ If you convey a covered work, knowingly relying on a patent license,
+and the Corresponding Source of the work is not available for anyone
+to copy, free of charge and under the terms of this License, through a
+publicly available network server or other readily accessible means,
+then you must either (1) cause the Corresponding Source to be so
+available, or (2) arrange to deprive yourself of the benefit of the
+patent license for this particular work, or (3) arrange, in a manner
+consistent with the requirements of this License, to extend the patent
+license to downstream recipients. "Knowingly relying" means you have
+actual knowledge that, but for the patent license, your conveying the
+covered work in a country, or your recipient's use of the covered work
+in a country, would infringe one or more identifiable patents in that
+country that you have reason to believe are valid.
+
+ If, pursuant to or in connection with a single transaction or
+arrangement, you convey, or propagate by procuring conveyance of, a
+covered work, and grant a patent license to some of the parties
+receiving the covered work authorizing them to use, propagate, modify
+or convey a specific copy of the covered work, then the patent license
+you grant is automatically extended to all recipients of the covered
+work and works based on it.
+
+ A patent license is "discriminatory" if it does not include within
+the scope of its coverage, prohibits the exercise of, or is
+conditioned on the non-exercise of one or more of the rights that are
+specifically granted under this License. You may not convey a covered
+work if you are a party to an arrangement with a third party that is
+in the business of distributing software, under which you make payment
+to the third party based on the extent of your activity of conveying
+the work, and under which the third party grants, to any of the
+parties who would receive the covered work from you, a discriminatory
+patent license (a) in connection with copies of the covered work
+conveyed by you (or copies made from those copies), or (b) primarily
+for and in connection with specific products or compilations that
+contain the covered work, unless you entered into that arrangement,
+or that patent license was granted, prior to 28 March 2007.
+
+ Nothing in this License shall be construed as excluding or limiting
+any implied license or other defenses to infringement that may
+otherwise be available to you under applicable patent law.
+
+ 12. No Surrender of Others' Freedom.
+
+ If conditions are imposed on you (whether by court order, agreement or
+otherwise) that contradict the conditions of this License, they do not
+excuse you from the conditions of this License. If you cannot convey a
+covered work so as to satisfy simultaneously your obligations under this
+License and any other pertinent obligations, then as a consequence you may
+not convey it at all. For example, if you agree to terms that obligate you
+to collect a royalty for further conveying from those to whom you convey
+the Program, the only way you could satisfy both those terms and this
+License would be to refrain entirely from conveying the Program.
+
+ 13. Remote Network Interaction; Use with the GNU General Public License.
+
+ Notwithstanding any other provision of this License, if you modify the
+Program, your modified version must prominently offer all users
+interacting with it remotely through a computer network (if your version
+supports such interaction) an opportunity to receive the Corresponding
+Source of your version by providing access to the Corresponding Source
+from a network server at no charge, through some standard or customary
+means of facilitating copying of software. This Corresponding Source
+shall include the Corresponding Source for any work covered by version 3
+of the GNU General Public License that is incorporated pursuant to the
+following paragraph.
+
+ Notwithstanding any other provision of this License, you have
+permission to link or combine any covered work with a work licensed
+under version 3 of the GNU General Public License into a single
+combined work, and to convey the resulting work. The terms of this
+License will continue to apply to the part which is the covered work,
+but the work with which it is combined will remain governed by version
+3 of the GNU General Public License.
+
+ 14. Revised Versions of this License.
+
+ The Free Software Foundation may publish revised and/or new versions of
+the GNU Affero General Public License from time to time. Such new versions
+will be similar in spirit to the present version, but may differ in
+detail to address new problems or concerns.
+
+ Each version is given a distinguishing version number. If the
+Program specifies that a certain numbered version of the GNU Affero
+General Public License "or any later version" applies to it, you have
+the option of following the terms and conditions either of that numbered
+version or of any later version published by the Free Software
+Foundation. If the Program does not specify a version number of the
+GNU Affero General Public License, you may choose any version ever
+published by the Free Software Foundation.
+
+ If the Program specifies that a proxy can decide which future
+versions of the GNU Affero General Public License can be used, that
+proxy's public statement of acceptance of a version permanently
+authorizes you to choose that version for the Program.
+
+ Later license versions may give you additional or different
+permissions. However, no additional obligations are imposed on any
+author or copyright holder as a result of your choosing to follow a
+later version.
+
+ 15. Disclaimer of Warranty.
+
+ THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
+APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
+HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
+OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
+THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
+PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE
+PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE
+COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
+
+ 16. Limitation of Liability.
+
+ IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
+WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR
+CONVEYS THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES,
+INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES
+ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT
+NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES
+SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE
+WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN
+ADVISED OF THE POSSIBILITY OF SUCH DAMAGES.
+
+ 17. Interpretation of Sections 15 and 16.
+
+ If the disclaimer of warranty and limitation of liability provided
+above cannot be given local legal effect according to their terms,
+reviewing courts shall apply local law that most closely approximates
+an absolute waiver of all civil liability in connection with the
+Program, unless a warranty or assumption of liability accompanies a
+copy of the Program in return for a fee.
+
+ END OF TERMS AND CONDITIONS
+
+ How to Apply These Terms to Your New Programs
+
+ If you develop a new program, and you want it to be of the greatest
+possible use to the public, the best way to achieve this is to make it
+free software which everyone can redistribute and change under these
+terms.
+
+ To do so, attach the following notices to the program. It is safest
+to attach them to the start of each source file to most effectively
+state the exclusion of warranty; and each file should have at least the
+"copyright" line and a pointer to where the full notice is found.
+
+
+ Copyright (C)
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as
+ published by the Free Software Foundation, either version 3 of the
+ License, or (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public
+ License along with this program. If not, see
+ .
+
+Also add information on how to contact you by electronic and paper mail.
+
+ If your software can interact with users remotely through a computer
+network, you should also make sure that it provides a way for users to
+get its source. For example, if your program is a web application, its
+interface could display a "Source" link that leads users to an archive
+of the code. There are many ways you could offer source, and different
+solutions will be better for different programs; see section 13 for the
+specific requirements.
+
+ You should also get your employer (if you work as a programmer) or
+school, if any, to sign a "copyright disclaimer" for the program, if
+necessary. For more information on this, and how to apply and follow
+the GNU AGPL, see .
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..52a081d
--- /dev/null
+++ b/README.md
@@ -0,0 +1,206 @@
+# Elektrine
+
+Elektrine is an Elixir umbrella app. Shared runtime and persistence live in
+`apps/elektrine`, the Phoenix host app lives in `apps/elektrine_web`, and the
+product areas are split into their own umbrella apps.
+
+## Layout
+
+- `apps/`: umbrella apps
+- `clients/`: client-side artifacts that are not shipped with the server release
+- `config/`: shared compile-time and runtime config
+- `deploy/`: Docker, Caddy, onion, and VPN deploy assets
+- `docs/`: architecture, protocol, API, and self-hosting docs
+- `env/`: example environment files
+- `release_builder/`: module-aware release build project
+- `scripts/`: release, deploy, and ops helpers
+
+## Main apps
+
+- `apps/elektrine`: shared domain logic, `Repo`, supervisors, accounts, uploads, notifications, calendar, module selection, frontend assets, and the cross-app UI components in `lib/elektrine/components`
+- `apps/elektrine_web`: endpoint, router, plugs, layouts, the `ElektrineWeb.Components.UI` design-system kit, and the account/admin shell
+- `apps/arblarg`: chat facade plus LiveViews, JSON APIs, and PAT APIs
+- `apps/elektrine_social`: timeline, communities, federation, social controllers, and LiveViews
+- `apps/elektrine_email`: mailbox, contacts, mail protocols, JMAP, WKD, and mail web routes
+- `apps/elektrine_vpn`: WireGuard management and VPN UI/API
+- `apps/elektrine_nerve`: Nerve domain and extracted routes
+- `apps/elektrine_dns`: managed DNS runtime, DNS API routes, and DNS LiveViews
+- `apps/elektrine_uptime`: uptime monitors, check history, incidents, and dashboard routes
+- `apps/atomine`: proofs, personhood, account trust, and credit policy domain logic
+- `apps/kairo`: capture, indexing, and graph-oriented knowledge features
+- `apps/paige`: search provider integration and ranking support
+
+Requests enter through `ElektrineWeb.Router`, pass through shared plugs and
+module guards, and then land in the controller or LiveView mounted for the
+selected feature set. Persistence stays centralized in `Elektrine.Repo`.
+
+## Identity Provider
+
+Elektrine exposes an OpenID Connect-compatible identity provider on top of the
+existing OAuth tables:
+
+- discovery: `/.well-known/openid-configuration`
+- browser consent: `/oauth/authorize`
+- public keys: `/oauth/jwks`
+- token exchange: `/oauth/token`
+- user info: `/oauth/userinfo`
+- app management UI: `/account/developer/oidc/clients`
+- grant review UI: `/account/developer/oidc/grants`
+- dynamic registration: `POST /oauth/register` while signed in
+
+Register OAuth apps with `openid`, `profile`, and `email` scopes to use this
+flow. The current implementation supports the authorization code flow and
+issues `RS256` `id_token`s for confidential clients.
+
+## Module Selection
+
+Normal deployments use one public module switch:
+
+- `ELEKTRINE_ENABLED_MODULES` controls which product modules are turned on
+- release builds default to that same module list
+- `ELEKTRINE_RELEASE_MODULES` still exists as an advanced override when you need
+ to compile more modules than you expose at runtime
+
+Supported module ids are `chat`, `social`, `email`, `nerve`, `vpn`, `dns`,
+`uptime`, `atomine`, and `kairo`. `proofs` and `personhood` are accepted aliases
+for `atomine` in the deploy scripts.
+
+In normal use, set `ELEKTRINE_ENABLED_MODULES` and leave
+`ELEKTRINE_RELEASE_MODULES` unset.
+
+## Local Development
+
+From the repo root:
+
+```bash
+mix setup
+```
+
+Start the web app:
+
+```bash
+cd apps/elektrine
+mix phx.server
+```
+
+Useful commands:
+
+```bash
+mix compile
+mix check
+mix test
+mix test apps/elektrine/test
+mix test apps/elektrine_web/test
+mix test apps/elektrine_email/test
+mix test apps/elektrine_social/test
+mix test apps/elektrine_vpn/test
+mix test apps/elektrine_nerve/test
+mix test apps/elektrine_dns/test
+```
+
+Frontend assets live under `apps/elektrine/assets`.
+
+## Build And Deploy
+
+The supported path for hosted or subset installs is the release builder:
+
+```bash
+scripts/release/deploy_release.sh --modules email,vpn
+```
+
+This builds assets, selects the requested feature apps, and writes the release
+to `_deploy_release/`. `deploy/docker/Dockerfile` uses the same path.
+
+For Docker deployments, use the wrapper instead of invoking `docker compose`
+against `deploy/docker/compose.full.yml` directly:
+
+```bash
+scripts/deploy/docker_deploy.sh
+```
+
+The wrapper renders a module-aware Compose file first, sets
+`ELEKTRINE_ENABLED_MODULES`, derives release modules from it by default, sets
+mail runtime flags, and removes POP3, IMAP, and SMTP port bindings when
+`email` is not selected. With no module or profile arguments, it deploys all
+standard modules and Docker profiles; pass `--modules` or `--profile` only for a
+smaller stack.
+
+When `vpn` is selected, the Docker deploy also enables the bundled `vpn`
+service automatically. That service runs the Docker-managed WireGuard node,
+creates or restores its private key, derives the public key, and reconciles
+peer state in-app.
+
+## Self-hosting
+
+The self-hosting docs are split by profile:
+
+- `core`: Phoenix app and Postgres only
+- `mail`: Elektrine mail protocols; production SMTP edge/delivery still needs a separate Haraka deployment
+- `dns`: optional authoritative DNS service enabled through the Docker `dns` profile
+- `vpn`: optional Docker-managed WireGuard on the same stack, with optional fleet mode
+- `addons`: Caddy edge, TURN, Bluesky PDS, onion hosting, and client artifacts
+
+Start with:
+
+- `docs/api/external-api.md`
+- `docs/self-hosting/README.md`
+- `docs/self-hosting/docker.md`
+- `docs/self-hosting/core.md`
+- `docs/self-hosting/caddy.md`
+- `docs/self-hosting/mail.md`
+- `docs/architecture/dns-module.md`
+- `docs/self-hosting/turn.md`
+- `docs/self-hosting/vpn.md`
+- `docs/addons/onion.md`
+- `docs/clients/nerve-extension.md`
+- `docs/clients/static-site-github-deploys.md`
+
+## Email Deployment
+
+The `email` module in this repo is the mailbox/product side of the mail stack.
+Production internet mail also needs
+[`elektrine/elektrine-haraka`](https://git.elektrine.com/elektrine/elektrine-haraka),
+which owns the SMTP edge and delivery pipeline.
+
+This repo owns the mailbox product: UI, aliases, contacts, JMAP, WKD, message
+storage, optional IMAP/POP3/SMTP submission listeners, and the Phoenix endpoints
+that receive mail webhooks. `elektrine-haraka` owns inbound SMTP on port 25,
+outbound delivery, Redis-backed queueing, and the worker that posts cleaned
+inbound message data back into Phoenix.
+
+If you enable the `email` module for production, run `elektrine-haraka`
+alongside Elektrine and configure `HARAKA_BASE_URL`, an outbound Haraka API key,
+and an inbound webhook key. Both stacks live on the same host as separate Docker
+projects (`/opt/elektrine` and `/opt/elektrine-haraka`).
+
+**Deploy split**
+
+- `elektrine-haraka` CI builds and pushes
+ `git.elektrine.com/elektrine/elektrine-haraka` (no SSH deploy).
+- Elektrine deploy rolls the app stack, then pulls that image and restarts the
+ Haraka compose services (`maybe_roll_haraka_stack`), and applies wildcard TLS
+ via `configure_haraka_wildcard_tls.sh`.
+
+Optional env for the roll: `HARAKA_IMAGE`, `HARAKA_IMAGE_TAG` (default `latest`),
+`HARAKA_DEPLOY_DIR`, `HARAKA_ROLL_ON_DEPLOY=false` to skip.
+
+## Bluesky Integration
+
+Bluesky support is part of the social stack.
+
+- Outbound sync mirrors local public posts to Bluesky and keeps linked post
+ state in sync for create, edit, delete, like, repost, and follow events.
+ These jobs run through Oban, so failed outbound calls do not block the local
+ action.
+- Inbound sync, when `BLUESKY_INBOUND_ENABLED=true`, polls notifications for
+ connected accounts and turns replies, mentions, quotes, likes, and reposts on
+ mirrored posts into local notifications. It can also store timeline items
+ locally.
+- Managed mode, when `BLUESKY_MANAGED_ENABLED=true`, talks to a
+ Bluesky-compatible PDS admin API to create per-user accounts, issue app
+ passwords, and store the linkage. Without managed mode, users connect their
+ own Bluesky identifier and app password.
+
+## License
+
+This repository is AGPL-3.0-only unless a file says otherwise.
diff --git a/SECURITY.md b/SECURITY.md
new file mode 100644
index 0000000..b123f4f
--- /dev/null
+++ b/SECURITY.md
@@ -0,0 +1,30 @@
+# Security Policy
+
+## Supported Versions
+
+Security fixes are currently provided for:
+
+- `main` branch (latest commit)
+
+## Reporting a Vulnerability
+
+Please do not file public GitHub issues for security vulnerabilities.
+
+Instead:
+
+1. Email maintainers at `security@elektrine.com`.
+2. Include reproduction steps, impact, and affected components/apps.
+3. If possible, include a minimal proof of concept.
+
+You should receive an initial response within 72 hours.
+
+## Disclosure Process
+
+1. We acknowledge receipt and assess severity.
+2. We prepare and validate a fix.
+3. We coordinate disclosure timing with the reporter.
+4. We publish a fix and release notes.
+
+## Scope Notes
+
+This policy applies to all umbrella apps under `apps/`.
diff --git a/apps/README.md b/apps/README.md
new file mode 100644
index 0000000..0475f36
--- /dev/null
+++ b/apps/README.md
@@ -0,0 +1,44 @@
+# Umbrella Apps
+
+Elektrine is organized as an Elixir umbrella project.
+
+## Apps
+
+- [`elektrine`](./elektrine/README.md) - Core domain logic, persistence, runtime services
+- [`elektrine_web`](./elektrine_web/README.md) - Phoenix endpoint, router, shared layout, and common components
+- [`arblarg`](./arblarg/README.md) - Chat facade, LiveViews, JSON APIs, and PAT APIs
+- [`elektrine_email`](./elektrine_email/README.md) - Mailbox domain, mail protocols, JMAP, WKD, and web routes
+- [`elektrine_social`](./elektrine_social/README.md) - Social/timeline features
+- [`elektrine_vpn`](./elektrine_vpn/README.md) - VPN features
+- [`elektrine_nerve`](./elektrine_nerve/README.md) - Password nerve domain and route macros
+- [`elektrine_dns`](./elektrine_dns/README.md) - Managed DNS runtime, PAT API, and DNS LiveViews
+- [`elektrine_uptime`](./elektrine_uptime/README.md) - Uptime monitors, check history, incidents, and dashboard routes
+- [`atomine`](./atomine) - Proofs, personhood, account trust, and credit policy domain logic
+- [`kairo`](./kairo) - Capture, indexing, and graph-oriented knowledge features
+- [`paige`](./paige) - Search provider integration and ranking support
+
+## Running app-focused tests
+
+From the umbrella root:
+
+```bash
+mix test apps/elektrine/test
+mix test apps/elektrine_web/test
+mix test apps/elektrine_email/test
+mix test apps/elektrine_social/test
+mix test apps/elektrine_vpn/test
+mix test apps/elektrine_nerve/test
+mix test apps/elektrine_dns/test
+mix test apps/elektrine_uptime/test
+mix test apps/atomine/test
+mix test apps/kairo/test
+mix test apps/paige/test
+```
+
+## Protocol docs
+
+The current Arblarg messaging federation spec lives at `../docs/arblarg-1.0.md`.
+
+## License
+
+All umbrella apps are covered by the root repository `LICENSE`.
diff --git a/apps/arblarg/.formatter.exs b/apps/arblarg/.formatter.exs
new file mode 100644
index 0000000..d2cda26
--- /dev/null
+++ b/apps/arblarg/.formatter.exs
@@ -0,0 +1,4 @@
+# Used by "mix format"
+[
+ inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"]
+]
diff --git a/apps/arblarg/.gitignore b/apps/arblarg/.gitignore
new file mode 100644
index 0000000..8b0b58a
--- /dev/null
+++ b/apps/arblarg/.gitignore
@@ -0,0 +1,24 @@
+# The directory Mix will write compiled artifacts to.
+/_build/
+
+# If you run "mix test --cover", coverage assets end up here.
+/cover/
+
+# The directory Mix downloads your dependencies sources to.
+/deps/
+
+# Where third-party dependencies like ExDoc output generated docs.
+/doc/
+
+# Temporary files, for example, from tests.
+/tmp/
+
+# If the VM crashes, it generates a dump, let's ignore it too.
+erl_crash.dump
+
+# Also ignore archive artifacts (built via "mix archive.build").
+*.ez
+
+# Ignore package tarball (built via "mix hex.build").
+elektrine_chat-*.tar
+
diff --git a/apps/arblarg/README.md b/apps/arblarg/README.md
new file mode 100644
index 0000000..723b909
--- /dev/null
+++ b/apps/arblarg/README.md
@@ -0,0 +1,20 @@
+# Arblarg
+
+Chat application boundary for the Elektrine umbrella.
+
+## What lives here
+
+- Chat APIs used by web and mobile clients
+- Chat LiveViews, JSON APIs, PAT APIs, and admin Arblarg views
+- Chat-specific components and web helpers in `ArblargWeb.*` and `ElektrineWeb.*`
+
+## Notes
+
+- The public facade is `Arblarg`.
+- Domain calls still delegate to `Elektrine.Messaging` today.
+- Uses shared layouts and components from `elektrine_web`.
+- Router-mounted modules in this app still use the shared `ElektrineWeb.*` namespace where that keeps route integration simple.
+
+## License
+
+AGPL-3.0-only (see `../../LICENSE`).
diff --git a/apps/arblarg/lib/arblarg.ex b/apps/arblarg/lib/arblarg.ex
new file mode 100644
index 0000000..3bd7b63
--- /dev/null
+++ b/apps/arblarg/lib/arblarg.ex
@@ -0,0 +1,138 @@
+defmodule Arblarg do
+ @moduledoc """
+ Chat application facade.
+
+ This module is the primary API surface for chat use-cases consumed by
+ `elektrine_web` and other clients. It delegates to `Elektrine.Messaging`
+ during migration so call sites can move first without behavior changes.
+ """
+
+ alias Elektrine.Messaging
+
+ # Conversations and discovery
+ defdelegate list_conversations(user_id, opts \\ []), to: Messaging
+ defdelegate user_has_conversations?(user_id), to: Messaging
+ defdelegate get_conversation!(id, user_id), to: Messaging
+ defdelegate get_conversation_unread_count(conversation_id, user_id), to: Messaging
+ defdelegate get_conversation_for_chat!(id, user_id), to: Messaging
+ defdelegate get_conversation_for_chat_by_hash!(hash, user_id), to: Messaging
+ defdelegate get_conversation_by_hash(hash), to: Messaging
+ defdelegate search_public_conversations(query, current_user_id, limit \\ 10), to: Messaging
+ defdelegate list_public_channels(opts \\ []), to: Messaging
+ defdelegate list_public_groups(opts \\ []), to: Messaging
+ defdelegate join_conversation(conversation_id, user_id), to: Messaging
+ defdelegate leave_conversation(conversation_id, user_id), to: Messaging
+ defdelegate pin_conversation(conversation_id, user_id), to: Messaging
+ defdelegate unpin_conversation(conversation_id, user_id), to: Messaging
+ defdelegate update_conversation(conversation, attrs), to: Messaging
+ defdelegate delete_conversation(conversation_id), to: Messaging
+ defdelegate clear_history_for_user(conversation_id, user_id), to: Messaging
+ defdelegate create_dm_conversation(user1_id, user2_id), to: Messaging
+ defdelegate create_group_conversation(creator_id, attrs, member_ids \\ []), to: Messaging
+ defdelegate create_channel(creator_id, attrs), to: Messaging
+
+ defdelegate add_member_to_conversation(
+ conversation_id,
+ user_id,
+ role \\ "member",
+ added_by_user_id \\ nil
+ ),
+ to: Messaging
+
+ defdelegate remove_member_from_conversation(conversation_id, user_id), to: Messaging
+ defdelegate update_member_role(conversation_id, user_id, new_role), to: Messaging
+ defdelegate update_member_role(conversation_id, user_id, new_role, actor_user_id), to: Messaging
+ defdelegate get_conversation_member(conversation_id, user_id), to: Messaging
+ defdelegate get_conversation_members(conversation_id), to: Messaging
+
+ # Messages and reactions
+ defdelegate get_conversation_messages(conversation_id, user_id, opts \\ []), to: Messaging
+ defdelegate get_messages(conversation_id, user_id, opts \\ []), to: Messaging
+
+ defdelegate create_text_message(
+ conversation_id,
+ sender_id,
+ content,
+ reply_to_id \\ nil,
+ opts \\ []
+ ),
+ to: Messaging
+
+ defdelegate create_media_message(
+ conversation_id,
+ sender_id,
+ media_urls,
+ content \\ nil,
+ media_metadata \\ %{}
+ ),
+ to: Messaging
+
+ defdelegate create_voice_message(conversation_id, sender_id, audio_url, duration, mime_type),
+ to: Messaging
+
+ defdelegate delete_message(message_id, user_id, is_admin \\ false), to: Messaging
+ defdelegate admin_delete_message(message_id, admin_user), to: Messaging
+ defdelegate add_reaction(message_id, user_id, emoji), to: Messaging
+ defdelegate pin_message(message_id, user_id), to: Messaging
+ defdelegate unpin_message(message_id, user_id), to: Messaging
+
+ defdelegate create_chat_text_message(conversation_id, sender_id, content, opts \\ []),
+ to: Messaging
+
+ defdelegate create_chat_media_message(
+ conversation_id,
+ sender_id,
+ media_urls,
+ content \\ nil,
+ media_metadata \\ %{}
+ ),
+ to: Messaging
+
+ defdelegate create_chat_voice_message(
+ conversation_id,
+ sender_id,
+ audio_url,
+ duration,
+ mime_type
+ ),
+ to: Messaging
+
+ defdelegate edit_chat_message(message_id, user_id, new_content), to: Messaging
+ defdelegate delete_chat_message(message_id, user_id, is_admin \\ false), to: Messaging
+ defdelegate add_chat_reaction(message_id, user_id, emoji), to: Messaging
+ defdelegate remove_chat_reaction(message_id, user_id, emoji), to: Messaging
+
+ defdelegate search_messages_in_conversation(conversation_id, user_id, query, opts \\ []),
+ to: Messaging
+
+ # Read state and unread counters
+ defdelegate mark_as_read(conversation_id, user_id), to: Messaging
+ defdelegate update_last_read_message(conversation_id, user_id, message_id), to: Messaging
+ defdelegate get_read_status_for_messages(message_ids, conversation_id), to: Messaging
+ defdelegate get_unread_count(user_id), to: Messaging
+ defdelegate get_conversation_unread_counts(conversation_ids, user_id), to: Messaging
+ defdelegate get_batch_last_message_read_status(message_info_list), to: Messaging
+
+ defdelegate mark_chat_messages_read(conversation_id, user_id, up_to_message_id \\ nil),
+ to: Messaging
+
+ defdelegate get_all_chat_unread_counts(user_id), to: Messaging
+
+ # Search and moderation
+ defdelegate list_servers(user_id, opts \\ []), to: Messaging
+ defdelegate list_public_servers(user_id, opts \\ []), to: Messaging
+ defdelegate list_public_directory_servers(opts \\ []), to: Messaging
+ defdelegate get_server(server_id, user_id), to: Messaging
+ defdelegate get_server_member(server_id, user_id), to: Messaging
+ defdelegate create_server(creator_id, attrs), to: Messaging
+ defdelegate create_server_channel(server_id, creator_id, attrs), to: Messaging
+ defdelegate join_server(server_id, user_id), to: Messaging
+
+ defdelegate search_users(query, current_user_id, opts \\ []), to: Messaging
+ defdelegate timeout_user(user_id, created_by_id, duration_seconds, opts \\ []), to: Messaging
+ defdelegate remove_timeout(user_id, conversation_id \\ nil), to: Messaging
+ defdelegate user_timed_out?(user_id, conversation_id \\ nil), to: Messaging
+
+ defdelegate log_moderation_action(action, target_user_id, performed_by_id, opts \\ []),
+ to: Messaging
+end
diff --git a/apps/arblarg/lib/arblarg/application.ex b/apps/arblarg/lib/arblarg/application.ex
new file mode 100644
index 0000000..44ad4f7
--- /dev/null
+++ b/apps/arblarg/lib/arblarg/application.ex
@@ -0,0 +1,20 @@
+defmodule Arblarg.Application do
+ # See https://hexdocs.pm/elixir/Application.html
+ # for more information on OTP Applications
+ @moduledoc false
+
+ use Application
+
+ @impl true
+ def start(_type, _args) do
+ children = [
+ # Starts a worker by calling: Arblarg.Worker.start_link(arg)
+ # {Arblarg.Worker, arg}
+ ]
+
+ # See https://hexdocs.pm/elixir/Supervisor.html
+ # for other strategies and supported options
+ opts = [strategy: :one_for_one, name: Arblarg.Supervisor]
+ Supervisor.start_link(children, opts)
+ end
+end
diff --git a/apps/arblarg/lib/arblarg_web.ex b/apps/arblarg/lib/arblarg_web.ex
new file mode 100644
index 0000000..e33630b
--- /dev/null
+++ b/apps/arblarg/lib/arblarg_web.ex
@@ -0,0 +1,96 @@
+defmodule ArblargWeb do
+ @moduledoc """
+ Shared web entrypoints for chat surfaces extracted from elektrine_web.
+ """
+
+ def controller do
+ quote do
+ use Phoenix.Controller,
+ formats: [:html, :json],
+ layouts: [html: ElektrineWeb.Layouts]
+
+ use Gettext, backend: ElektrineWeb.Gettext
+
+ import Plug.Conn
+
+ alias Elektrine.Utils.SafeConvert
+
+ unquote(verified_routes())
+ end
+ end
+
+ def live_view do
+ quote do
+ use Phoenix.LiveView,
+ layout: {ElektrineWeb.Layouts, :app}
+
+ on_mount ElektrineWeb.Live.AnnouncementCache
+
+ import ElektrineWeb.Live.NotificationHelpers
+
+ unquote(html_helpers())
+
+ # Presence events are handled by ElektrineWeb's PresenceHook where it is
+ # mounted; these no-op fallbacks keep stray client events from crashing
+ # LiveViews that don't track presence.
+ def handle_event(event, _params, socket)
+ when event in [
+ "user_activity",
+ "auto_away_timeout",
+ "device_detected",
+ "connection_changed"
+ ] do
+ {:noreply, socket}
+ end
+ end
+ end
+
+ def live_component do
+ quote do
+ use Phoenix.LiveComponent
+
+ unquote(html_helpers())
+ end
+ end
+
+ def html do
+ quote do
+ use Phoenix.Component
+
+ import Phoenix.Controller,
+ only: [get_csrf_token: 0, view_module: 1, view_template: 1]
+
+ unquote(html_helpers())
+ end
+ end
+
+ defp html_helpers do
+ quote do
+ use Gettext, backend: ElektrineWeb.Gettext
+
+ import Phoenix.HTML
+ import ElektrineWeb.CoreComponents
+ import Elektrine.Components.User.Avatar
+ import Elektrine.Components.Loaders.Skeleton
+ import ArblargWeb.Components.UI.ImageModal
+
+ alias Elektrine.Utils.SafeConvert
+ alias Phoenix.LiveView.JS
+
+ unquote(verified_routes())
+ end
+ end
+
+ def verified_routes do
+ quote do
+ use Phoenix.VerifiedRoutes,
+ endpoint: ElektrineWeb.Endpoint,
+ router: ElektrineWeb.Router,
+ statics: ElektrineWeb.static_paths()
+ end
+ end
+
+ defmacro __using__(which) when is_atom(which) do
+ apply(__MODULE__, which, [])
+ end
+end
diff --git a/apps/arblarg/lib/arblarg_web/components/chat/call.ex b/apps/arblarg/lib/arblarg_web/components/chat/call.ex
new file mode 100644
index 0000000..c159f22
--- /dev/null
+++ b/apps/arblarg/lib/arblarg_web/components/chat/call.ex
@@ -0,0 +1,368 @@
+defmodule ArblargWeb.Components.Chat.Call do
+ @moduledoc false
+ use Phoenix.Component
+
+ attr :user, :map, default: nil
+ attr :conversation_id, :string, default: nil
+ attr :remote_handle, :string, default: nil
+
+ def call_buttons(assigns) do
+ ~H"""
+
+ """
+ end
+
+ attr :call, :map, required: true
+ attr :show, :boolean, default: false
+
+ def incoming_call_modal(assigns) do
+ ~H"""
+
+
+
Incoming {@call.call_type} call
+
+
+ <.user_avatar user={@call.caller} size="lg" />
+
+
{@call.caller.username}
+
is calling you...
+
+
+
+
+
+
+ Decline
+
+
+ Answer
+
+
+
+
+ """
+ end
+
+ attr :call, :map, required: true
+ attr :show, :boolean, default: false
+ attr :audio_enabled, :boolean, default: true
+ attr :video_enabled, :boolean, default: true
+ attr :is_caller, :boolean, default: false
+ attr :call_status, :string, default: "connecting"
+
+ def active_call_overlay(assigns) do
+ ~H"""
+
+
+
+
+
+
+
+
+
+ <.user_avatar user={if @is_caller, do: @call.callee, else: @call.caller} size="sm" />
+
+
+ {if @is_caller, do: @call.callee.username, else: @call.caller.username}
+
+
+ {if @call_status == "connected", do: "00:00", else: "Connecting..."}
+
+
+
+
+
+
+
+
+
+ <.user_avatar user={if @is_caller, do: @call.callee, else: @call.caller} size="2xl" />
+
+
+ {if @is_caller, do: @call.callee.username, else: @call.caller.username}
+
+
+ {if @call_status == "connected", do: "00:00", else: "Connecting..."}
+
+
+
+ <.spinner :if={@call_status == "connecting"} size="sm" />
+
+
+ {String.capitalize(@call_status)}
+
+
+
+
+
+
+ <.spinner size="sm" />
+ {String.capitalize(@call_status)}...
+
+
+
+
+
+
+
+ """
+ end
+
+ attr :user, :map, required: true
+ attr :size, :string, default: "md"
+
+ defp user_avatar(assigns) do
+ size_classes = %{
+ "sm" => "w-8 h-8",
+ "md" => "w-12 h-12",
+ "lg" => "w-16 h-16",
+ "2xl" => "w-24 h-24"
+ }
+
+ assigns =
+ assign(assigns, :size_class, Map.get(size_classes, assigns.size, size_classes["md"]))
+
+ ~H"""
+
+
+
+
+ {String.first(@user.username) |> String.upcase()}
+
+
+
+ """
+ end
+
+ attr :size, :string, default: "md", values: ["xs", "sm", "md", "lg"]
+ attr :class, :string, default: nil
+
+ defp spinner(assigns) do
+ ~H"""
+ "w-3 h-3"
+ "sm" -> "w-4 h-4"
+ "lg" -> "w-8 h-8"
+ _ -> "w-6 h-6"
+ end,
+ @class
+ ]}
+ viewBox="0 0 24 24"
+ xmlns="http://www.w3.org/2000/svg"
+ fill="currentColor"
+ aria-hidden="true"
+ role="status"
+ aria-label="Loading"
+ >
+
+
+ """
+ end
+end
diff --git a/apps/arblarg/lib/arblarg_web/components/chat/sender_badge.ex b/apps/arblarg/lib/arblarg_web/components/chat/sender_badge.ex
new file mode 100644
index 0000000..195d884
--- /dev/null
+++ b/apps/arblarg/lib/arblarg_web/components/chat/sender_badge.ex
@@ -0,0 +1,46 @@
+defmodule ArblargWeb.Components.Chat.SenderBadge do
+ @moduledoc """
+ Small badge shown next to automated chat message authors.
+
+ Webhook-authored messages hydrate a sender-shaped map with `webhook: true`
+ (see `Elektrine.Messaging.ChatMessages`); bot user accounts carry an
+ `is_bot` flag. Both render a small "APP"/"BOT" badge so automated authors
+ are visually distinct from people.
+
+ Usage in message templates, next to the sender name:
+
+ <.sender_badge sender={message_sender(message)} />
+
+ Renders nothing for regular human senders.
+ """
+ use Phoenix.Component
+
+ attr :sender, :map, default: nil
+
+ def sender_badge(assigns) do
+ assigns = assign(assigns, :label, badge_label(assigns.sender))
+
+ ~H"""
+
+ {@label}
+
+ """
+ end
+
+ @doc """
+ Returns "APP" for webhook senders, "BOT" for bot users, nil otherwise.
+ """
+ def badge_label(sender) when is_map(sender) do
+ cond do
+ Map.get(sender, :webhook) == true -> "APP"
+ Map.get(sender, :is_bot) == true -> "BOT"
+ true -> nil
+ end
+ end
+
+ def badge_label(_sender), do: nil
+end
diff --git a/apps/arblarg/lib/arblarg_web/components/platform/e_nav.ex b/apps/arblarg/lib/arblarg_web/components/platform/e_nav.ex
new file mode 100644
index 0000000..70eabe6
--- /dev/null
+++ b/apps/arblarg/lib/arblarg_web/components/platform/e_nav.ex
@@ -0,0 +1,50 @@
+defmodule ArblargWeb.Components.Platform.ENav do
+ @moduledoc """
+ Chat-owned wrapper for the shared platform navigation renderer.
+ """
+
+ use Phoenix.Component
+ use Gettext, backend: ElektrineWeb.Gettext
+
+ alias Elektrine.Platform.ENav, as: PlatformENav
+ alias Elektrine.Platform.ENavComponent
+
+ attr :active_tab, :string, required: true
+ attr :class, :string, default: "mb-6"
+ attr :current_user, :any, default: nil
+ attr :badge_counts, :map, default: nil
+
+ def e_nav(assigns) do
+ assigns = assign_new(assigns, :badge_counts, fn -> nil end)
+
+ badge_counts =
+ assigns.badge_counts || PlatformENav.notification_badge_counts(assigns.current_user)
+
+ assigns =
+ assigns
+ |> assign(:items, nav_items(assigns.current_user, badge_counts))
+ |> assign(:secondary_items, secondary_items(assigns.current_user, badge_counts))
+
+ ENavComponent.render(assigns)
+ end
+
+ defp nav_items(current_user, badge_counts) do
+ PlatformENav.primary_items()
+ |> Enum.map(
+ &Map.update!(&1, :label, fn label -> Gettext.gettext(ElektrineWeb.Gettext, label) end)
+ )
+ |> Enum.filter(&PlatformENav.visible?(&1, current_user))
+ |> PlatformENav.with_badge_counts(badge_counts)
+ end
+
+ defp secondary_items(nil, _badge_counts), do: []
+
+ defp secondary_items(current_user, badge_counts) do
+ PlatformENav.secondary_items()
+ |> Enum.map(
+ &Map.update!(&1, :label, fn label -> Gettext.gettext(ElektrineWeb.Gettext, label) end)
+ )
+ |> Enum.filter(&PlatformENav.visible?(&1, current_user))
+ |> PlatformENav.with_badge_counts(badge_counts)
+ end
+end
diff --git a/apps/arblarg/lib/arblarg_web/components/social/content_journey.ex b/apps/arblarg/lib/arblarg_web/components/social/content_journey.ex
new file mode 100644
index 0000000..aa94904
--- /dev/null
+++ b/apps/arblarg/lib/arblarg_web/components/social/content_journey.ex
@@ -0,0 +1,11 @@
+defmodule ArblargWeb.Components.Social.ContentJourney do
+ @moduledoc false
+
+ alias ArblargWeb.OptionalModule
+
+ @component_module :"Elixir.ElektrineSocialWeb.Components.Social.ContentJourney"
+
+ def content_journey(assigns) do
+ OptionalModule.call(:social, @component_module, :content_journey, [assigns], "")
+ end
+end
diff --git a/apps/arblarg/lib/arblarg_web/components/social/embedded_post.ex b/apps/arblarg/lib/arblarg_web/components/social/embedded_post.ex
new file mode 100644
index 0000000..d9811e2
--- /dev/null
+++ b/apps/arblarg/lib/arblarg_web/components/social/embedded_post.ex
@@ -0,0 +1,11 @@
+defmodule ArblargWeb.Components.Social.EmbeddedPost do
+ @moduledoc false
+
+ alias ArblargWeb.OptionalModule
+
+ @component_module :"Elixir.ElektrineSocialWeb.Components.Social.EmbeddedPost"
+
+ def embedded_post(assigns) do
+ OptionalModule.call(:social, @component_module, :embedded_post, [assigns], "")
+ end
+end
diff --git a/apps/arblarg/lib/arblarg_web/components/ui/image_modal.ex b/apps/arblarg/lib/arblarg_web/components/ui/image_modal.ex
new file mode 100644
index 0000000..612bbf0
--- /dev/null
+++ b/apps/arblarg/lib/arblarg_web/components/ui/image_modal.ex
@@ -0,0 +1,11 @@
+defmodule ArblargWeb.Components.UI.ImageModal do
+ @moduledoc false
+
+ alias ArblargWeb.OptionalModule
+
+ @component_module :"Elixir.ElektrineSocialWeb.Components.UI.ImageModal"
+
+ def image_modal(assigns) do
+ OptionalModule.call(:social, @component_module, :image_modal, [assigns], "")
+ end
+end
diff --git a/apps/arblarg/lib/arblarg_web/controllers/admin/chat_messages_controller.ex b/apps/arblarg/lib/arblarg_web/controllers/admin/chat_messages_controller.ex
new file mode 100644
index 0000000..07dce7f
--- /dev/null
+++ b/apps/arblarg/lib/arblarg_web/controllers/admin/chat_messages_controller.ex
@@ -0,0 +1,410 @@
+defmodule ArblargWeb.Admin.ChatMessagesController do
+ @moduledoc """
+ Controller for admin chat message viewing and management.
+ """
+
+ use ArblargWeb, :controller
+
+ alias Elektrine.{Messaging, Repo}
+ import Ecto.Query
+
+ plug :put_layout, html: {ElektrineWeb.Layouts, :admin}
+ plug :assign_timezone_and_format
+
+ defp assign_timezone_and_format(conn, _opts) do
+ current_user = conn.assigns[:current_user]
+
+ timezone =
+ if current_user && current_user.timezone, do: current_user.timezone, else: "Etc/UTC"
+
+ time_format =
+ if current_user && current_user.time_format, do: current_user.time_format, else: "12"
+
+ conn
+ |> assign(:timezone, timezone)
+ |> assign(:time_format, time_format)
+ end
+
+ def index(conn, params) do
+ search_query = params |> Map.get("search", "") |> String.trim()
+
+ conversation_type_filter =
+ parse_conversation_type_filter(Map.get(params, "conversation_type"))
+
+ protocol_filter = parse_protocol_filter(Map.get(params, "protocol"))
+ page = SafeConvert.parse_page(params)
+ per_page = 50
+ offset = (page - 1) * per_page
+
+ query =
+ base_chat_messages_query()
+ |> maybe_filter_conversation_type(conversation_type_filter)
+ |> maybe_filter_protocol(protocol_filter)
+ |> maybe_search(search_query)
+
+ total_count =
+ query
+ |> exclude(:order_by)
+ |> select([m, ...], m.id)
+ |> Repo.aggregate(:count, :id)
+
+ messages =
+ query
+ |> order_by([m], desc: m.inserted_at)
+ |> limit(^per_page)
+ |> offset(^offset)
+ |> preload([_m, c, s], conversation: c, sender: s)
+ |> Repo.all()
+ |> Messaging.ChatMessage.decrypt_messages()
+ |> Enum.map(&Map.put(&1, :protocol_kind, protocol_kind(&1)))
+
+ stats = %{
+ local: Enum.count(messages, &(&1.protocol_kind == "local")),
+ federated: Enum.count(messages, &(&1.protocol_kind == "federated")),
+ mirrored: Enum.count(messages, &(&1.protocol_kind == "mirrored"))
+ }
+
+ total_pages = ceil(total_count / per_page)
+ page_range = pagination_range(page, total_pages)
+
+ log_admin_chat_messages_index(
+ conn,
+ messages,
+ page,
+ search_query,
+ conversation_type_filter,
+ protocol_filter
+ )
+
+ render(conn, :chat_messages,
+ messages: messages,
+ search_query: search_query,
+ conversation_type_filter: conversation_type_filter,
+ protocol_filter: protocol_filter,
+ page_results_count: length(messages),
+ stats: stats,
+ current_page: page,
+ total_pages: total_pages,
+ total_count: total_count,
+ page_range: page_range
+ )
+ end
+
+ def view(conn, %{"id" => message_id}) do
+ case SafeConvert.parse_id(message_id) do
+ {:ok, id} ->
+ case Messaging.get_chat_message(id) do
+ nil ->
+ not_found(conn)
+
+ %{deleted_at: deleted_at} when not is_nil(deleted_at) ->
+ not_found(conn)
+
+ message ->
+ message =
+ message
+ |> Repo.preload(:conversation)
+ |> Messaging.ChatMessage.decrypt_content()
+ |> Map.put(:protocol_kind, protocol_kind(message))
+
+ log_admin_chat_message_view(conn, message, "html", "admin_arblarg_messages")
+
+ render(conn, :view_chat_message, message: message)
+ end
+
+ {:error, _} ->
+ not_found(conn)
+ end
+ end
+
+ def view_raw(conn, %{"id" => message_id}) do
+ case SafeConvert.parse_id(message_id) do
+ {:ok, id} ->
+ case Messaging.get_chat_message(id) do
+ nil ->
+ conn
+ |> put_resp_content_type("text/plain")
+ |> send_resp(404, "Chat message not found")
+
+ %{deleted_at: deleted_at} when not is_nil(deleted_at) ->
+ conn
+ |> put_resp_content_type("text/plain")
+ |> send_resp(404, "Chat message not found")
+
+ message ->
+ message =
+ message
+ |> Repo.preload(:conversation)
+ |> Messaging.ChatMessage.decrypt_content()
+ |> Map.put(:protocol_kind, protocol_kind(message))
+
+ log_admin_chat_message_view(conn, message, "raw", "admin_arblarg_messages")
+
+ conn
+ |> put_resp_content_type("text/plain")
+ |> send_resp(200, get_raw_chat_message_content(message))
+ end
+
+ {:error, _} ->
+ conn
+ |> put_resp_content_type("text/plain")
+ |> send_resp(404, "Chat message not found")
+ end
+ end
+
+ defp not_found(conn) do
+ conn
+ |> put_flash(:error, "Chat message not found")
+ |> redirect(to: ~p"/pripyat/arblarg/messages")
+ end
+
+ defp base_chat_messages_query do
+ from(m in Messaging.ChatMessage,
+ left_join: c in Messaging.ChatConversation,
+ on: m.conversation_id == c.id,
+ left_join: s in Elektrine.Accounts.User,
+ on: m.sender_id == s.id,
+ where: is_nil(m.deleted_at)
+ )
+ end
+
+ defp maybe_search(query, ""), do: query
+
+ defp maybe_search(query, search_query) do
+ search_term = "%#{search_query}%"
+
+ from([m, c, s] in query,
+ where:
+ ilike(fragment("COALESCE(?, '')", m.content), ^search_term) or
+ ilike(fragment("COALESCE(?, '')", s.username), ^search_term) or
+ ilike(fragment("COALESCE(?, '')", s.handle), ^search_term) or
+ ilike(fragment("COALESCE(?, '')", c.name), ^search_term) or
+ ilike(fragment("COALESCE(?, '')", m.federated_source), ^search_term) or
+ ilike(fragment("COALESCE(?, '')", m.origin_domain), ^search_term)
+ )
+ end
+
+ defp maybe_filter_conversation_type(query, "dm"), do: where(query, [_m, c, _s], c.type == "dm")
+
+ defp maybe_filter_conversation_type(query, "group"),
+ do: where(query, [_m, c, _s], c.type == "group")
+
+ defp maybe_filter_conversation_type(query, "channel"),
+ do: where(query, [_m, c, _s], c.type == "channel")
+
+ defp maybe_filter_conversation_type(query, "all"), do: query
+ defp maybe_filter_conversation_type(query, _), do: query
+
+ defp maybe_filter_protocol(query, "local"),
+ do:
+ where(
+ query,
+ [m, _c, _s],
+ is_nil(m.federated_source) and is_nil(m.origin_domain) and m.is_federated_mirror == false
+ )
+
+ defp maybe_filter_protocol(query, "mirrored"),
+ do: where(query, [m, _c, _s], m.is_federated_mirror == true)
+
+ defp maybe_filter_protocol(query, "federated"),
+ do:
+ where(
+ query,
+ [m, _c, _s],
+ not is_nil(m.federated_source) or not is_nil(m.origin_domain) or
+ m.is_federated_mirror == true
+ )
+
+ defp maybe_filter_protocol(query, "all"), do: query
+ defp maybe_filter_protocol(query, _), do: query
+
+ defp parse_conversation_type_filter("dm"), do: "dm"
+ defp parse_conversation_type_filter("group"), do: "group"
+ defp parse_conversation_type_filter("channel"), do: "channel"
+ defp parse_conversation_type_filter(_), do: "all"
+
+ defp parse_protocol_filter("local"), do: "local"
+ defp parse_protocol_filter("federated"), do: "federated"
+ defp parse_protocol_filter("mirrored"), do: "mirrored"
+ defp parse_protocol_filter(_), do: "all"
+
+ defp protocol_kind(message) do
+ cond do
+ message.is_federated_mirror -> "mirrored"
+ present?(message.federated_source) or present?(message.origin_domain) -> "federated"
+ true -> "local"
+ end
+ end
+
+ defp present?(value), do: Elektrine.Strings.present?(value)
+
+ defp get_raw_chat_message_content(message) do
+ conversation = Map.get(message, :conversation)
+ sender = Map.get(message, :sender)
+
+ media_urls =
+ message.media_urls
+ |> List.wrap()
+ |> Enum.join("\n")
+ |> case do
+ "" -> "(No media URLs)"
+ value -> value
+ end
+
+ media_metadata =
+ case message.media_metadata do
+ value when is_map(value) and map_size(value) > 0 -> Jason.encode!(value, pretty: true)
+ _ -> "(No media metadata)"
+ end
+
+ """
+ ===============================================================================
+ ARBLARG CHAT MESSAGE
+ ===============================================================================
+
+ MESSAGE FIELDS:
+ ---------------
+ ID: #{message.id}
+ Message Type: #{message.message_type || "text"}
+ Protocol Kind: #{message.protocol_kind}
+ Conversation ID: #{message.conversation_id || "N/A"}
+ Sender ID: #{message.sender_id || "N/A"}
+ Reply To ID: #{message.reply_to_id || "(None)"}
+ Federated Source: #{message.federated_source || "(None)"}
+ Origin Domain: #{message.origin_domain || "(None)"}
+ Mirrored: #{message.is_federated_mirror || false}
+ Edited At: #{message.edited_at || "(Never)"}
+ Inserted At: #{message.inserted_at}
+ Updated At: #{message.updated_at}
+
+ CONVERSATION:
+ -------------
+ #{if conversation do
+ """
+ Name: #{conversation.name || "(No name)"}
+ Type: #{conversation.type || "(Unknown)"}
+ Public: #{conversation.is_public || false}
+ Hash: #{conversation.hash || "(No hash)"}
+ """
+ else
+ "Conversation not loaded"
+ end}
+
+ SENDER:
+ -------
+ #{if sender do
+ """
+ Username: #{sender.username}
+ Handle: #{sender.handle || "(No handle)"}
+ Is Admin: #{sender.is_admin || false}
+ """
+ else
+ "Sender not loaded"
+ end}
+
+ CONTENT:
+ --------
+ #{message.content || "(No text content)"}
+
+ MEDIA URLS:
+ -----------
+ #{media_urls}
+
+ MEDIA METADATA:
+ ---------------
+ #{media_metadata}
+
+ RAW STRUCT:
+ -----------
+ #{inspect(message, pretty: true, limit: :infinity)}
+
+ ===============================================================================
+ END OF ARBLARG CHAT MESSAGE
+ ===============================================================================
+ """
+ end
+
+ defp log_admin_chat_messages_index(
+ conn,
+ messages,
+ page,
+ search_query,
+ conversation_type_filter,
+ protocol_filter
+ ) do
+ case conn.assigns[:current_user] do
+ %{id: admin_id, is_admin: true, username: admin_username} ->
+ Elektrine.AuditLog.log(
+ admin_id,
+ "view_chat_messages",
+ "chat_message_list",
+ details: %{
+ route_context: "admin_arblarg_messages",
+ page: page,
+ search_query: search_query,
+ conversation_type_filter: conversation_type_filter,
+ protocol_filter: protocol_filter,
+ result_count: length(messages),
+ message_ids: Enum.map(messages, & &1.id),
+ viewer_username: admin_username
+ },
+ ip_address: get_remote_ip(conn),
+ user_agent: get_req_header(conn, "user-agent") |> List.first()
+ )
+
+ _ ->
+ :ok
+ end
+ end
+
+ defp log_admin_chat_message_view(conn, message, view_format, route_context) do
+ case conn.assigns[:current_user] do
+ %{id: admin_id, is_admin: true, username: admin_username} ->
+ Elektrine.AuditLog.log(
+ admin_id,
+ "view_chat_message",
+ "chat_message",
+ target_user_id: message.sender_id,
+ resource_id: message.id,
+ details: %{
+ view_format: view_format,
+ route_context: route_context,
+ message_type: message.message_type,
+ conversation_id: message.conversation_id,
+ conversation_type: message.conversation && message.conversation.type,
+ protocol_kind: message.protocol_kind,
+ federated_source: message.federated_source,
+ origin_domain: message.origin_domain,
+ mirrored: message.is_federated_mirror || false,
+ viewer_username: admin_username
+ },
+ ip_address: get_remote_ip(conn),
+ user_agent: get_req_header(conn, "user-agent") |> List.first()
+ )
+
+ _ ->
+ :ok
+ end
+ end
+
+ defp get_remote_ip(conn) do
+ ElektrineWeb.ClientIP.client_ip(conn)
+ end
+
+ defp pagination_range(_current_page, total_pages) when total_pages <= 7 do
+ 1..total_pages//1 |> Enum.to_list()
+ end
+
+ defp pagination_range(current_page, total_pages) do
+ cond do
+ current_page <= 4 ->
+ 1..7//1 |> Enum.to_list()
+
+ current_page >= total_pages - 3 ->
+ (total_pages - 6)..total_pages//1 |> Enum.to_list()
+
+ true ->
+ (current_page - 3)..(current_page + 3)//1 |> Enum.to_list()
+ end
+ end
+end
diff --git a/apps/arblarg/lib/arblarg_web/controllers/admin/chat_messages_html.ex b/apps/arblarg/lib/arblarg_web/controllers/admin/chat_messages_html.ex
new file mode 100644
index 0000000..c9c3cf0
--- /dev/null
+++ b/apps/arblarg/lib/arblarg_web/controllers/admin/chat_messages_html.ex
@@ -0,0 +1,19 @@
+defmodule ArblargWeb.Admin.ChatMessagesHTML do
+ @moduledoc """
+ View helpers and templates for admin chat message views.
+ """
+
+ use ArblargWeb, :html
+
+ embed_templates "chat_messages_html/*"
+
+ def truncate_content(nil), do: ""
+
+ def truncate_content(content) when is_binary(content) do
+ if String.length(content) > 150 do
+ String.slice(content, 0, 150) <> "..."
+ else
+ content
+ end
+ end
+end
diff --git a/apps/arblarg/lib/arblarg_web/controllers/admin/chat_messages_html/chat_messages.html.heex b/apps/arblarg/lib/arblarg_web/controllers/admin/chat_messages_html/chat_messages.html.heex
new file mode 100644
index 0000000..5494e1a
--- /dev/null
+++ b/apps/arblarg/lib/arblarg_web/controllers/admin/chat_messages_html/chat_messages.html.heex
@@ -0,0 +1,389 @@
+
+ <% search_active = @search_query != "" %>
+ <% conversation_type_filter = assigns[:conversation_type_filter] || "all" %>
+ <% protocol_filter = assigns[:protocol_filter] || "all" %>
+ <% page_results_count = assigns[:page_results_count] || length(@messages) %>
+ <% stats = assigns[:stats] || %{local: 0, federated: 0, mirrored: 0} %>
+ <% base_params =
+ %{
+ "conversation_type" => conversation_type_filter,
+ "protocol" => protocol_filter
+ }
+ |> then(fn params ->
+ if search_active, do: Map.put(params, "search", @search_query), else: params
+ end) %>
+ <% base_params_without_search = Map.delete(base_params, "search") %>
+
+ <.card class="panel-card" body_class="p-0">
+ <:body>
+
+
+
+ Arblarg
+
+
+
+ Chat Message Console
+
+
+
+ Admin view for chat traffic across DMs, groups, and channels. Powered by Arblarg.
+
+
+
+
+ Page {@current_page}
+ of {@total_pages}
+
+
+
+
+
+
+
+
+ On This Page
+
+
{page_results_count}
+
+
+
+
+ Local
+
+
{stats.local}
+
+
+
+
+ Federated
+
+
{stats.federated}
+
+
+
+
+ Mirrored
+
+
{stats.mirrored}
+
+
+
+
+ Total Matches
+
+
{@total_count}
+
+
+
+ <.card class="panel-card" body_class="p-0">
+ <:body>
+
+
+ Filters
+
+
+
Search Messages
+
+
+
+
+
+ <%= if search_active do %>
+
+ Showing results for {@search_query}
+
+ <% end %>
+
+
+
+
+ <%= if @messages == [] do %>
+
+ <.icon
+ name="hero-chat-bubble-left-right"
+ class="mx-auto mb-3 h-10 w-10 text-base-content/30"
+ />
+
No chat messages found
+
+ Adjust your filters or search query.
+
+
+ <% else %>
+ <.card class="panel-card" body_class="p-0">
+ <:body>
+
+
+
+
+ Results
+
+
+
Messages
+
+
+
+ {page_results_count} on this page
+
+
+
+
+
+
+
+
+
+ Message
+ Conversation
+ Sender
+ Protocol
+ Created
+ Actions
+
+
+
+ <%= for message <- @messages do %>
+
+
+
+
+ {truncate_content(message.content || "(No text content)")}
+
+
+ {message.message_type}
+ ID: {message.id}
+
+
+
+
+
+ {message.conversation && (message.conversation.name || "(No name)")}
+
+
+ {message.conversation &&
+ String.upcase(message.conversation.type || "unknown")}
+
+
+
+ {message.sender && message.sender.username}
+
+ @{message.sender && message.sender.handle}
+
+
+
+ "badge badge-warning badge-sm"
+ "federated" -> "badge badge-success badge-sm"
+ _ -> "badge badge-info badge-sm"
+ end
+ }>
+ {String.capitalize(message.protocol_kind)}
+
+
+ {message.origin_domain || message.federated_source || "local"}
+
+
+
+ <.local_time
+ datetime={message.inserted_at}
+ format="datetime"
+ timezone={@timezone}
+ time_format={@time_format}
+ />
+
+
+
+ <.button
+ href={~p"/pripyat/arblarg/messages/#{message.id}/view"}
+ variant="ghost"
+ size="sm"
+ title="View message"
+ >
+ <.icon name="hero-eye" class="h-4 w-4" />
+
+ <.button
+ href={~p"/pripyat/arblarg/messages/#{message.id}/raw"}
+ variant="ghost"
+ size="sm"
+ target="_blank"
+ title="View raw payload"
+ >
+ <.icon name="hero-code-bracket" class="h-4 w-4" />
+
+
+
+
+ <% end %>
+
+
+
+
+
+ <%= for message <- @messages do %>
+
+
+
+
+ {truncate_content(message.content || "(No text content)")}
+
+
+ {message.conversation && (message.conversation.name || "(No name)")} • {message.conversation &&
+ String.upcase(message.conversation.type || "unknown")}
+
+
+ @{message.sender && message.sender.handle}
+
+
+
+ <.button
+ href={~p"/pripyat/arblarg/messages/#{message.id}/view"}
+ variant="ghost"
+ size="xs"
+ title="View message"
+ >
+ <.icon name="hero-eye" class="h-3 w-3" />
+
+ <.button
+ href={~p"/pripyat/arblarg/messages/#{message.id}/raw"}
+ variant="ghost"
+ size="xs"
+ target="_blank"
+ title="View raw payload"
+ >
+ <.icon name="hero-code-bracket" class="h-3 w-3" />
+
+
+
+
+ ID: {message.id}
+
+ <.local_time
+ datetime={message.inserted_at}
+ format="datetime"
+ timezone={@timezone}
+ time_format={@time_format}
+ />
+
+
+
+ <% end %>
+
+
+
+
+ <% end %>
+
+ <%= if @total_pages > 1 do %>
+
+
+ <%= if @current_page > 1 do %>
+ <.button
+ href={
+ ~p"/pripyat/arblarg/messages?#{Map.put(base_params, "page", @current_page - 1)}"
+ }
+ variant="default"
+ class="join-item"
+ >
+ «
+
+ <% end %>
+
+ <%= for page_num <- @page_range do %>
+ <.button
+ href={~p"/pripyat/arblarg/messages?#{Map.put(base_params, "page", page_num)}"}
+ variant={if page_num == @current_page, do: "primary", else: "ghost"}
+ class="join-item"
+ >
+ {page_num}
+
+ <% end %>
+
+ <%= if @current_page < @total_pages do %>
+ <.button
+ href={
+ ~p"/pripyat/arblarg/messages?#{Map.put(base_params, "page", @current_page + 1)}"
+ }
+ variant="default"
+ class="join-item"
+ >
+ »
+
+ <% end %>
+
+
+
+ {@total_count} total message{if @total_count != 1, do: "s"}
+
+
+ <% end %>
+
diff --git a/apps/arblarg/lib/arblarg_web/controllers/admin/chat_messages_html/view_chat_message.html.heex b/apps/arblarg/lib/arblarg_web/controllers/admin/chat_messages_html/view_chat_message.html.heex
new file mode 100644
index 0000000..359d434
--- /dev/null
+++ b/apps/arblarg/lib/arblarg_web/controllers/admin/chat_messages_html/view_chat_message.html.heex
@@ -0,0 +1,228 @@
+
+ <.card class="panel-card" body_class="p-0">
+ <:body>
+
+
+
+ Arblarg
+
+
+
Chat Message
+
+
+ Inspect a single message, its delivery metadata, and raw payload for moderation or debugging work.
+
+
+
+
+ <.button
+ href={~p"/pripyat/arblarg/messages/#{@message.id}/raw"}
+ variant="ghost"
+ target="_blank"
+ >
+ <.icon name="hero-code-bracket" class="h-4 w-4" /> View Raw
+
+ <.button href={~p"/pripyat/arblarg/messages"} variant="ghost">
+ <.icon name="hero-arrow-left" class="h-4 w-4" /> Back to Messages
+
+
+
+
+
+
+ <.card class="panel-card" body_class="p-0">
+ <:body>
+
+
+ Delivery
+
+
+
Message Info
+
+
+
+
+
+
+ Message ID
+
+
#{@message.id}
+
+
+
+
+ Type
+
+
+ {@message.message_type}
+
+
+
+
+
+ Sender
+
+
+ <.link
+ href={~p"/pripyat/users/#{@message.sender_id}/edit"}
+ class="link link-primary"
+ >
+ {@message.sender && @message.sender.username} (@{@message.sender &&
+ @message.sender.handle})
+
+
+
+
+
+
+ Conversation
+
+
+ {@message.conversation && (@message.conversation.name || "(No name)")}
+
+
+
+
+
+ Conversation Type
+
+
+ {@message.conversation && String.upcase(@message.conversation.type || "unknown")}
+
+
+
+
+
+
+
+ Created
+
+
+ <.local_time
+ datetime={@message.inserted_at}
+ format="datetime"
+ timezone={@timezone}
+ time_format={@time_format}
+ />
+
+
+
+
+
+ Edited At
+
+
{@message.edited_at || "(Never)"}
+
+
+
+
+ Protocol
+
+
+ "badge badge-warning badge-sm"
+ "federated" -> "badge badge-success badge-sm"
+ _ -> "badge badge-info badge-sm"
+ end
+ }>
+ {String.capitalize(@message.protocol_kind)}
+
+
+
+
+
+
+ Origin Domain
+
+
{@message.origin_domain || "(Local)"}
+
+
+
+
+ Federated Source
+
+
{@message.federated_source || "(None)"}
+
+
+
+
+ Mirrored
+
+
+ {if @message.is_federated_mirror, do: "Yes", else: "No"}
+
+
+
+
+
+
+
+ <.card class="panel-card" body_class="p-0">
+ <:body>
+
+
+
+ <%= if @message.content && String.trim(@message.content) != "" do %>
+
+ {@message.content}
+
+ <% else %>
+
+ No text content on this message.
+
+ <% end %>
+
+
+
+
+ <.card class="panel-card" body_class="p-0">
+ <:body>
+
+
+ Attachments
+
+
+
Media & Metadata
+
+
+
+
+ Media URLs
+
+
+ <%= if @message.media_urls && @message.media_urls != [] do %>
+
+ <%= for media_url <- @message.media_urls do %>
+
+ <% end %>
+
+ <% else %>
+
No media URLs.
+ <% end %>
+
+
+
+
+ Media Metadata
+
+
{if is_map(@message.media_metadata), do: Jason.encode!(@message.media_metadata, pretty: true), else: inspect(@message.media_metadata, pretty: true)}
+
+
+
+
diff --git a/apps/arblarg/lib/arblarg_web/controllers/api/conversation_controller.ex b/apps/arblarg/lib/arblarg_web/controllers/api/conversation_controller.ex
new file mode 100644
index 0000000..05bf6eb
--- /dev/null
+++ b/apps/arblarg/lib/arblarg_web/controllers/api/conversation_controller.ex
@@ -0,0 +1,907 @@
+defmodule ArblargWeb.API.ConversationController do
+ @moduledoc """
+ API controller for chat conversations.
+ """
+ use ArblargWeb, :controller
+
+ alias Elektrine.Messaging
+ alias Elektrine.Uploads
+
+ action_fallback ElektrineWeb.FallbackController
+
+ @doc """
+ GET /api/conversations
+ Lists all conversations for the current user.
+ """
+ def index(conn, params) do
+ user = conn.assigns[:current_user]
+ limit = parse_int(params["limit"], 50)
+
+ conversations = Messaging.list_chat_conversations(user.id, limit: limit)
+
+ conn
+ |> put_status(:ok)
+ |> json(%{
+ conversations: Enum.map(conversations, &format_conversation(&1, user.id))
+ })
+ end
+
+ @doc """
+ GET /api/conversations/:id
+ Gets a specific conversation with recent messages.
+ """
+ def show(conn, %{"id" => id}) do
+ user = conn.assigns[:current_user]
+
+ with_valid_id(conn, id, fn conversation_id ->
+ case Messaging.get_conversation!(conversation_id, user.id) do
+ {:ok, conversation} ->
+ {conv_data, messages} = format_conversation_with_messages(conversation, user.id)
+
+ conn
+ |> put_status(:ok)
+ |> json(%{conversation: conv_data, messages: messages})
+
+ {:error, :not_found} ->
+ conn
+ |> put_status(:not_found)
+ |> json(%{error: "Conversation not found"})
+
+ {:error, :not_member} ->
+ conn
+ |> put_status(:forbidden)
+ |> json(%{error: "You are not a member of this conversation"})
+ end
+ end)
+ end
+
+ @doc """
+ POST /api/conversations
+ Creates a new conversation (DM, group, or channel).
+
+ Params:
+ - type: "dm" | "group" | "channel" (required)
+ - user_id: Target user ID (required for DM)
+ - name: Conversation name (required for group/channel)
+ - description: Optional description
+ - member_ids: List of user IDs to add (for group)
+ - is_public: Whether group/channel is public (default false)
+ """
+ def create(conn, %{"type" => "dm", "user_id" => target_user_id}) do
+ user = conn.assigns[:current_user]
+
+ with_valid_id(conn, target_user_id, fn parsed_target_user_id ->
+ case Messaging.create_dm_conversation(user.id, parsed_target_user_id) do
+ {:ok, conversation} ->
+ conn
+ |> put_status(:created)
+ |> json(%{
+ message: "Conversation created",
+ conversation: format_conversation(conversation, user.id)
+ })
+
+ {:error, :self_dm} ->
+ conn
+ |> put_status(:unprocessable_entity)
+ |> json(%{error: "Cannot create conversation with yourself"})
+
+ {:error, :user_not_found} ->
+ conn
+ |> put_status(:not_found)
+ |> json(%{error: "User not found"})
+
+ {:error, :privacy_blocked} ->
+ conn
+ |> put_status(:forbidden)
+ |> json(%{error: "Cannot message this user due to privacy settings"})
+
+ {:error, :rate_limited} ->
+ conn
+ |> put_status(:too_many_requests)
+ |> json(%{error: "Too many conversations created. Please try again later."})
+
+ {:error, reason} ->
+ conn
+ |> put_status(:unprocessable_entity)
+ |> json(%{error: "Failed to create conversation: #{inspect(reason)}"})
+ end
+ end)
+ end
+
+ def create(conn, %{"type" => "group"} = params) do
+ user = conn.assigns[:current_user]
+
+ attrs = %{
+ name: params["name"],
+ description: params["description"],
+ avatar_url: params["avatar_url"],
+ is_public: params["is_public"] == true
+ }
+
+ member_ids = params["member_ids"] || []
+
+ case Messaging.create_chat_group_conversation(user.id, attrs, member_ids) do
+ {:ok, conversation} ->
+ conn
+ |> put_status(:created)
+ |> json(%{
+ message: "Group created",
+ conversation: format_conversation(conversation, user.id)
+ })
+
+ {:error, :limit_reached} ->
+ conn
+ |> put_status(:forbidden)
+ |> json(%{error: "Maximum group limit reached"})
+
+ {:error, %Ecto.Changeset{} = changeset} ->
+ conn
+ |> put_status(:unprocessable_entity)
+ |> json(%{error: "Validation failed", errors: format_errors(changeset)})
+
+ {:error, reason} ->
+ conn
+ |> put_status(:unprocessable_entity)
+ |> json(%{error: "Failed to create group: #{inspect(reason)}"})
+ end
+ end
+
+ def create(conn, %{"type" => "channel"} = params) do
+ user = conn.assigns[:current_user]
+
+ attrs = %{
+ name: params["name"],
+ description: params["description"],
+ avatar_url: params["avatar_url"],
+ # Channels are public by default
+ is_public: params["is_public"] != false
+ }
+
+ case Messaging.create_channel(user.id, attrs) do
+ {:ok, conversation} ->
+ conn
+ |> put_status(:created)
+ |> json(%{
+ message: "Channel created",
+ conversation: format_conversation(conversation, user.id)
+ })
+
+ {:error, :limit_reached} ->
+ conn
+ |> put_status(:forbidden)
+ |> json(%{error: "Maximum channel limit reached"})
+
+ {:error, %Ecto.Changeset{} = changeset} ->
+ conn
+ |> put_status(:unprocessable_entity)
+ |> json(%{error: "Validation failed", errors: format_errors(changeset)})
+
+ {:error, reason} ->
+ conn
+ |> put_status(:unprocessable_entity)
+ |> json(%{error: "Failed to create channel: #{inspect(reason)}"})
+ end
+ end
+
+ def create(conn, _params) do
+ conn
+ |> put_status(:bad_request)
+ |> json(%{error: "Missing required parameter: type (dm, group, or channel)"})
+ end
+
+ @doc """
+ PUT /api/conversations/:id
+ Updates a conversation (name, description, etc.).
+ """
+ def update(conn, %{"id" => id} = params) do
+ user = conn.assigns[:current_user]
+
+ with_valid_id(conn, id, fn conversation_id ->
+ # Check if user has permission to update
+ case Messaging.get_conversation_member(conversation_id, user.id) do
+ nil ->
+ conn
+ |> put_status(:not_found)
+ |> json(%{error: "Conversation not found"})
+
+ member when member.role not in ["owner", "admin"] ->
+ conn
+ |> put_status(:forbidden)
+ |> json(%{error: "Only owners and admins can update conversations"})
+
+ _member ->
+ # Get the conversation and update it
+ case Messaging.get_conversation!(conversation_id, user.id) do
+ {:ok, conversation} ->
+ attrs =
+ %{}
+ |> maybe_put(:name, params["name"])
+ |> maybe_put(:description, params["description"])
+ |> maybe_put(:avatar_url, params["avatar_url"])
+
+ case Messaging.update_conversation(conversation, attrs) do
+ {:ok, updated} ->
+ conn
+ |> put_status(:ok)
+ |> json(%{
+ message: "Conversation updated",
+ conversation: format_conversation(updated, user.id)
+ })
+
+ {:error, %Ecto.Changeset{} = changeset} ->
+ conn
+ |> put_status(:unprocessable_entity)
+ |> json(%{error: "Validation failed", errors: format_errors(changeset)})
+ end
+
+ {:error, _} ->
+ conn
+ |> put_status(:not_found)
+ |> json(%{error: "Conversation not found"})
+ end
+ end
+ end)
+ end
+
+ @doc """
+ DELETE /api/conversations/:id
+ Deletes a conversation (owner only) or leaves it.
+ """
+ def delete(conn, %{"id" => id}) do
+ user = conn.assigns[:current_user]
+
+ with_valid_id(conn, id, fn conversation_id ->
+ case Messaging.get_conversation_member(conversation_id, user.id) do
+ nil ->
+ conn
+ |> put_status(:not_found)
+ |> json(%{error: "Conversation not found"})
+
+ member ->
+ if member.role == "owner" do
+ # Owner deletes the entire conversation
+ case Messaging.delete_conversation(conversation_id) do
+ {:ok, _} ->
+ conn
+ |> put_status(:ok)
+ |> json(%{message: "Conversation deleted"})
+
+ {:error, reason} ->
+ conn
+ |> put_status(:unprocessable_entity)
+ |> json(%{error: "Failed to delete: #{inspect(reason)}"})
+ end
+ else
+ # Non-owner leaves the conversation
+ case Messaging.remove_member_from_conversation(conversation_id, user.id) do
+ {:ok, _} ->
+ conn
+ |> put_status(:ok)
+ |> json(%{message: "Left conversation"})
+
+ {:error, reason} ->
+ conn
+ |> put_status(:unprocessable_entity)
+ |> json(%{error: "Failed to leave: #{inspect(reason)}"})
+ end
+ end
+ end
+ end)
+ end
+
+ @doc """
+ POST /api/conversations/:id/join
+ Joins a public conversation.
+ """
+ def join(conn, %{"conversation_id" => id}) do
+ user = conn.assigns[:current_user]
+
+ with_valid_id(conn, id, fn conversation_id ->
+ case Messaging.join_conversation(conversation_id, user.id) do
+ {:ok, :pending} ->
+ conn
+ |> put_status(:accepted)
+ |> json(%{message: "Join request sent"})
+
+ {:ok, _member} ->
+ {:ok, conversation} = Messaging.get_conversation!(conversation_id, user.id)
+
+ conn
+ |> put_status(:ok)
+ |> json(%{
+ message: "Joined conversation",
+ conversation: format_conversation(conversation, user.id)
+ })
+
+ {:error, :not_found} ->
+ conn
+ |> put_status(:not_found)
+ |> json(%{error: "Conversation not found"})
+
+ {:error, :not_public} ->
+ conn
+ |> put_status(:forbidden)
+ |> json(%{error: "This conversation is not public"})
+
+ {:error, :not_public_channel} ->
+ conn
+ |> put_status(:forbidden)
+ |> json(%{error: "This conversation is not public"})
+
+ {:error, :must_join_server} ->
+ conn
+ |> put_status(:forbidden)
+ |> json(%{error: "Join the server first before joining this channel"})
+
+ {:error, :already_member} ->
+ conn
+ |> put_status(:unprocessable_entity)
+ |> json(%{error: "Already a member of this conversation"})
+
+ {:error, reason} ->
+ conn
+ |> put_status(:unprocessable_entity)
+ |> json(%{error: "Failed to join: #{inspect(reason)}"})
+ end
+ end)
+ end
+
+ @doc """
+ POST /api/conversations/:id/leave
+ Leaves a conversation.
+ """
+ def leave(conn, %{"conversation_id" => id}) do
+ user = conn.assigns[:current_user]
+
+ with_valid_id(conn, id, fn conversation_id ->
+ case Messaging.remove_member_from_conversation(conversation_id, user.id) do
+ {:ok, _} ->
+ conn
+ |> put_status(:ok)
+ |> json(%{message: "Left conversation"})
+
+ {:error, :not_found} ->
+ conn
+ |> put_status(:not_found)
+ |> json(%{error: "Conversation not found or not a member"})
+
+ {:error, :owner_cannot_leave} ->
+ conn
+ |> put_status(:forbidden)
+ |> json(%{error: "Owners must transfer ownership or delete the conversation"})
+
+ {:error, reason} ->
+ conn
+ |> put_status(:unprocessable_entity)
+ |> json(%{error: "Failed to leave: #{inspect(reason)}"})
+ end
+ end)
+ end
+
+ @doc """
+ POST /api/conversations/:id/read
+ Marks conversation as read.
+ """
+ def mark_read(conn, %{"conversation_id" => id}) do
+ user = conn.assigns[:current_user]
+
+ with_valid_id(conn, id, fn conversation_id ->
+ case Messaging.mark_as_read(conversation_id, user.id) do
+ {:ok, _} ->
+ conn
+ |> put_status(:ok)
+ |> json(%{message: "Marked as read"})
+
+ {:error, reason} ->
+ conn
+ |> put_status(:unprocessable_entity)
+ |> json(%{error: "Failed to mark as read: #{inspect(reason)}"})
+ end
+ end)
+ end
+
+ @doc """
+ GET /api/conversations/:id/members
+ Lists members of a conversation.
+ """
+ def members(conn, %{"conversation_id" => id}) do
+ user = conn.assigns[:current_user]
+
+ with_valid_id(conn, id, fn conversation_id ->
+ # Verify user is a member
+ case Messaging.get_conversation_member(conversation_id, user.id) do
+ nil ->
+ conn
+ |> put_status(:forbidden)
+ |> json(%{error: "Not a member of this conversation"})
+
+ _member ->
+ members = Messaging.get_conversation_members(conversation_id)
+
+ conn
+ |> put_status(:ok)
+ |> json(%{members: Enum.map(members, &format_member/1)})
+ end
+ end)
+ end
+
+ @doc """
+ GET /api/conversations/:id/remote-join-requests
+ Lists pending remote join requests for a locally authoritative channel.
+ """
+ def pending_remote_join_requests(conn, %{"conversation_id" => id}) do
+ user = conn.assigns[:current_user]
+
+ with_valid_id(conn, id, fn conversation_id ->
+ case manage_conversation_membership_permission(conversation_id, user.id) do
+ :ok ->
+ requests = Messaging.list_pending_remote_join_requests(conversation_id)
+
+ conn
+ |> put_status(:ok)
+ |> json(%{requests: Enum.map(requests, &format_remote_join_request/1)})
+
+ {:error, status, error} ->
+ conn
+ |> put_status(status)
+ |> json(%{error: error})
+ end
+ end)
+ end
+
+ @doc """
+ POST /api/conversations/:id/remote-join-requests/approve
+ Approves a pending remote join request.
+ """
+ def approve_remote_join_request(
+ conn,
+ %{"conversation_id" => id, "remote_actor_id" => remote_actor_id}
+ ) do
+ review_remote_join_request(conn, id, remote_actor_id, :approve)
+ end
+
+ @doc """
+ POST /api/conversations/:id/remote-join-requests/decline
+ Declines a pending remote join request.
+ """
+ def decline_remote_join_request(
+ conn,
+ %{"conversation_id" => id, "remote_actor_id" => remote_actor_id}
+ ) do
+ review_remote_join_request(conn, id, remote_actor_id, :decline)
+ end
+
+ @doc """
+ POST /api/conversations/:id/members
+ Adds a member to a conversation.
+ """
+ def add_member(conn, %{"conversation_id" => id, "user_id" => target_user_id}) do
+ user = conn.assigns[:current_user]
+
+ with_valid_id(conn, id, fn conversation_id ->
+ with_valid_id(conn, target_user_id, fn parsed_target_user_id ->
+ # Check if user has permission to add members
+ case Messaging.get_conversation_member(conversation_id, user.id) do
+ nil ->
+ conn
+ |> put_status(:not_found)
+ |> json(%{error: "Conversation not found"})
+
+ member when member.role not in ["owner", "admin", "moderator"] ->
+ conn
+ |> put_status(:forbidden)
+ |> json(%{error: "You don't have permission to add members"})
+
+ _member ->
+ role = "member"
+
+ case Messaging.add_member_to_conversation(
+ conversation_id,
+ parsed_target_user_id,
+ role,
+ user.id
+ ) do
+ {:ok, new_member} ->
+ conn
+ |> put_status(:created)
+ |> json(%{
+ message: "Member added",
+ member: format_member(new_member)
+ })
+
+ {:error, :user_not_found} ->
+ conn
+ |> put_status(:not_found)
+ |> json(%{error: "User not found"})
+
+ {:error, :already_member} ->
+ conn
+ |> put_status(:unprocessable_entity)
+ |> json(%{error: "User is already a member"})
+
+ {:error, :privacy_blocked} ->
+ conn
+ |> put_status(:forbidden)
+ |> json(%{error: "Cannot add user due to privacy settings"})
+
+ {:error, reason} ->
+ conn
+ |> put_status(:unprocessable_entity)
+ |> json(%{error: "Failed to add member: #{inspect(reason)}"})
+ end
+ end
+ end)
+ end)
+ end
+
+ def add_member(conn, _params) do
+ conn
+ |> put_status(:bad_request)
+ |> json(%{error: "Missing required parameter: user_id"})
+ end
+
+ @doc """
+ DELETE /api/conversations/:id/members/:user_id
+ Removes a member from a conversation.
+ """
+ def remove_member(conn, %{"conversation_id" => id, "user_id" => target_user_id}) do
+ user = conn.assigns[:current_user]
+
+ with_valid_id(conn, id, fn conversation_id ->
+ with_valid_id(conn, target_user_id, fn target_id ->
+ # Check if user has permission to remove members
+ case Messaging.get_conversation_member(conversation_id, user.id) do
+ nil ->
+ conn
+ |> put_status(:not_found)
+ |> json(%{error: "Conversation not found"})
+
+ member
+ when member.role not in ["owner", "admin", "moderator"] and user.id != target_id ->
+ conn
+ |> put_status(:forbidden)
+ |> json(%{error: "You don't have permission to remove members"})
+
+ _member ->
+ case Messaging.remove_member_from_conversation(conversation_id, target_id) do
+ {:ok, _} ->
+ conn
+ |> put_status(:ok)
+ |> json(%{message: "Member removed"})
+
+ {:error, :not_found} ->
+ conn
+ |> put_status(:not_found)
+ |> json(%{error: "Member not found"})
+
+ {:error, :owner_cannot_leave} ->
+ conn
+ |> put_status(:forbidden)
+ |> json(%{error: "Cannot remove the owner"})
+
+ {:error, reason} ->
+ conn
+ |> put_status(:unprocessable_entity)
+ |> json(%{error: "Failed to remove member: #{inspect(reason)}"})
+ end
+ end
+ end)
+ end)
+ end
+
+ # Private helpers
+
+ defp review_remote_join_request(conn, conversation_id, remote_actor_id, decision)
+ when decision in [:approve, :decline] do
+ user = conn.assigns[:current_user]
+
+ with {:ok, parsed_conversation_id} <- parse_id(conversation_id),
+ {:ok, parsed_remote_actor_id} <- parse_remote_actor_id(remote_actor_id),
+ :ok <- manage_conversation_membership_permission(parsed_conversation_id, user.id) do
+ review_result =
+ case decision do
+ :approve ->
+ Messaging.approve_remote_join_request(
+ parsed_conversation_id,
+ parsed_remote_actor_id,
+ user.id
+ )
+
+ :decline ->
+ Messaging.decline_remote_join_request(
+ parsed_conversation_id,
+ parsed_remote_actor_id,
+ user.id
+ )
+ end
+
+ case review_result do
+ {:ok, request} ->
+ message =
+ if decision == :approve do
+ "Remote join request approved"
+ else
+ "Remote join request declined"
+ end
+
+ conn
+ |> put_status(:ok)
+ |> json(%{message: message, request: format_remote_join_request(request)})
+
+ {:error, :not_found} ->
+ conn
+ |> put_status(:not_found)
+ |> json(%{error: "Remote join request not found"})
+
+ {:error, reason} ->
+ conn
+ |> put_status(:unprocessable_entity)
+ |> json(%{error: "Failed to review remote join request: #{inspect(reason)}"})
+ end
+ else
+ :error ->
+ invalid_id_response(conn)
+
+ {:error, :invalid_remote_actor_id} ->
+ conn
+ |> put_status(:bad_request)
+ |> json(%{error: "Missing or invalid remote_actor_id"})
+
+ {:error, status, error} ->
+ conn
+ |> put_status(status)
+ |> json(%{error: error})
+ end
+ end
+
+ defp parse_remote_actor_id(remote_actor_id) when is_integer(remote_actor_id),
+ do:
+ if(remote_actor_id > 0,
+ do: {:ok, remote_actor_id},
+ else: {:error, :invalid_remote_actor_id}
+ )
+
+ defp parse_remote_actor_id(remote_actor_id) when is_binary(remote_actor_id) do
+ case Integer.parse(remote_actor_id) do
+ {parsed_remote_actor_id, ""} when parsed_remote_actor_id > 0 ->
+ {:ok, parsed_remote_actor_id}
+
+ _ ->
+ {:error, :invalid_remote_actor_id}
+ end
+ end
+
+ defp parse_remote_actor_id(_remote_actor_id), do: {:error, :invalid_remote_actor_id}
+
+ defp manage_conversation_membership_permission(conversation_id, user_id) do
+ case Messaging.get_conversation_member(conversation_id, user_id) do
+ nil ->
+ {:error, :not_found, "Conversation not found"}
+
+ member when member.role not in ["owner", "admin", "moderator"] ->
+ {:error, :forbidden, "You don't have permission to manage members"}
+
+ _member ->
+ :ok
+ end
+ end
+
+ defp format_conversation(conversation, current_user_id) do
+ # Get unread count if not already loaded
+ unread_count =
+ case Map.get(conversation, :unread_count) do
+ nil -> Messaging.get_conversation_unread_count(conversation.id, current_user_id)
+ count -> count
+ end
+
+ %{
+ id: conversation.id,
+ name: conversation.name,
+ description: conversation.description,
+ type: conversation.type,
+ server_id: Map.get(conversation, :server_id),
+ avatar_url: conversation.avatar_url,
+ is_public: conversation.is_public,
+ member_count: conversation.member_count,
+ hash: conversation.hash,
+ channel_topic: Map.get(conversation, :channel_topic),
+ channel_position: Map.get(conversation, :channel_position, 0),
+ creator_id: conversation.creator_id,
+ last_message_at: conversation.last_message_at,
+ unread_count: unread_count,
+ last_message: format_last_message(conversation),
+ inserted_at: conversation.inserted_at,
+ updated_at: conversation.updated_at
+ }
+ end
+
+ defp format_conversation_with_messages(conversation, current_user_id) do
+ base = format_conversation(conversation, current_user_id)
+
+ members =
+ case Map.get(conversation, :members) do
+ nil -> []
+ members -> Enum.map(members, &format_member/1)
+ end
+
+ # Fetch messages from messages table
+ messages =
+ case Messaging.get_messages(conversation.id, current_user_id, limit: 50) do
+ {:ok, msgs} -> Enum.map(msgs, &format_message/1)
+ {:error, _} -> []
+ end
+
+ conv_data = Map.put(base, :members, members)
+ {conv_data, messages}
+ end
+
+ defp format_last_message(%{last_message: nil}), do: nil
+ defp format_last_message(%{last_message: %Ecto.Association.NotLoaded{}}), do: nil
+ defp format_last_message(%{last_message: message}), do: format_message(message)
+ defp format_last_message(_), do: nil
+
+ defp format_message(message) do
+ %{
+ id: message.id,
+ content: message.content,
+ message_type: message.message_type,
+ media_urls: attachment_urls_for_message(message),
+ sender_id: message.sender_id,
+ sender: format_user(message.sender),
+ reply_to_id: message.reply_to_id,
+ like_count: message.like_count || 0,
+ reply_count: message.reply_count || 0,
+ edited_at: message.edited_at,
+ deleted_at: message.deleted_at,
+ created_at: message.inserted_at
+ }
+ end
+
+ defp attachment_urls_for_message(message) do
+ Enum.map(message.media_urls || [], &Uploads.attachment_url(&1, %{type: "dm"}))
+ end
+
+ defp format_remote_join_request(request) when is_map(request) do
+ %{
+ remote_actor_id: request[:remote_actor_id] || request["remote_actor_id"],
+ actor_uri: request[:actor_uri] || request["actor_uri"],
+ origin_domain: request[:origin_domain] || request["origin_domain"],
+ role: request[:role] || request["role"],
+ state: request[:state] || request["state"],
+ handle: request[:handle] || request["handle"],
+ display_name: request[:display_name] || request["display_name"],
+ display_label: request[:display_label] || request["display_label"],
+ avatar_url: request[:avatar_url] || request["avatar_url"],
+ requested_at: maybe_iso8601(request[:requested_at] || request["requested_at"]),
+ updated_at: maybe_iso8601(request[:updated_at] || request["updated_at"]),
+ metadata: request[:metadata] || request["metadata"] || %{}
+ }
+ end
+
+ defp format_remote_join_request(_request), do: %{}
+
+ defp maybe_iso8601(%DateTime{} = datetime), do: DateTime.to_iso8601(datetime)
+ defp maybe_iso8601(value), do: value
+
+ @doc """
+ POST /api/conversations/:conversation_id/upload
+ Uploads a media file for a conversation.
+ """
+ def upload_media(conn, %{"conversation_id" => conversation_id, "file" => upload}) do
+ user = conn.assigns[:current_user]
+
+ with_valid_id(conn, conversation_id, fn conv_id ->
+ # Verify user is a member
+ case Messaging.get_conversation_member(conv_id, user.id) do
+ nil ->
+ conn
+ |> put_status(:forbidden)
+ |> json(%{error: "Not a member of this conversation"})
+
+ _member ->
+ case Elektrine.Uploads.upload_chat_attachment(upload, user.id) do
+ {:ok, metadata} ->
+ url = Elektrine.Uploads.attachment_url(metadata.key, %{type: "dm"})
+
+ conn
+ |> put_status(:created)
+ |> json(%{
+ url: url,
+ key: metadata.key,
+ filename: metadata.filename,
+ content_type: metadata.content_type,
+ size: metadata.size
+ })
+
+ {:error, {:file_too_large, msg}} ->
+ conn
+ |> put_status(:request_entity_too_large)
+ |> json(%{error: msg})
+
+ {:error, {:invalid_file_type, msg}} ->
+ conn
+ |> put_status(:unsupported_media_type)
+ |> json(%{error: msg})
+
+ {:error, reason} ->
+ conn
+ |> put_status(:unprocessable_entity)
+ |> json(%{error: "Upload failed: #{inspect(reason)}"})
+ end
+ end
+ end)
+ end
+
+ def upload_media(conn, %{"conversation_id" => _conversation_id}) do
+ conn
+ |> put_status(:bad_request)
+ |> json(%{error: "Missing file parameter"})
+ end
+
+ defp format_member(member) do
+ %{
+ id: member.id,
+ user_id: member.user_id,
+ role: member.role,
+ joined_at: member.joined_at,
+ last_read_at: member.last_read_at,
+ user: format_user(member.user)
+ }
+ end
+
+ defp format_user(nil), do: nil
+ defp format_user(%Ecto.Association.NotLoaded{}), do: nil
+
+ defp format_user(user) do
+ %{
+ id: user.id,
+ username: user.username,
+ avatar: user.avatar
+ }
+ end
+
+ defp format_errors(changeset) do
+ Ecto.Changeset.traverse_errors(changeset, fn {msg, opts} ->
+ Enum.reduce(opts, msg, fn {key, value}, acc ->
+ String.replace(acc, "%{#{key}}", to_string(value))
+ end)
+ end)
+ end
+
+ defp maybe_put(map, _key, nil), do: map
+ defp maybe_put(map, key, value), do: Map.put(map, key, value)
+
+ defp with_valid_id(conn, value, fun) when is_function(fun, 1) do
+ case parse_id(value) do
+ {:ok, id} -> fun.(id)
+ :error -> invalid_id_response(conn)
+ end
+ end
+
+ defp invalid_id_response(conn) do
+ conn
+ |> put_status(:bad_request)
+ |> json(%{error: "Invalid id"})
+ end
+
+ defp parse_id(value) when is_integer(value) and value > 0, do: {:ok, value}
+
+ defp parse_id(value) when is_binary(value) do
+ case Integer.parse(value) do
+ {int, ""} when int > 0 -> {:ok, int}
+ _ -> :error
+ end
+ end
+
+ defp parse_id(_value), do: :error
+
+ defp parse_int(nil, default), do: default
+ defp parse_int(value, _default) when is_integer(value), do: value
+
+ defp parse_int(value, default) when is_binary(value) do
+ case Integer.parse(value) do
+ {int, ""} -> int
+ _ -> default
+ end
+ end
+end
diff --git a/apps/arblarg/lib/arblarg_web/controllers/api/ext_chat_controller.ex b/apps/arblarg/lib/arblarg_web/controllers/api/ext_chat_controller.ex
new file mode 100644
index 0000000..14453b6
--- /dev/null
+++ b/apps/arblarg/lib/arblarg_web/controllers/api/ext_chat_controller.ex
@@ -0,0 +1,423 @@
+defmodule ArblargWeb.API.ExtChatController do
+ @moduledoc """
+ External API controller for chat access.
+ """
+
+ use ArblargWeb, :controller
+
+ import Ecto.Query, warn: false
+
+ alias Elektrine.Accounts
+ alias Elektrine.Messaging
+ alias Elektrine.Messaging.{ChatConversation, ChatConversationMember}
+ alias Elektrine.Messaging.ChatMessages
+ alias Elektrine.Repo
+ alias Elektrine.Social.Conversation
+ alias Elektrine.Uploads
+ alias ElektrineWeb.API.Response
+
+ @chat_types ~w(dm group channel)
+ @default_limit 20
+ @max_limit 100
+ @default_message_limit 50
+
+ @doc """
+ GET /api/ext/v1/chat/conversations
+ """
+ def index(conn, params) do
+ user = conn.assigns.current_user
+ limit = parse_positive_int(params["limit"], @default_limit) |> min(@max_limit)
+
+ conversations = list_chat_conversations(user.id, limit)
+
+ Response.ok(
+ conn,
+ %{conversations: Enum.map(conversations, &format_conversation(&1, user.id))},
+ %{pagination: %{limit: limit, total_count: length(conversations)}}
+ )
+ end
+
+ @doc """
+ GET /api/ext/v1/chat/conversations/:id
+ """
+ def show(conn, %{"id" => id}) do
+ user = conn.assigns.current_user
+
+ with {:ok, conversation_id} <- parse_id(id),
+ {:ok, conversation} <- fetch_chat_conversation(conversation_id, user.id) do
+ page =
+ ChatMessages.get_conversation_messages(conversation.id, user.id,
+ limit: @default_message_limit
+ )
+
+ Response.ok(
+ conn,
+ %{
+ conversation:
+ format_conversation(conversation, user.id, latest_message(conversation, user.id)),
+ messages: Enum.map(page.messages, &format_message/1)
+ },
+ %{pagination: pagination_meta(page, @default_message_limit)}
+ )
+ else
+ {:error, :invalid_id} ->
+ Response.error(conn, :bad_request, "invalid_id", "Invalid conversation id")
+
+ {:error, :not_found} ->
+ Response.error(conn, :not_found, "not_found", "Conversation not found")
+ end
+ end
+
+ @doc """
+ GET /api/ext/v1/chat/conversations/:id/messages
+ """
+ def messages(conn, %{"id" => id} = params) do
+ user = conn.assigns.current_user
+ limit = parse_positive_int(params["limit"], @default_message_limit) |> min(@max_limit)
+
+ with {:ok, conversation_id} <- parse_id(id),
+ {:ok, before_id} <- parse_optional_id(params["before_id"]),
+ {:ok, after_id} <- parse_optional_id(params["after_id"]),
+ {:ok, conversation} <- fetch_chat_conversation(conversation_id, user.id) do
+ page =
+ ChatMessages.get_conversation_messages(conversation.id, user.id,
+ limit: limit,
+ before_id: before_id,
+ after_id: after_id
+ )
+
+ Response.ok(
+ conn,
+ %{messages: Enum.map(page.messages, &format_message/1)},
+ %{pagination: pagination_meta(page, limit)}
+ )
+ else
+ {:error, :invalid_id} ->
+ Response.error(conn, :bad_request, "invalid_id", "Invalid conversation or message id")
+
+ {:error, :not_found} ->
+ Response.error(conn, :not_found, "not_found", "Conversation not found")
+ end
+ end
+
+ @doc """
+ POST /api/ext/v1/chat/conversations/:id/messages
+ """
+ def create(conn, %{"id" => id} = params) do
+ user = conn.assigns.current_user
+ source = Map.get(params, "message", params)
+ message_type = source["message_type"] || "text"
+
+ with {:ok, conversation_id} <- parse_id(id),
+ {:ok, conversation} <- fetch_chat_conversation(conversation_id, user.id),
+ {:ok, reply_to_id} <- parse_optional_id(source["reply_to_id"]) do
+ result =
+ case message_type do
+ "text" ->
+ opts = if is_nil(reply_to_id), do: [], else: [reply_to_id: reply_to_id]
+
+ Messaging.create_chat_text_message(
+ conversation.id,
+ user.id,
+ source["content"] || "",
+ opts
+ )
+
+ "image" ->
+ Messaging.create_chat_media_message(
+ conversation.id,
+ user.id,
+ normalize_media_urls(source["media_urls"]),
+ source["content"],
+ source["media_metadata"] || %{}
+ )
+
+ "file" ->
+ Messaging.create_chat_media_message(
+ conversation.id,
+ user.id,
+ normalize_media_urls(source["media_urls"]),
+ source["content"],
+ source["media_metadata"] || %{}
+ )
+
+ "voice" ->
+ Messaging.create_chat_voice_message(
+ conversation.id,
+ user.id,
+ List.first(normalize_media_urls(source["media_urls"])),
+ source["duration"] || 0,
+ source["mime_type"] || "audio/webm"
+ )
+
+ _ ->
+ {:error, :invalid_message_type}
+ end
+
+ case result do
+ {:ok, message} ->
+ Response.created(conn, %{message: format_message(message)})
+
+ {:error, :empty_message} ->
+ Response.error(
+ conn,
+ :unprocessable_entity,
+ "empty_message",
+ "Message content cannot be empty"
+ )
+
+ {:error, :rate_limited} ->
+ Response.error(conn, :too_many_requests, "rate_limited", "Message rate limit exceeded")
+
+ {:error, :timed_out} ->
+ Response.error(
+ conn,
+ :forbidden,
+ "timed_out",
+ "User is timed out from this conversation"
+ )
+
+ {:error, :invalid_message_type} ->
+ Response.error(
+ conn,
+ :bad_request,
+ "invalid_message_type",
+ "Invalid message type. Use: text, image, file, or voice"
+ )
+
+ {:error, %Ecto.Changeset{} = changeset} ->
+ Response.error(
+ conn,
+ :unprocessable_entity,
+ "validation_failed",
+ "Message failed validation",
+ errors_on(changeset)
+ )
+
+ {:error, reason} ->
+ Response.error(
+ conn,
+ :unprocessable_entity,
+ "message_send_failed",
+ "Failed to send message",
+ inspect(reason)
+ )
+ end
+ else
+ {:error, :invalid_id} ->
+ Response.error(conn, :bad_request, "invalid_id", "Invalid conversation or message id")
+
+ {:error, :not_found} ->
+ Response.error(conn, :not_found, "not_found", "Conversation not found")
+ end
+ end
+
+ defp list_chat_conversations(user_id, limit) do
+ from(conversation in ChatConversation,
+ join: membership in ChatConversationMember,
+ on: membership.conversation_id == conversation.id and membership.user_id == ^user_id,
+ where: is_nil(membership.left_at) and conversation.type in ^@chat_types,
+ order_by: [
+ desc: membership.pinned,
+ desc: conversation.last_message_at,
+ desc: conversation.updated_at
+ ],
+ limit: ^limit,
+ preload: [members: [user: [:profile]]]
+ )
+ |> Repo.all()
+ |> Enum.reject(&blocked_dm?(&1, user_id))
+ end
+
+ defp fetch_chat_conversation(conversation_id, user_id) do
+ case Messaging.get_conversation_for_chat!(conversation_id, user_id) do
+ {:ok, %ChatConversation{type: type} = conversation} when type in @chat_types ->
+ if blocked_dm?(conversation, user_id) do
+ {:error, :not_found}
+ else
+ {:ok, conversation}
+ end
+
+ {:ok, _conversation} ->
+ {:error, :not_found}
+
+ {:error, :not_found} ->
+ {:error, :not_found}
+ end
+ end
+
+ defp latest_message(conversation, user_id) do
+ conversation.id
+ |> ChatMessages.get_messages(user_id: user_id, limit: 1)
+ |> List.first()
+ end
+
+ defp blocked_dm?(%ChatConversation{type: "dm", members: members}, user_id)
+ when is_list(members) do
+ case Enum.find(members, fn member ->
+ member.user_id != user_id and is_nil(member.left_at)
+ end) do
+ %{user_id: other_user_id} ->
+ Accounts.user_blocked?(user_id, other_user_id) or
+ Accounts.user_blocked?(other_user_id, user_id)
+
+ _ ->
+ false
+ end
+ end
+
+ defp blocked_dm?(_conversation, _user_id), do: false
+
+ defp format_conversation(conversation, current_user_id) do
+ format_conversation(
+ conversation,
+ current_user_id,
+ latest_message(conversation, current_user_id)
+ )
+ end
+
+ defp format_conversation(conversation, current_user_id, latest_message) do
+ members =
+ conversation.members
+ |> List.wrap()
+ |> Enum.filter(&is_nil(&1.left_at))
+ |> Enum.map(&format_member/1)
+
+ %{
+ id: conversation.id,
+ type: conversation.type,
+ name: Conversation.display_name(conversation, current_user_id),
+ description: conversation.description,
+ avatar_url: Conversation.avatar_url(conversation, current_user_id),
+ is_public: conversation.is_public,
+ member_count: length(members),
+ last_message_at: conversation.last_message_at,
+ latest_message: format_message_preview(latest_message),
+ members: members
+ }
+ end
+
+ defp format_member(member) do
+ %{
+ id: member.id,
+ user_id: member.user_id,
+ role: member.role,
+ joined_at: member.joined_at,
+ last_read_at: member.last_read_at,
+ user: format_user(member.user)
+ }
+ end
+
+ defp format_message_preview(nil), do: nil
+
+ defp format_message_preview(message) do
+ %{
+ id: message.id,
+ content: message.content,
+ message_type: message.message_type,
+ sender: format_user(message.sender),
+ inserted_at: message.inserted_at,
+ edited_at: message.edited_at
+ }
+ end
+
+ defp format_message(message) do
+ %{
+ id: message.id,
+ content: message.content,
+ message_type: message.message_type,
+ media_urls: attachment_urls_for_message(message),
+ media_metadata: message.media_metadata || %{},
+ conversation_id: message.conversation_id,
+ sender_id: message.sender_id,
+ sender: format_user(message.sender),
+ reply_to_id: message.reply_to_id,
+ reply_to: format_reply_to(message.reply_to),
+ edited_at: message.edited_at,
+ deleted_at: message.deleted_at,
+ inserted_at: message.inserted_at
+ }
+ end
+
+ defp attachment_urls_for_message(message) do
+ Enum.map(message.media_urls || [], &Uploads.attachment_url(&1, %{type: "dm"}))
+ end
+
+ defp format_reply_to(nil), do: nil
+ defp format_reply_to(%Ecto.Association.NotLoaded{}), do: nil
+
+ defp format_reply_to(message) do
+ %{
+ id: message.id,
+ content: message.content,
+ sender: format_user(message.sender)
+ }
+ end
+
+ defp format_user(nil), do: nil
+ defp format_user(%Ecto.Association.NotLoaded{}), do: nil
+
+ defp format_user(user) do
+ %{
+ id: user.id,
+ username: user.username,
+ handle: user.handle,
+ display_name: user.display_name,
+ avatar: user.avatar
+ }
+ end
+
+ defp pagination_meta(page, limit) do
+ %{
+ limit: limit,
+ has_more_older: page.has_more_older,
+ has_more_newer: page.has_more_newer,
+ oldest_id: page.oldest_id,
+ newest_id: page.newest_id
+ }
+ end
+
+ defp parse_id(value) when is_integer(value) and value > 0, do: {:ok, value}
+
+ defp parse_id(value) when is_binary(value) do
+ case Integer.parse(value) do
+ {int, ""} when int > 0 -> {:ok, int}
+ _ -> {:error, :invalid_id}
+ end
+ end
+
+ defp parse_id(_value), do: {:error, :invalid_id}
+
+ defp parse_optional_id(nil), do: {:ok, nil}
+ defp parse_optional_id(""), do: {:ok, nil}
+
+ defp parse_optional_id(value) do
+ parse_id(value)
+ end
+
+ defp parse_positive_int(value, _default) when is_integer(value) and value > 0, do: value
+
+ defp parse_positive_int(value, default) when is_binary(value) do
+ case Integer.parse(value) do
+ {int, ""} when int > 0 -> int
+ _ -> default
+ end
+ end
+
+ defp parse_positive_int(_value, default), do: default
+
+ defp normalize_media_urls(urls) when is_list(urls), do: urls
+ defp normalize_media_urls(_urls), do: []
+
+ defp errors_on(changeset) do
+ Ecto.Changeset.traverse_errors(changeset, fn {message, opts} ->
+ interpolate_error(message, opts)
+ end)
+ end
+
+ defp interpolate_error(message, opts) do
+ Enum.reduce(opts, message, fn {key, value}, acc ->
+ String.replace(acc, "%{#{key}}", to_string(value))
+ end)
+ end
+end
diff --git a/apps/arblarg/lib/arblarg_web/controllers/api/message_controller.ex b/apps/arblarg/lib/arblarg_web/controllers/api/message_controller.ex
new file mode 100644
index 0000000..b2ed9de
--- /dev/null
+++ b/apps/arblarg/lib/arblarg_web/controllers/api/message_controller.ex
@@ -0,0 +1,473 @@
+defmodule ArblargWeb.API.MessageController do
+ @moduledoc """
+ API controller for chat messages.
+
+ Uses the new ChatMessages module for DM/group/channel messages,
+ separate from timeline posts which remain in the Messages module.
+ """
+ use ArblargWeb, :controller
+
+ alias Elektrine.Messaging
+ alias Elektrine.Uploads
+
+ action_fallback ElektrineWeb.FallbackController
+
+ @doc """
+ GET /api/conversations/:conversation_id/messages
+ Lists messages for a conversation with pagination.
+
+ Query params:
+ - before_id: Get messages before this ID (for loading older)
+ - after_id: Get messages after this ID (for loading newer)
+ - limit: Number of messages to return (default 50, max 100)
+ """
+ def index(conn, %{"conversation_id" => conversation_id} = params) do
+ user = conn.assigns[:current_user]
+
+ with_valid_id(conn, conversation_id, fn conv_id ->
+ # Verify user is a member
+ case Messaging.get_conversation_member(conv_id, user.id) do
+ nil ->
+ conn
+ |> put_status(:forbidden)
+ |> json(%{error: "Not a member of this conversation"})
+
+ _member ->
+ opts =
+ [
+ limit: min(parse_int(params["limit"], 50), 100),
+ before_id: parse_int(params["before_id"], nil)
+ ]
+ |> Enum.reject(fn {_k, v} -> is_nil(v) end)
+
+ case Messaging.get_messages(conv_id, user.id, opts) do
+ {:ok, messages} ->
+ conn
+ |> put_status(:ok)
+ |> json(%{
+ messages: Enum.map(messages, &format_message/1),
+ has_more: length(messages) >= Keyword.get(opts, :limit, 50)
+ })
+
+ {:error, _reason} ->
+ conn
+ |> put_status(:forbidden)
+ |> json(%{error: "Unable to load messages"})
+ end
+ end
+ end)
+ end
+
+ @doc """
+ POST /api/conversations/:conversation_id/messages
+ Sends a new message in a conversation.
+
+ Params:
+ - content: Message text (required)
+ - reply_to_id: ID of message being replied to (optional)
+ - message_type: "text" | "image" | "file" (default: "text")
+ - media_urls: Array of media URLs (for image/file messages)
+ """
+ def create(conn, %{"conversation_id" => conversation_id} = params) do
+ user = conn.assigns[:current_user]
+
+ with_valid_id(conn, conversation_id, fn conv_id ->
+ # Verify user is a member
+ case Messaging.get_conversation_member(conv_id, user.id) do
+ nil ->
+ conn
+ |> put_status(:forbidden)
+ |> json(%{error: "Not a member of this conversation"})
+
+ member when member.role == "readonly" ->
+ conn
+ |> put_status(:forbidden)
+ |> json(%{error: "You don't have permission to send messages"})
+
+ _member ->
+ content = params["content"] || ""
+ reply_to_id = parse_int(params["reply_to_id"], nil)
+ message_type = params["message_type"] || "text"
+
+ result =
+ case message_type do
+ "text" ->
+ opts = if reply_to_id, do: [reply_to_id: reply_to_id], else: []
+ Messaging.create_chat_text_message(conv_id, user.id, content, opts)
+
+ "image" ->
+ media_urls = params["media_urls"] || []
+ media_metadata = params["media_metadata"] || %{}
+
+ Messaging.create_chat_media_message(
+ conv_id,
+ user.id,
+ media_urls,
+ content,
+ media_metadata
+ )
+
+ "file" ->
+ media_urls = params["media_urls"] || []
+ media_metadata = params["media_metadata"] || %{}
+
+ Messaging.create_chat_media_message(
+ conv_id,
+ user.id,
+ media_urls,
+ content,
+ media_metadata
+ )
+
+ "voice" ->
+ audio_url = List.first(params["media_urls"] || [])
+ duration = params["duration"] || 0
+ mime_type = params["mime_type"] || "audio/webm"
+
+ Messaging.create_chat_voice_message(
+ conv_id,
+ user.id,
+ audio_url,
+ duration,
+ mime_type
+ )
+
+ _ ->
+ {:error, :invalid_message_type}
+ end
+
+ case result do
+ {:ok, message} ->
+ conn
+ |> put_status(:created)
+ |> json(%{
+ message: format_message(message)
+ })
+
+ {:error, :empty_message} ->
+ conn
+ |> put_status(:unprocessable_entity)
+ |> json(%{error: "Message content cannot be empty"})
+
+ {:error, :rate_limited} ->
+ conn
+ |> put_status(:too_many_requests)
+ |> json(%{error: "You're sending messages too quickly. Please slow down."})
+
+ {:error, :timed_out} ->
+ conn
+ |> put_status(:forbidden)
+ |> json(%{error: "You are currently timed out from this conversation"})
+
+ {:error, :invalid_message_type} ->
+ conn
+ |> put_status(:bad_request)
+ |> json(%{error: "Invalid message type. Use: text, image, file, or voice"})
+
+ {:error, %Ecto.Changeset{} = changeset} ->
+ conn
+ |> put_status(:unprocessable_entity)
+ |> json(%{error: "Validation failed", errors: format_errors(changeset)})
+
+ {:error, reason} ->
+ conn
+ |> put_status(:unprocessable_entity)
+ |> json(%{error: "Failed to send message: #{inspect(reason)}"})
+ end
+ end
+ end)
+ end
+
+ @doc """
+ PUT /api/messages/:id
+ Edits a message.
+
+ Params:
+ - content: New message content (required)
+ """
+ def update(conn, %{"id" => id, "content" => new_content}) do
+ user = conn.assigns[:current_user]
+
+ with_valid_id(conn, id, fn message_id ->
+ case Messaging.edit_chat_message(message_id, user.id, new_content) do
+ {:ok, message} ->
+ conn
+ |> put_status(:ok)
+ |> json(%{
+ message: format_message(message)
+ })
+
+ {:error, :not_found} ->
+ conn
+ |> put_status(:not_found)
+ |> json(%{error: "Message not found"})
+
+ {:error, :unauthorized} ->
+ conn
+ |> put_status(:forbidden)
+ |> json(%{error: "You cannot edit this message"})
+
+ {:error, reason} ->
+ conn
+ |> put_status(:unprocessable_entity)
+ |> json(%{error: "Failed to edit message: #{inspect(reason)}"})
+ end
+ end)
+ end
+
+ def update(conn, %{"id" => _id}) do
+ conn
+ |> put_status(:bad_request)
+ |> json(%{error: "Missing required parameter: content"})
+ end
+
+ @doc """
+ DELETE /api/messages/:id
+ Deletes a message (soft delete).
+ """
+ def delete(conn, %{"id" => id}) do
+ user = conn.assigns[:current_user]
+
+ with_valid_id(conn, id, fn message_id ->
+ # Check if user is admin (for admin delete)
+ is_admin = user.admin == true
+
+ case Messaging.delete_chat_message(message_id, user.id, is_admin) do
+ {:ok, _message} ->
+ conn
+ |> put_status(:ok)
+ |> json(%{message: "Message deleted"})
+
+ {:error, :not_found} ->
+ conn
+ |> put_status(:not_found)
+ |> json(%{error: "Message not found"})
+
+ {:error, :unauthorized} ->
+ conn
+ |> put_status(:forbidden)
+ |> json(%{error: "You can only delete your own messages"})
+
+ {:error, reason} ->
+ conn
+ |> put_status(:unprocessable_entity)
+ |> json(%{error: "Failed to delete message: #{inspect(reason)}"})
+ end
+ end)
+ end
+
+ @doc """
+ POST /api/messages/:id/reactions
+ Adds a reaction to a message.
+
+ Params:
+ - emoji: The emoji to react with (required)
+ """
+ def add_reaction(conn, %{"message_id" => id, "emoji" => emoji}) do
+ user = conn.assigns[:current_user]
+
+ with_valid_id(conn, id, fn message_id ->
+ case Messaging.add_chat_reaction(message_id, user.id, emoji) do
+ {:ok, reaction} ->
+ conn
+ |> put_status(:created)
+ |> json(%{
+ message: "Reaction added",
+ reaction: format_reaction(reaction)
+ })
+
+ {:error, :not_found} ->
+ conn
+ |> put_status(:not_found)
+ |> json(%{error: "Message not found"})
+
+ {:error, :rate_limited} ->
+ conn
+ |> put_status(:too_many_requests)
+ |> json(%{error: "You're adding reactions too quickly"})
+
+ {:error, :already_exists} ->
+ # Toggle behavior - remove the reaction
+ case Messaging.remove_chat_reaction(message_id, user.id, emoji) do
+ {:ok, _} ->
+ conn
+ |> put_status(:ok)
+ |> json(%{message: "Reaction removed"})
+
+ {:error, _} ->
+ conn
+ |> put_status(:unprocessable_entity)
+ |> json(%{error: "Failed to toggle reaction"})
+ end
+
+ {:error, reason} ->
+ conn
+ |> put_status(:unprocessable_entity)
+ |> json(%{error: "Failed to add reaction: #{inspect(reason)}"})
+ end
+ end)
+ end
+
+ def add_reaction(conn, %{"message_id" => _id}) do
+ conn
+ |> put_status(:bad_request)
+ |> json(%{error: "Missing required parameter: emoji"})
+ end
+
+ @doc """
+ DELETE /api/messages/:id/reactions/:emoji
+ Removes a reaction from a message.
+ """
+ def remove_reaction(conn, %{"message_id" => id, "emoji" => emoji}) do
+ user = conn.assigns[:current_user]
+
+ with_valid_id(conn, id, fn message_id ->
+ # URL decode emoji (emojis may be encoded)
+ decoded_emoji = URI.decode(emoji)
+
+ case Messaging.remove_chat_reaction(message_id, user.id, decoded_emoji) do
+ {:ok, _} ->
+ conn
+ |> put_status(:ok)
+ |> json(%{message: "Reaction removed"})
+
+ {:error, :not_found} ->
+ conn
+ |> put_status(:not_found)
+ |> json(%{error: "Reaction not found"})
+
+ {:error, reason} ->
+ conn
+ |> put_status(:unprocessable_entity)
+ |> json(%{error: "Failed to remove reaction: #{inspect(reason)}"})
+ end
+ end)
+ end
+
+ # Private helpers
+ defp with_valid_id(conn, value, fun) when is_function(fun, 1) do
+ case parse_id(value) do
+ {:ok, id} -> fun.(id)
+ :error -> invalid_id_response(conn)
+ end
+ end
+
+ defp invalid_id_response(conn) do
+ conn
+ |> put_status(:bad_request)
+ |> json(%{error: "Invalid id"})
+ end
+
+ defp parse_id(value) when is_integer(value) and value > 0, do: {:ok, value}
+
+ defp parse_id(value) when is_binary(value) do
+ case Integer.parse(value) do
+ {int, ""} when int > 0 -> {:ok, int}
+ _ -> :error
+ end
+ end
+
+ defp parse_id(_value), do: :error
+
+ defp format_message(message) do
+ %{
+ id: message.id,
+ content: message.content,
+ message_type: message.message_type,
+ media_urls: attachment_urls_for_message(message),
+ media_metadata: message.media_metadata || %{},
+ conversation_id: message.conversation_id,
+ sender_id: message.sender_id,
+ sender: format_user(message.sender),
+ webhook_id: Map.get(message, :webhook_id),
+ reply_to_id: message.reply_to_id,
+ reply_to: format_reply_to(message.reply_to),
+ like_count: Map.get(message, :like_count, 0) || 0,
+ reply_count: Map.get(message, :reply_count, 0) || 0,
+ reactions: format_reactions(message.reactions),
+ edited_at: message.edited_at,
+ deleted_at: message.deleted_at,
+ created_at: message.inserted_at
+ }
+ end
+
+ defp attachment_urls_for_message(message) do
+ Enum.map(message.media_urls || [], &Uploads.attachment_url(&1, %{type: "dm"}))
+ end
+
+ defp format_reply_to(nil), do: nil
+ defp format_reply_to(%Ecto.Association.NotLoaded{}), do: nil
+
+ defp format_reply_to(message) do
+ %{
+ id: message.id,
+ content: message.content,
+ sender_id: message.sender_id,
+ sender: format_user(message.sender)
+ }
+ end
+
+ defp format_reactions(nil), do: []
+ defp format_reactions(%Ecto.Association.NotLoaded{}), do: []
+
+ defp format_reactions(reactions) when is_list(reactions) do
+ # Return individual reactions for the iOS client
+ Enum.map(reactions, fn reaction ->
+ # Handle both ChatMessageReaction (chat_message_id) and MessageReaction (message_id)
+ message_id =
+ cond do
+ Map.has_key?(reaction, :chat_message_id) -> reaction.chat_message_id
+ Map.has_key?(reaction, :message_id) -> reaction.message_id
+ true -> nil
+ end
+
+ %{
+ id: reaction.id,
+ emoji: reaction.emoji,
+ user_id: reaction.user_id,
+ message_id: message_id
+ }
+ end)
+ end
+
+ defp format_reaction(reaction) do
+ %{
+ id: reaction.id,
+ message_id: reaction.message_id,
+ user_id: reaction.user_id,
+ emoji: reaction.emoji,
+ created_at: reaction.inserted_at
+ }
+ end
+
+ defp format_user(nil), do: nil
+ defp format_user(%Ecto.Association.NotLoaded{}), do: nil
+
+ defp format_user(user) do
+ %{
+ id: user.id,
+ username: user.username,
+ avatar: user.avatar,
+ is_bot: Map.get(user, :is_bot, false) == true,
+ webhook: Map.get(user, :webhook, false) == true
+ }
+ end
+
+ defp format_errors(changeset) do
+ Ecto.Changeset.traverse_errors(changeset, fn {msg, opts} ->
+ Enum.reduce(opts, msg, fn {key, value}, acc ->
+ String.replace(acc, "%{#{key}}", to_string(value))
+ end)
+ end)
+ end
+
+ defp parse_int(nil, default), do: default
+ defp parse_int(value, _default) when is_integer(value), do: value
+
+ defp parse_int(value, default) when is_binary(value) do
+ case Integer.parse(value) do
+ {int, ""} -> int
+ _ -> default
+ end
+ end
+end
diff --git a/apps/arblarg/lib/arblarg_web/controllers/api/server_controller.ex b/apps/arblarg/lib/arblarg_web/controllers/api/server_controller.ex
new file mode 100644
index 0000000..45cbb64
--- /dev/null
+++ b/apps/arblarg/lib/arblarg_web/controllers/api/server_controller.ex
@@ -0,0 +1,301 @@
+defmodule ArblargWeb.API.ServerController do
+ @moduledoc """
+ API controller for community servers and server channels.
+ """
+ use ArblargWeb, :controller
+
+ alias Elektrine.Messaging
+
+ action_fallback ElektrineWeb.FallbackController
+
+ @doc """
+ GET /api/servers
+ Lists all servers for the current user. Supports `scope=discover` (or `discover=true`) for joinable public server discovery.
+ """
+ def index(conn, params) do
+ user = conn.assigns[:current_user]
+ limit = parse_int(params["limit"], 50)
+ query = params["query"]
+
+ servers =
+ if discover_scope?(params) do
+ Messaging.list_public_servers(user.id, limit: limit, query: query)
+ else
+ Messaging.list_servers(user.id, limit: limit)
+ end
+
+ conn
+ |> put_status(:ok)
+ |> json(%{servers: Enum.map(servers, &format_server_summary(&1, user.id))})
+ end
+
+ @doc """
+ GET /api/servers/:id
+ Shows a server with its channels.
+ """
+ def show(conn, %{"id" => id}) do
+ user = conn.assigns[:current_user]
+
+ with_valid_id(conn, id, fn server_id ->
+ case Messaging.get_server(server_id, user.id) do
+ {:ok, server} ->
+ conn
+ |> put_status(:ok)
+ |> json(%{
+ server: format_server_summary(server, user.id),
+ channels: Enum.map(server.channels, &format_channel/1)
+ })
+
+ {:error, :not_found} ->
+ conn
+ |> put_status(:not_found)
+ |> json(%{error: "Server not found"})
+ end
+ end)
+ end
+
+ @doc """
+ POST /api/servers
+ Creates a new server and a default #general channel.
+ """
+ def create(conn, params) do
+ user = conn.assigns[:current_user]
+
+ attrs = %{
+ name: params["name"],
+ description: params["description"],
+ icon_url: params["icon_url"],
+ is_public: parse_bool(params["is_public"], false)
+ }
+
+ case Messaging.create_server(user.id, attrs) do
+ {:ok, server} ->
+ conn
+ |> put_status(:created)
+ |> json(%{
+ message: "Server created",
+ server: format_server_summary(server, user.id),
+ channels: Enum.map(server.channels, &format_channel/1)
+ })
+
+ {:error, %Ecto.Changeset{} = changeset} ->
+ conn
+ |> put_status(:unprocessable_entity)
+ |> json(%{error: "Validation failed", errors: format_errors(changeset)})
+
+ {:error, :already_member} ->
+ conn
+ |> put_status(:unprocessable_entity)
+ |> json(%{error: "Failed to initialize server membership"})
+
+ {:error, reason} ->
+ conn
+ |> put_status(:unprocessable_entity)
+ |> json(%{error: "Failed to create server: #{inspect(reason)}"})
+ end
+ end
+
+ @doc """
+ POST /api/servers/:server_id/join
+ Joins a public server.
+ """
+ def join(conn, %{"server_id" => server_id}) do
+ user = conn.assigns[:current_user]
+
+ with_valid_id(conn, server_id, fn parsed_server_id ->
+ case Messaging.join_server(parsed_server_id, user.id) do
+ {:ok, _member} ->
+ case Messaging.get_server(parsed_server_id, user.id) do
+ {:ok, server} ->
+ conn
+ |> put_status(:ok)
+ |> json(%{
+ message: "Joined server",
+ server: format_server_summary(server, user.id),
+ channels: Enum.map(server.channels, &format_channel/1)
+ })
+
+ {:error, :not_found} ->
+ conn
+ |> put_status(:not_found)
+ |> json(%{error: "Server not found"})
+ end
+
+ {:error, :not_found} ->
+ conn
+ |> put_status(:not_found)
+ |> json(%{error: "Server not found"})
+
+ {:error, :not_public} ->
+ conn
+ |> put_status(:forbidden)
+ |> json(%{error: "This server is private"})
+
+ {:error, :already_member} ->
+ conn
+ |> put_status(:unprocessable_entity)
+ |> json(%{error: "Already a member of this server"})
+
+ {:error, :federation_sync_unavailable} ->
+ conn
+ |> put_status(:service_unavailable)
+ |> json(%{error: "This federated server is temporarily unavailable to join"})
+
+ {:error, reason} ->
+ conn
+ |> put_status(:unprocessable_entity)
+ |> json(%{error: "Failed to join server: #{inspect(reason)}"})
+ end
+ end)
+ end
+
+ @doc """
+ POST /api/servers/:server_id/channels
+ Creates a new channel in the server.
+ """
+ def create_channel(conn, %{"server_id" => server_id} = params) do
+ user = conn.assigns[:current_user]
+
+ attrs = %{
+ name: params["name"],
+ description: params["description"],
+ channel_topic: params["channel_topic"],
+ channel_position: parse_int(params["channel_position"], nil)
+ }
+
+ with_valid_id(conn, server_id, fn parsed_server_id ->
+ case Messaging.create_server_channel(parsed_server_id, user.id, attrs) do
+ {:ok, channel} ->
+ conn
+ |> put_status(:created)
+ |> json(%{
+ message: "Channel created",
+ channel: format_channel(channel)
+ })
+
+ {:error, :not_found} ->
+ conn
+ |> put_status(:not_found)
+ |> json(%{error: "Server not found"})
+
+ {:error, :unauthorized} ->
+ conn
+ |> put_status(:forbidden)
+ |> json(%{error: "You don't have permission to create channels in this server"})
+
+ {:error, %Ecto.Changeset{} = changeset} ->
+ conn
+ |> put_status(:unprocessable_entity)
+ |> json(%{error: "Validation failed", errors: format_errors(changeset)})
+
+ {:error, reason} ->
+ conn
+ |> put_status(:unprocessable_entity)
+ |> json(%{error: "Failed to create channel: #{inspect(reason)}"})
+ end
+ end)
+ end
+
+ defp with_valid_id(conn, value, fun) when is_function(fun, 1) do
+ case parse_id(value) do
+ {:ok, id} -> fun.(id)
+ :error -> invalid_id_response(conn)
+ end
+ end
+
+ defp invalid_id_response(conn) do
+ conn
+ |> put_status(:bad_request)
+ |> json(%{error: "Invalid id"})
+ end
+
+ defp parse_id(value) when is_integer(value) and value > 0, do: {:ok, value}
+
+ defp parse_id(value) when is_binary(value) do
+ case Integer.parse(value) do
+ {int, ""} when int > 0 -> {:ok, int}
+ _ -> :error
+ end
+ end
+
+ defp parse_id(_value), do: :error
+
+ defp format_server_summary(server, current_user_id) do
+ role =
+ case Messaging.get_server_member(server.id, current_user_id) do
+ nil -> nil
+ member -> member.role
+ end
+
+ %{
+ id: server.id,
+ name: server.name,
+ description: server.description,
+ icon_url: server.icon_url,
+ is_public: server.is_public,
+ member_count: server.member_count,
+ creator_id: server.creator_id,
+ origin_domain: server.origin_domain,
+ federation_id: server.federation_id,
+ is_federated_mirror: server.is_federated_mirror,
+ role: role,
+ inserted_at: server.inserted_at,
+ updated_at: server.updated_at
+ }
+ end
+
+ defp format_channel(channel) do
+ %{
+ id: channel.id,
+ name: channel.name,
+ description: channel.description,
+ type: channel.type,
+ channel_topic: channel.channel_topic,
+ channel_position: channel.channel_position,
+ server_id: channel.server_id,
+ inserted_at: channel.inserted_at,
+ updated_at: channel.updated_at
+ }
+ end
+
+ defp format_errors(changeset) do
+ Ecto.Changeset.traverse_errors(changeset, fn {msg, opts} ->
+ Enum.reduce(opts, msg, fn {key, value}, acc ->
+ String.replace(acc, "%{#{key}}", to_string(value))
+ end)
+ end)
+ end
+
+ defp parse_bool(nil, default), do: default
+ defp parse_bool(value, _default) when is_boolean(value), do: value
+
+ defp parse_bool(value, default) when is_binary(value) do
+ case String.downcase(value) do
+ "true" -> true
+ "1" -> true
+ "false" -> false
+ "0" -> false
+ _ -> default
+ end
+ end
+
+ defp parse_int(nil, default), do: default
+ defp parse_int(value, _default) when is_integer(value), do: value
+
+ defp parse_int(value, default) when is_binary(value) do
+ case Integer.parse(value) do
+ {int, ""} -> int
+ _ -> default
+ end
+ end
+
+ defp discover_scope?(params) do
+ scope =
+ case params["scope"] do
+ value when is_binary(value) -> value |> String.trim() |> String.downcase()
+ _ -> nil
+ end
+
+ parse_bool(params["discover"], false) or scope in ["discover", "public"]
+ end
+end
diff --git a/apps/arblarg/lib/arblarg_web/controllers/api/webhook_controller.ex b/apps/arblarg/lib/arblarg_web/controllers/api/webhook_controller.ex
new file mode 100644
index 0000000..eaca45b
--- /dev/null
+++ b/apps/arblarg/lib/arblarg_web/controllers/api/webhook_controller.ex
@@ -0,0 +1,255 @@
+defmodule ArblargWeb.API.WebhookController do
+ @moduledoc """
+ API controller for incoming chat webhooks.
+
+ Management endpoints (list/create/update/rotate/deactivate/delete) run on
+ the authenticated API surface and require the `manage_webhooks` permission
+ on the target conversation.
+
+ The execute endpoint (`POST /api/webhooks/:id/:token`) is unauthenticated:
+ the webhook token is the credential. Unknown ids and bad tokens both
+ return 404 to avoid oracle behavior.
+ """
+ use ArblargWeb, :controller
+
+ alias Elektrine.Messaging
+
+ action_fallback ElektrineWeb.FallbackController
+
+ @doc """
+ GET /api/conversations/:conversation_id/webhooks
+ Lists the webhooks of a conversation.
+ """
+ def index(conn, %{"conversation_id" => conversation_id}) do
+ user = conn.assigns[:current_user]
+
+ with_valid_id(conn, conversation_id, fn conv_id ->
+ case Messaging.list_chat_webhooks(conv_id, user.id) do
+ {:ok, webhooks} ->
+ json(conn, %{webhooks: Enum.map(webhooks, &format_webhook/1)})
+
+ {:error, reason} ->
+ handle_error(conn, reason)
+ end
+ end)
+ end
+
+ @doc """
+ POST /api/conversations/:conversation_id/webhooks
+ Creates a webhook. The response includes the plaintext token exactly once.
+ """
+ def create(conn, %{"conversation_id" => conversation_id} = params) do
+ user = conn.assigns[:current_user]
+
+ with_valid_id(conn, conversation_id, fn conv_id ->
+ attrs = %{
+ "name" => params["name"],
+ "avatar_url" => params["avatar_url"]
+ }
+
+ case Messaging.create_chat_webhook(conv_id, user.id, attrs) do
+ {:ok, webhook} ->
+ conn
+ |> put_status(:created)
+ |> json(%{webhook: format_webhook(webhook)})
+
+ {:error, reason} ->
+ handle_error(conn, reason)
+ end
+ end)
+ end
+
+ @doc """
+ PUT /api/webhooks/:id
+ Renames a webhook and/or updates its avatar.
+ """
+ def update(conn, %{"id" => id} = params) do
+ user = conn.assigns[:current_user]
+
+ with_valid_id(conn, id, fn webhook_id ->
+ case Messaging.update_chat_webhook(webhook_id, user.id, params) do
+ {:ok, webhook} ->
+ json(conn, %{webhook: format_webhook(webhook)})
+
+ {:error, reason} ->
+ handle_error(conn, reason)
+ end
+ end)
+ end
+
+ @doc """
+ POST /api/webhooks/:id/rotate
+ Rotates a webhook token. The response includes the new plaintext token
+ exactly once.
+ """
+ def rotate(conn, %{"id" => id}) do
+ user = conn.assigns[:current_user]
+
+ with_valid_id(conn, id, fn webhook_id ->
+ case Messaging.rotate_chat_webhook_token(webhook_id, user.id) do
+ {:ok, webhook} ->
+ json(conn, %{webhook: format_webhook(webhook)})
+
+ {:error, reason} ->
+ handle_error(conn, reason)
+ end
+ end)
+ end
+
+ @doc """
+ POST /api/webhooks/:id/deactivate
+ Deactivates a webhook without deleting it.
+ """
+ def deactivate(conn, %{"id" => id}) do
+ user = conn.assigns[:current_user]
+
+ with_valid_id(conn, id, fn webhook_id ->
+ case Messaging.deactivate_chat_webhook(webhook_id, user.id) do
+ {:ok, webhook} ->
+ json(conn, %{webhook: format_webhook(webhook)})
+
+ {:error, reason} ->
+ handle_error(conn, reason)
+ end
+ end)
+ end
+
+ @doc """
+ DELETE /api/webhooks/:id
+ Deletes a webhook.
+ """
+ def delete(conn, %{"id" => id}) do
+ user = conn.assigns[:current_user]
+
+ with_valid_id(conn, id, fn webhook_id ->
+ case Messaging.delete_chat_webhook(webhook_id, user.id) do
+ {:ok, _webhook} ->
+ json(conn, %{success: true})
+
+ {:error, reason} ->
+ handle_error(conn, reason)
+ end
+ end)
+ end
+
+ @doc """
+ POST /api/webhooks/:id/:token
+ Executes a webhook: posts a message into the webhook's conversation.
+
+ No session or API token required -- the webhook token is the credential.
+ Body: {"content": "...", "username": optional, "avatar_url": optional}.
+ """
+ def execute(conn, %{"id" => id, "token" => token} = params) do
+ case Messaging.execute_chat_webhook(id, token, params) do
+ {:ok, message} ->
+ conn
+ |> put_status(:created)
+ |> json(%{id: message.id, conversation_id: message.conversation_id})
+
+ {:error, reason} ->
+ handle_error(conn, reason)
+ end
+ end
+
+ # Error mapping
+
+ defp handle_error(conn, :not_found) do
+ conn |> put_status(:not_found) |> json(%{error: "Not found"})
+ end
+
+ defp handle_error(conn, :unauthorized) do
+ conn
+ |> put_status(:forbidden)
+ |> json(%{error: "You do not have permission to manage webhooks here"})
+ end
+
+ defp handle_error(conn, :unsupported_conversation) do
+ conn
+ |> put_status(:unprocessable_entity)
+ |> json(%{error: "Webhooks are only supported in channels and groups"})
+ end
+
+ defp handle_error(conn, :webhook_limit_reached) do
+ conn
+ |> put_status(:unprocessable_entity)
+ |> json(%{error: "Webhook limit reached for this conversation"})
+ end
+
+ defp handle_error(conn, :webhook_inactive) do
+ conn |> put_status(:forbidden) |> json(%{error: "Webhook is inactive"})
+ end
+
+ defp handle_error(conn, :rate_limited) do
+ conn
+ |> put_status(:too_many_requests)
+ |> json(%{error: "Rate limit exceeded. Please slow down."})
+ end
+
+ defp handle_error(conn, :invalid_content) do
+ conn
+ |> put_status(:unprocessable_entity)
+ |> json(%{error: "Content is required and must be at most 4000 characters"})
+ end
+
+ defp handle_error(conn, :invalid_override) do
+ conn
+ |> put_status(:unprocessable_entity)
+ |> json(%{error: "Invalid username or avatar_url override"})
+ end
+
+ defp handle_error(conn, %Ecto.Changeset{} = changeset) do
+ conn
+ |> put_status(:unprocessable_entity)
+ |> json(%{error: "Validation failed", errors: format_errors(changeset)})
+ end
+
+ defp handle_error(conn, _reason) do
+ conn |> put_status(:internal_server_error) |> json(%{error: "Something went wrong"})
+ end
+
+ # Serialization
+
+ defp format_webhook(webhook) do
+ base = %{
+ id: webhook.id,
+ conversation_id: webhook.conversation_id,
+ name: webhook.name,
+ avatar_url: webhook.avatar_url,
+ active: webhook.active,
+ creator_id: webhook.creator_id,
+ created_at: webhook.inserted_at
+ }
+
+ # Plaintext token is only present right after create/rotate.
+ case webhook.token do
+ nil -> base
+ token -> Map.put(base, :token, token)
+ end
+ end
+
+ defp format_errors(changeset) do
+ Ecto.Changeset.traverse_errors(changeset, fn {msg, opts} ->
+ Enum.reduce(opts, msg, fn {key, value}, acc ->
+ String.replace(acc, "%{#{key}}", to_string(value))
+ end)
+ end)
+ end
+
+ defp with_valid_id(conn, value, fun) do
+ case parse_id(value) do
+ {:ok, id} -> fun.(id)
+ :error -> conn |> put_status(:bad_request) |> json(%{error: "Invalid id"})
+ end
+ end
+
+ defp parse_id(value) when is_integer(value) and value > 0, do: {:ok, value}
+
+ defp parse_id(value) when is_binary(value) do
+ case Integer.parse(value) do
+ {int, ""} when int > 0 -> {:ok, int}
+ _ -> :error
+ end
+ end
+
+ defp parse_id(_value), do: :error
+end
diff --git a/apps/arblarg/lib/arblarg_web/live/chat_live/bootstrap.ex b/apps/arblarg/lib/arblarg_web/live/chat_live/bootstrap.ex
new file mode 100644
index 0000000..c6f62c0
--- /dev/null
+++ b/apps/arblarg/lib/arblarg_web/live/chat_live/bootstrap.ex
@@ -0,0 +1,104 @@
+defmodule ArblargWeb.ChatLive.Bootstrap do
+ @moduledoc false
+
+ alias ArblargWeb.ChatLive.Operations.Helpers
+ alias ArblargWeb.ChatLive.State
+
+ def initialize_socket(socket, opts) do
+ cached_conversations = Keyword.fetch!(opts, :cached_conversations)
+ cached_unread = Keyword.fetch!(opts, :cached_unread)
+ cached_servers = Keyword.fetch!(opts, :cached_servers)
+ chat_attachment_limit = Keyword.fetch!(opts, :chat_attachment_limit)
+ user_token = Keyword.fetch!(opts, :user_token)
+ custom_emojis = Keyword.fetch!(opts, :custom_emojis)
+ federation_preview = Keyword.fetch!(opts, :federation_preview)
+ loading_conversations = Keyword.fetch!(opts, :loading_conversations)
+
+ filtered_cached_conversations =
+ Helpers.scope_conversations_to_server(cached_conversations, nil)
+
+ socket
+ |> Phoenix.Component.assign(:page_title, "Chat")
+ |> Phoenix.Component.assign(:ui, %State.UI{})
+ |> Phoenix.Component.assign(:search, %State.Search{user_results: []})
+ |> Phoenix.Component.assign(:call, %State.Call{})
+ |> Phoenix.Component.assign(:form, %State.Form{})
+ |> Phoenix.Component.assign(:context_menu, %State.ContextMenu{})
+ |> Phoenix.Component.assign(:message, %State.Message{})
+ |> Phoenix.Component.assign(
+ :conversation,
+ %State.Conversation{
+ list: cached_conversations,
+ selected: nil,
+ filtered: filtered_cached_conversations,
+ last_message_read_status: %{},
+ unread_count: cached_unread,
+ unread_counts: %{}
+ }
+ )
+ |> Phoenix.Component.assign(:joined_servers, cached_servers)
+ |> Phoenix.Component.assign(:active_server_id, nil)
+ |> Phoenix.Component.assign(:server_categories, [])
+ |> Phoenix.Component.assign(:collapsed_category_ids, MapSet.new())
+ |> Phoenix.Component.assign(:pinned_messages, [])
+ |> Phoenix.Component.assign(:threads, %State.Threads{})
+ |> Phoenix.Component.assign(:voice, %State.Voice{})
+ |> Phoenix.Component.assign(:moderation, %State.Moderation{})
+ |> Phoenix.Component.assign(:browse, %State.Browse{})
+ |> Phoenix.Component.assign(:profile, %State.Profile{})
+ |> Phoenix.Component.assign(:messages, [])
+ |> Phoenix.Component.assign(:uploaded_files, [])
+ |> Phoenix.Component.assign(:can_send_messages, true)
+ |> Phoenix.LiveView.allow_upload(:chat_attachments,
+ accept:
+ ~w(.jpg .jpeg .png .gif .webp .heic .heif .avif .pdf .doc .docx .xls .xlsx .txt .mp3 .wav .webm),
+ max_entries: 5,
+ max_file_size: chat_attachment_limit
+ )
+ |> Phoenix.LiveView.allow_upload(:server_icon_upload,
+ accept: ~w(.jpg .jpeg .png .gif .webp),
+ max_entries: 1,
+ max_file_size: 5 * 1024 * 1024
+ )
+ |> Phoenix.LiveView.allow_upload(:group_avatar_upload,
+ accept: ~w(.jpg .jpeg .png .gif .webp),
+ max_entries: 1,
+ max_file_size: 5 * 1024 * 1024
+ )
+ |> Phoenix.LiveView.allow_upload(:channel_avatar_upload,
+ accept: ~w(.jpg .jpeg .png .gif .webp),
+ max_entries: 1,
+ max_file_size: 5 * 1024 * 1024
+ )
+ |> Phoenix.Component.assign(:user_token, user_token)
+ |> Phoenix.Component.assign(:show_mobile_search, false)
+ |> Phoenix.Component.assign(:show_report_modal, false)
+ |> Phoenix.Component.assign(:report_type, nil)
+ |> Phoenix.Component.assign(:report_id, nil)
+ |> Phoenix.Component.assign(:report_metadata, %{})
+ |> Phoenix.Component.assign(:has_more_older_messages, false)
+ |> Phoenix.Component.assign(:has_more_newer_messages, false)
+ |> Phoenix.Component.assign(:oldest_message_id, nil)
+ |> Phoenix.Component.assign(:processed_call_events, MapSet.new())
+ |> Phoenix.Component.assign(:newest_message_id, nil)
+ |> Phoenix.Component.assign(:loading_older_messages, false)
+ |> Phoenix.Component.assign(:loading_newer_messages, false)
+ |> Phoenix.Component.assign(:initial_messages_loading, false)
+ |> Phoenix.Component.assign(:first_unread_message_id, nil)
+ |> Phoenix.Component.assign(:show_image_modal, false)
+ |> Phoenix.Component.assign(:modal_image_url, nil)
+ |> Phoenix.Component.assign(:modal_images, [])
+ |> Phoenix.Component.assign(:modal_image_index, 0)
+ |> Phoenix.Component.assign(:modal_post, nil)
+ |> Phoenix.Component.assign(:public_server_search_results, [])
+ |> Phoenix.Component.assign(:public_group_search_results, [])
+ |> Phoenix.Component.assign(:custom_emojis, custom_emojis)
+ |> Phoenix.Component.assign(:federation_preview, federation_preview)
+ |> Phoenix.Component.assign(:federation_presence, %{})
+ |> Phoenix.Component.assign(:chat_e2ee_devices, [])
+ |> Phoenix.Component.assign(:room_presence_timer_ref, nil)
+ |> Phoenix.Component.assign(:pending_remote_join_requests, [])
+ |> Phoenix.Component.assign(:loading_conversations, loading_conversations)
+ |> Phoenix.Component.assign(:refresh_conversations_scheduled, false)
+ end
+end
diff --git a/apps/arblarg/lib/arblarg_web/live/chat_live/components/channel_modal.ex b/apps/arblarg/lib/arblarg_web/live/chat_live/components/channel_modal.ex
new file mode 100644
index 0000000..f4d58ea
--- /dev/null
+++ b/apps/arblarg/lib/arblarg_web/live/chat_live/components/channel_modal.ex
@@ -0,0 +1,207 @@
+defmodule ArblargWeb.ChatLive.Components.ChannelModal do
+ @moduledoc false
+ use ArblargWeb, :live_component
+
+ attr :uploads, :map, default: %{}
+ attr :categories, :list, default: []
+
+ def render(assigns) do
+ ~H"""
+
+
+
+
Create Server Channel
+
+ <.icon name="hero-x-mark" class="w-5 h-5" />
+
+
+
+
+
+
+ """
+ end
+
+ def handle_event("close_modal", _params, socket) do
+ send(self(), {:close_channel_modal})
+ {:noreply, socket}
+ end
+
+ def handle_event("create_channel", %{"channel" => channel_params}, socket) do
+ send(self(), {:create_channel, channel_params})
+ {:noreply, socket}
+ end
+
+ def handle_event("validate_upload", _params, socket) do
+ {:noreply, socket}
+ end
+end
diff --git a/apps/arblarg/lib/arblarg_web/live/chat_live/components/group_modal.ex b/apps/arblarg/lib/arblarg_web/live/chat_live/components/group_modal.ex
new file mode 100644
index 0000000..14165b9
--- /dev/null
+++ b/apps/arblarg/lib/arblarg_web/live/chat_live/components/group_modal.ex
@@ -0,0 +1,272 @@
+defmodule ArblargWeb.ChatLive.Components.GroupModal do
+ @moduledoc false
+ use ArblargWeb, :live_component
+ import Elektrine.Components.User.Avatar
+ alias ArblargWeb.ChatLive.HandleFormatter
+
+ attr :group_name, :string, default: ""
+ attr :group_description, :string, default: ""
+ attr :group_is_public, :boolean, default: false
+ attr :search_query, :string, default: ""
+ attr :search_results, :list, default: []
+ attr :selected_users, :list, default: []
+ attr :uploads, :map, default: %{}
+
+ def render(assigns) do
+ ~H"""
+
+
+
+
Create Group Chat
+
+ <.icon name="hero-x-mark" class="w-5 h-5" />
+
+
+
+
+
+
+ Group Name
+
+
+
+
+
+
+ Description
+
+ {@group_description}
+
+
+ <%= if @uploads[:group_avatar_upload] do %>
+
+
+ Group Image
+
+
+ <%= if @uploads.group_avatar_upload.entries != [] do %>
+ <% entry = List.first(@uploads.group_avatar_upload.entries) %>
+
+ <.live_img_preview entry={entry} class="w-full h-full object-cover" />
+
+ <% else %>
+
+ <.icon name="hero-photo" class="w-6 h-6 text-base-content/60" />
+
+ <% end %>
+
+ Choose Image
+ <.live_file_input
+ upload={@uploads.group_avatar_upload}
+ class="hidden"
+ phx-change="validate_upload"
+ phx-target={@myself}
+ />
+
+
+ <%= for entry <- @uploads.group_avatar_upload.entries do %>
+
+
{entry.client_name}
+
+
+
+ <.icon name="hero-x-mark" class="w-3 h-3" />
+
+
+ <% end %>
+
+ <% end %>
+
+
+
+ Make Public
+
+
+
+ Public groups can be discovered and joined by anyone
+
+
+
+
+
+ Add Members
+
+
+
+
+
+ <%= if @selected_users != [] do %>
+
+ <%= for user <- @selected_users do %>
+
+ {user.handle || user.username}
+
+ <.icon name="hero-x-mark" class="w-3 h-3" />
+
+
+ <% end %>
+
+ <% end %>
+
+
+ <%= if @search_results != [] do %>
+
+ <%= for user <- @search_results do %>
+
+
+
+
+ <.user_avatar user={user} size="sm" />
+
+
+
+
+ {user.display_name || user.handle || user.username}
+
+
{HandleFormatter.at_handle(user)}
+
+
+ <% end %>
+
+ <% end %>
+
+
+
+ <.icon name="hero-users" class="w-4 h-4 mr-2" />
+ Create Group ({length(@selected_users)} members)
+
+
+ Cancel
+
+
+
+
+
+ """
+ end
+
+ def handle_event("close_modal", _params, socket) do
+ send(self(), {:close_group_modal})
+ {:noreply, socket}
+ end
+
+ def handle_event("create_group", %{"group" => group_params}, socket) do
+ send(self(), {:create_group, group_params, socket.assigns.selected_users})
+ {:noreply, socket}
+ end
+
+ def handle_event("search_users", %{"query" => query}, socket) do
+ send(self(), {:search_users_for_group, query})
+ {:noreply, assign(socket, :search_query, query)}
+ end
+
+ def handle_event("search_users", %{"value" => query}, socket) do
+ send(self(), {:search_users_for_group, query})
+ {:noreply, assign(socket, :search_query, query)}
+ end
+
+ def handle_event("update_group_form", %{"group" => group_params}, socket) do
+ send(self(), {:update_group_form, group_params})
+ {:noreply, socket}
+ end
+
+ def handle_event("update_group_form", %{"_target" => ["group", "is_public"]} = params, socket) do
+ # Handle checkbox toggle - when unchecked, the value won't be in params
+ group_params = Map.get(params, "group", %{})
+ group_params = Map.put(group_params, "is_public", Map.has_key?(group_params, "is_public"))
+
+ send(self(), {:update_group_form, group_params})
+ {:noreply, socket}
+ end
+
+ def handle_event("update_group_form", params, socket) do
+ # Fallback for other form updates
+ group_params = Map.get(params, "group", %{})
+ send(self(), {:update_group_form, group_params})
+ {:noreply, socket}
+ end
+
+ def handle_event("validate_upload", _params, socket) do
+ {:noreply, socket}
+ end
+
+ def handle_event("toggle_user_selection", %{"user_id" => user_id}, socket) do
+ send(self(), {:toggle_user_selection, user_id})
+ {:noreply, socket}
+ end
+end
diff --git a/apps/arblarg/lib/arblarg_web/live/chat_live/handle_formatter.ex b/apps/arblarg/lib/arblarg_web/live/chat_live/handle_formatter.ex
new file mode 100644
index 0000000..cbb0ab4
--- /dev/null
+++ b/apps/arblarg/lib/arblarg_web/live/chat_live/handle_formatter.ex
@@ -0,0 +1,108 @@
+defmodule ArblargWeb.ChatLive.HandleFormatter do
+ @moduledoc false
+
+ alias Elektrine.Messaging.Federation
+
+ def at_handle(user), do: "@" <> handle(user)
+
+ def handle(%Ecto.Association.NotLoaded{}), do: unknown_handle()
+ def handle(nil), do: unknown_handle()
+
+ def handle(user) when is_map(user) do
+ identifier =
+ first_present([
+ map_get(user, :remote_handle),
+ map_get(user, :handle),
+ map_get(user, :username)
+ ])
+
+ fallback_domain =
+ first_present([
+ map_get(user, :domain),
+ map_get(user, :origin_domain)
+ ])
+
+ case identifier do
+ nil -> unknown_handle()
+ value -> normalize_handle(value, fallback_domain)
+ end
+ end
+
+ def handle(value) when is_binary(value), do: normalize_handle(value, nil)
+ def handle(_), do: unknown_handle()
+
+ def domain(user) do
+ case String.split(handle(user), "@", parts: 2) do
+ [_local, domain] -> domain
+ _ -> local_domain()
+ end
+ end
+
+ def local_domain do
+ case Federation.local_domain() do
+ domain when is_binary(domain) ->
+ case String.trim(domain) do
+ "" -> Elektrine.Domains.default_user_handle_domain()
+ value -> String.downcase(value)
+ end
+
+ _ ->
+ Elektrine.Domains.default_user_handle_domain()
+ end
+ end
+
+ defp normalize_handle(value, fallback_domain) when is_binary(value) do
+ cleaned =
+ value
+ |> String.trim()
+ |> String.trim_leading("@")
+
+ case String.split(cleaned, "@", parts: 2) do
+ [local, domain] when local != "" and domain != "" ->
+ local <> "@" <> normalize_domain(domain)
+
+ [local] when local != "" ->
+ local <> "@" <> normalize_domain(fallback_domain || local_domain())
+
+ _ ->
+ unknown_handle()
+ end
+ end
+
+ defp normalize_handle(_value, fallback_domain),
+ do: unknown_handle(fallback_domain || local_domain())
+
+ defp normalize_domain(domain) when is_binary(domain) do
+ domain
+ |> String.trim()
+ |> String.trim_leading("@")
+ |> String.downcase()
+ |> case do
+ "" -> local_domain()
+ value -> value
+ end
+ end
+
+ defp normalize_domain(_), do: local_domain()
+
+ defp first_present(values) do
+ Enum.find_value(values, fn
+ value when is_binary(value) ->
+ case String.trim(value) do
+ "" -> nil
+ trimmed -> trimmed
+ end
+
+ _ ->
+ nil
+ end)
+ end
+
+ defp map_get(map, key) when is_map(map) do
+ Map.get(map, key) || Map.get(map, Atom.to_string(key))
+ end
+
+ defp unknown_handle(domain \\ local_domain()) do
+ "unknown@" <> normalize_domain(domain)
+ end
+end
diff --git a/apps/arblarg/lib/arblarg_web/live/chat_live/index.ex b/apps/arblarg/lib/arblarg_web/live/chat_live/index.ex
new file mode 100644
index 0000000..81a7c67
--- /dev/null
+++ b/apps/arblarg/lib/arblarg_web/live/chat_live/index.ex
@@ -0,0 +1,3604 @@
+defmodule ArblargWeb.ChatLive.Index do
+ use ArblargWeb, :live_view
+ require Logger
+
+ alias Elektrine.Accounts.User
+ alias Elektrine.Calls
+ alias Elektrine.Calls.Transport, as: CallTransport
+ alias Elektrine.Constants
+ alias Elektrine.Messaging, as: Messaging
+ alias Elektrine.Messaging.ChatMessage
+ alias Elektrine.Messaging.Federation.VoiceCalls
+ alias Elektrine.Messaging.RoomACL
+ alias Elektrine.Social.Message
+ alias Elektrine.Uploads
+ import Elektrine.Components.User.Avatar
+ import Elektrine.Components.User.UsernameEffects
+ import ElektrineWeb.Components.Social.YoutubePreview, only: [youtube_preview: 1]
+ import ArblargWeb.Components.Social.ContentJourney
+ import ArblargWeb.Components.Chat.Call
+ import ArblargWeb.Components.Chat.SenderBadge, only: [sender_badge: 1]
+ import ArblargWeb.Components.Platform.ENav
+ import ArblargWeb.Components.Social.EmbeddedPost
+ import ElektrineWeb.Live.NotificationHelpers
+
+ # Import operation modules
+ alias ArblargWeb.ChatLive.Bootstrap
+ alias ArblargWeb.ChatLive.HandleFormatter
+
+ alias ArblargWeb.ChatLive.Operations.{
+ CallInfoOperations,
+ Helpers,
+ MessageInfoOperations,
+ ThreadOperations,
+ VoiceChannelOperations
+ }
+
+ @room_presence_heartbeat_ms 30_000
+
+ @impl true
+ def mount(_params, session, socket) do
+ case socket.assigns[:current_user] do
+ %User{} = user ->
+ mount_authenticated_user(socket, session, user)
+
+ _unauthenticated ->
+ {:ok, redirect(socket, to: Elektrine.Paths.login_path())}
+ end
+ end
+
+ defp mount_authenticated_user(socket, session, user) do
+ # Set locale from session or user preference
+ locale = session["locale"] || (user && user.locale) || "en"
+ Gettext.put_locale(ElektrineWeb.Gettext, locale)
+
+ if connected?(socket) do
+ # Subscribe to user's personal channel for notifications
+ Phoenix.PubSub.subscribe(Elektrine.PubSub, "user:#{user.id}")
+ # Subscribe to global conversation events
+ Phoenix.PubSub.subscribe(Elektrine.PubSub, "conversations:all")
+ end
+
+ # Admins get higher upload limits
+ chat_attachment_limit =
+ if user.is_admin,
+ do: Constants.max_chat_attachment_size_admin(),
+ else: Constants.max_chat_attachment_size()
+
+ # Load cached conversations to prevent flicker on refresh
+ # This returns cached data instantly if available, only hits DB on cache miss
+ cached_conversations = get_cached_conversations(user.id)
+ cached_unread = get_cached_unread_count(user.id)
+ cached_servers = Messaging.list_servers(user.id)
+
+ custom_emojis = load_custom_emojis()
+ federation_preview = build_federation_preview()
+
+ loading_conversations =
+ !Enum.empty?(cached_conversations) || Messaging.user_has_conversations?(user.id)
+
+ # Initialize with cached data to prevent flicker
+ socket =
+ Bootstrap.initialize_socket(socket,
+ cached_conversations: cached_conversations,
+ cached_unread: cached_unread,
+ cached_servers: cached_servers,
+ chat_attachment_limit: chat_attachment_limit,
+ user_token: Helpers.generate_user_token(user.id),
+ custom_emojis: custom_emojis,
+ federation_preview: federation_preview,
+ loading_conversations: loading_conversations
+ )
+ |> restore_active_call_state(user.id)
+
+ # Load conversations async after connection
+ if connected?(socket) do
+ send(self(), :load_conversations)
+
+ if socket.assigns.call.active_call do
+ send(self(), :resume_active_call)
+ end
+ end
+
+ {:ok, socket}
+ end
+
+ @impl true
+ def handle_params(
+ %{"conversation_id" => conversation_identifier},
+ _url,
+ %{assigns: %{live_action: :join}} = socket
+ ) do
+ # Handle join link - first try to find by hash, then by ID
+ conversation_id =
+ case Messaging.get_conversation_by_hash(conversation_identifier) do
+ %{id: id} ->
+ id
+
+ nil ->
+ # Fallback to ID lookup when handle is absent
+ case Integer.parse(conversation_identifier) do
+ {id, ""} -> id
+ _ -> nil
+ end
+ end
+
+ if conversation_id do
+ case Messaging.join_conversation(conversation_id, socket.assigns.current_user.id) do
+ {:ok, :pending} ->
+ {:noreply,
+ socket
+ |> notify_info("Join request sent")
+ |> push_navigate(to: Elektrine.Paths.chat_root_path())}
+
+ {:ok, _} ->
+ # Successfully joined, trigger conversation refresh and redirect
+ {:noreply,
+ socket
+ |> maybe_schedule_conversation_refresh(100)
+ |> notify_info("Successfully joined!")
+ |> push_navigate(to: Elektrine.Paths.chat_path(conversation_identifier))}
+
+ {:error, :already_member} ->
+ # Already a member, just go to the conversation using the original identifier
+ {:noreply,
+ socket
+ |> push_navigate(to: Elektrine.Paths.chat_path(conversation_identifier))}
+
+ {:error, :not_public_channel} ->
+ {:noreply,
+ socket
+ |> notify_error("This is a private group or channel - you need an invitation to join")
+ |> push_navigate(to: Elektrine.Paths.chat_root_path())}
+
+ {:error, _} ->
+ {:noreply,
+ socket
+ |> notify_error("Unable to join this conversation")
+ |> push_navigate(to: Elektrine.Paths.chat_root_path())}
+ end
+ else
+ # Invalid conversation identifier
+ {:noreply,
+ socket
+ |> notify_error("Invalid chat invite link")
+ |> push_navigate(to: Elektrine.Paths.chat_root_path())}
+ end
+ end
+
+ def handle_params(%{"conversation_id" => conversation_identifier}, _url, socket) do
+ user_id = socket.assigns.current_user.id
+
+ case Messaging.get_conversation_for_chat_by_hash!(conversation_identifier, user_id) do
+ {:ok, conversation} ->
+ {:noreply, open_conversation_or_redirect(socket, conversation)}
+
+ {:error, :not_found} ->
+ case Integer.parse(conversation_identifier) do
+ {conversation_id, ""} ->
+ case Messaging.get_conversation_for_chat!(conversation_id, user_id) do
+ {:ok, conversation} ->
+ # If accessed by ID instead of hash, redirect to canonical hash URL.
+ if conversation_identifier != conversation.hash && conversation.hash do
+ {:noreply, push_navigate(socket, to: Elektrine.Paths.chat_path(conversation))}
+ else
+ {:noreply, open_conversation_or_redirect(socket, conversation)}
+ end
+
+ {:error, :not_found} ->
+ {:noreply,
+ socket
+ |> notify_error("Chat not found")
+ |> push_navigate(to: Elektrine.Paths.chat_root_path())}
+ end
+
+ _ ->
+ {:noreply,
+ socket
+ |> notify_error("Invalid chat")
+ |> push_navigate(to: Elektrine.Paths.chat_root_path())}
+ end
+ end
+ end
+
+ def handle_params(params, _url, socket) do
+ show_new_chat = params["composer"] == "message"
+
+ # Clear selected conversation when navigating to /chat without a conversation_id
+ {:noreply,
+ socket
+ |> cancel_room_presence_timer()
+ |> assign(:conversation, %{socket.assigns.conversation | selected: nil})
+ |> assign(:initial_messages_loading, false)
+ |> assign(:ui, %{socket.assigns.ui | show_new_chat: show_new_chat})
+ |> assign(:federation_presence, %{})}
+ end
+
+ @impl true
+ def handle_event(event_name, params, socket) do
+ # Delegate ALL events to the router
+ ArblargWeb.ChatLive.Router.route_event(event_name, params, socket)
+ end
+
+ # All event handlers now in operation modules via Router
+
+ # Handle messages from components
+ # Load conversations asynchronously after mount
+ def handle_info(:load_conversations, socket) do
+ user = socket.assigns.current_user
+
+ # Get all conversations but filter out Timeline and Community channels from chat view
+ all_conversations = Messaging.list_chat_conversations(user.id)
+
+ conversations_filtered =
+ Enum.reject(all_conversations, &(&1.type in ["timeline", "community"]))
+
+ unread_count = Messaging.get_unread_count(user.id)
+
+ # Calculate unread counts and sort conversations
+ unread_counts = Helpers.calculate_unread_counts(conversations_filtered, user.id)
+
+ conversations =
+ Helpers.sort_conversations_by_unread(conversations_filtered, unread_counts, user.id)
+
+ # Calculate read status for last messages
+ last_message_read_status = Helpers.calculate_last_message_read_status(conversations, user.id)
+ joined_servers = Messaging.list_servers(user.id)
+
+ {:noreply,
+ socket
+ |> assign(:conversation, %{
+ socket.assigns.conversation
+ | list: conversations,
+ filtered:
+ refresh_conversation_filter(
+ conversations,
+ socket.assigns.search.conversation_query,
+ user.id,
+ socket.assigns[:active_server_id]
+ ),
+ last_message_read_status: last_message_read_status,
+ unread_count: unread_count,
+ unread_counts: unread_counts
+ })
+ |> assign(:joined_servers, joined_servers)
+ |> assign(:loading_conversations, false)
+ |> VoiceChannelOperations.sync_voice_channels(conversations)}
+ end
+
+ def handle_info({:update_group_form, group_params}, socket) do
+ updated_form = %{
+ socket.assigns.form
+ | group_name: Map.get(group_params, "name", socket.assigns.form.group_name),
+ group_description:
+ Map.get(group_params, "description", socket.assigns.form.group_description),
+ group_is_public:
+ Map.get(group_params, "is_public", socket.assigns.form.group_is_public) == "true"
+ }
+
+ {:noreply, assign(socket, :form, updated_form)}
+ end
+
+ # Handle messages from components
+ def handle_info({:search_users, query}, socket) do
+ if String.length(query) >= 2 do
+ results =
+ query
+ |> Messaging.search_users(socket.assigns.current_user.id)
+ |> maybe_add_remote_dm_search_result(query)
+
+ {:noreply, assign(socket, :search_results, results)}
+ else
+ {:noreply, assign(socket, :search_results, [])}
+ end
+ end
+
+ def handle_info({:show_direct_search}, socket) do
+ updated_ui =
+ socket.assigns.ui
+ |> Map.put(:show_create_group, false)
+ |> Map.put(:show_create_channel, false)
+ |> Map.put(:show_browse_channels, false)
+
+ {:noreply,
+ socket
+ |> assign(:ui, updated_ui)}
+ end
+
+ def handle_info({:start_dm, %{"remote_handle" => remote_handle}}, socket)
+ when is_binary(remote_handle) and remote_handle != "" do
+ current_user_id = socket.assigns.current_user.id
+
+ case Messaging.create_remote_dm_conversation(current_user_id, remote_handle) do
+ {:ok, conversation} ->
+ {:noreply,
+ socket
+ |> assign(:ui, Map.put(socket.assigns.ui, :show_new_chat, false))
+ |> assign(:search, %{socket.assigns.search | query: "", results: []})
+ |> push_navigate(to: Elektrine.Paths.chat_path(conversation))}
+
+ {:error, :invalid_remote_handle} ->
+ {:noreply, notify_error(socket, "Use handle format user@domain")}
+
+ {:error, :unknown_peer} ->
+ {:noreply,
+ notify_error(
+ socket,
+ "That domain could not be reached through federation discovery"
+ )}
+
+ {:error, :rate_limited} ->
+ {:noreply,
+ socket
+ |> notify_error(
+ "You are creating too many conversations. Please wait a moment and try again."
+ )
+ |> assign(:ui, Map.put(socket.assigns.ui, :show_profile_modal, false))}
+
+ {:error, reason} ->
+ error_message = Elektrine.Privacy.privacy_error_message(reason)
+
+ {:noreply,
+ socket
+ |> notify_error(error_message)
+ |> assign(:ui, Map.put(socket.assigns.ui, :show_profile_modal, false))}
+ end
+ end
+
+ def handle_info({:start_dm, %{"user_id" => user_id_str}}, socket) do
+ handle_info({:start_dm, user_id_str}, socket)
+ end
+
+ def handle_info({:start_dm, user_id_str}, socket) when is_binary(user_id_str) do
+ case parse_positive_int(user_id_str) do
+ {:ok, user_id} ->
+ start_local_dm(socket, user_id)
+
+ :error ->
+ {:noreply,
+ socket
+ |> notify_error("Failed to start chat")
+ |> assign(:ui, Map.put(socket.assigns.ui, :show_profile_modal, false))}
+ end
+ end
+
+ def handle_info({:show_create_group}, socket) do
+ updated_ui =
+ socket.assigns.ui
+ |> Map.put(:show_group_modal, true)
+ |> Map.put(:show_channel_modal, false)
+ |> Map.put(:show_new_chat, false)
+
+ {:noreply,
+ socket
+ |> assign(:ui, updated_ui)}
+ end
+
+ def handle_info({:show_create_channel}, socket) do
+ case selected_server_id(socket) do
+ nil ->
+ {:noreply,
+ socket
+ |> notify_error("Select a server first, then create channels inside it")}
+
+ _server_id ->
+ updated_ui =
+ socket.assigns.ui
+ |> Map.put(:show_channel_modal, true)
+ |> Map.put(:show_group_modal, false)
+ |> Map.put(:show_new_chat, false)
+
+ {:noreply,
+ socket
+ |> assign(:ui, updated_ui)}
+ end
+ end
+
+ def handle_info({:show_browse_modal}, socket) do
+ public_servers = Messaging.list_public_servers(socket.assigns.current_user.id)
+ public_groups = Messaging.list_chat_public_groups()
+
+ updated_ui =
+ socket.assigns.ui
+ |> Map.put(:show_browse_modal, true)
+ |> Map.put(:show_group_modal, false)
+ |> Map.put(:show_channel_modal, false)
+ |> Map.put(:show_new_chat, false)
+
+ {:noreply,
+ socket
+ |> assign(:ui, updated_ui)
+ |> assign(:search, %{socket.assigns.search | browse_query: ""})
+ |> assign(:browse, %{
+ socket.assigns.browse
+ | tab: "servers",
+ public_servers: public_servers,
+ public_channels: [],
+ public_groups: public_groups,
+ filtered_servers: public_servers,
+ filtered_channels: [],
+ filtered_groups: public_groups
+ })}
+ end
+
+ def handle_info({:close_group_modal}, socket) do
+ {:noreply,
+ socket
+ |> assign(:ui, Map.put(socket.assigns.ui, :show_group_modal, false))
+ |> clear_upload_entries(:group_avatar_upload)}
+ end
+
+ def handle_info({:close_channel_modal}, socket) do
+ {:noreply,
+ socket
+ |> assign(:ui, Map.put(socket.assigns.ui, :show_channel_modal, false))
+ |> clear_upload_entries(:channel_avatar_upload)}
+ end
+
+ def handle_info({:create_group, group_params, selected_users}, socket) do
+ name = Map.get(group_params, "name", "")
+ description = Map.get(group_params, "description", "")
+ is_public = Map.get(group_params, "is_public") == "true"
+
+ if Elektrine.Strings.present?(name) and selected_users != [] do
+ member_ids = Enum.map(selected_users, & &1.id)
+
+ case consume_entity_image_upload(socket, :group_avatar_upload) do
+ {:ok, avatar_url} ->
+ case Messaging.create_chat_group_conversation(
+ socket.assigns.current_user.id,
+ %{
+ name: String.trim(name),
+ description: String.trim(description),
+ avatar_url: avatar_url,
+ is_public: is_public
+ },
+ member_ids
+ ) do
+ {:ok, conversation} ->
+ {:noreply,
+ socket
+ |> assign(:ui, Map.put(socket.assigns.ui, :show_group_modal, false))
+ |> assign(:form, %{socket.assigns.form | selected_users: []})
+ |> assign(:search, %{socket.assigns.search | query: "", results: []})
+ |> notify_info("Group created successfully!")
+ |> push_navigate(to: Elektrine.Paths.chat_path(conversation))}
+
+ {:ok, conversation, failed_count} ->
+ {:noreply,
+ socket
+ |> assign(:ui, Map.put(socket.assigns.ui, :show_group_modal, false))
+ |> assign(:form, %{socket.assigns.form | selected_users: []})
+ |> assign(:search, %{socket.assigns.search | query: "", results: []})
+ |> notify_warning(
+ "Group created but #{failed_count} user(s) could not be added due to their privacy settings"
+ )
+ |> push_navigate(to: Elektrine.Paths.chat_path(conversation))}
+
+ {:error, :group_limit_exceeded} ->
+ {:noreply,
+ socket
+ |> notify_error("You've reached the maximum limit of 20 groups")}
+
+ {:error, _changeset} ->
+ {:noreply,
+ socket
+ |> notify_error("Failed to create group. Please try again.")}
+ end
+
+ {:error, reason} ->
+ {:noreply, notify_error(socket, reason)}
+ end
+ else
+ {:noreply,
+ socket
+ |> notify_error("Please provide a group name and select at least one member.")}
+ end
+ end
+
+ def handle_info({:create_channel, channel_params}, socket) do
+ name = Map.get(channel_params, "name", "")
+ description = Map.get(channel_params, "description", "")
+ topic = Map.get(channel_params, "channel_topic", "")
+ is_private = parse_checkbox_value(Map.get(channel_params, "is_private"))
+ category_id = parse_optional_positive_int(Map.get(channel_params, "category_id"))
+
+ channel_type =
+ if Map.get(channel_params, "channel_type") == "voice_channel",
+ do: "voice_channel",
+ else: "channel"
+
+ with server_id when is_integer(server_id) <- selected_server_id(socket),
+ true <- Elektrine.Strings.present?(name),
+ {:ok, avatar_url} <- consume_entity_image_upload(socket, :channel_avatar_upload) do
+ attrs = %{
+ name: String.trim(name),
+ description: normalize_optional_text(description),
+ channel_topic: normalize_optional_text(topic),
+ avatar_url: avatar_url,
+ category_id: category_id,
+ type: channel_type,
+ is_public: !is_private
+ }
+
+ case Messaging.create_server_channel(server_id, socket.assigns.current_user.id, attrs) do
+ {:ok, %{type: "voice_channel"}} ->
+ # Voice channels have no text timeline to navigate to.
+ {:noreply,
+ socket
+ |> assign(:ui, Map.put(socket.assigns.ui, :show_channel_modal, false))
+ |> notify_info("Voice channel created successfully!")
+ |> maybe_schedule_conversation_refresh(100)}
+
+ {:ok, channel} ->
+ {:noreply,
+ socket
+ |> assign(:ui, Map.put(socket.assigns.ui, :show_channel_modal, false))
+ |> notify_info("Channel created successfully!")
+ |> push_navigate(to: Elektrine.Paths.chat_path(channel))}
+
+ {:error, :unauthorized} ->
+ {:noreply,
+ socket
+ |> notify_error("You don't have permission to create channels in this server")}
+
+ {:error, :not_found} ->
+ {:noreply,
+ socket
+ |> notify_error("Server not found")}
+
+ {:error, _changeset} ->
+ {:noreply,
+ socket
+ |> notify_error("Failed to create channel. Please try again.")}
+ end
+ else
+ nil ->
+ {:noreply,
+ socket
+ |> notify_error("Select a server first, then create channels inside it")}
+
+ false ->
+ {:noreply,
+ socket
+ |> notify_error("Please provide a channel name.")}
+
+ {:error, reason} ->
+ {:noreply, notify_error(socket, reason)}
+ end
+ end
+
+ def handle_info({:search_users_for_group, query}, socket) do
+ search_results =
+ if String.length(query) >= 2 do
+ Elektrine.Accounts.search_users(query, socket.assigns.current_user.id)
+ else
+ []
+ end
+
+ {:noreply,
+ socket
+ |> assign(:search, %{socket.assigns.search | query: query, results: search_results})}
+ end
+
+ def handle_info({:toggle_user_selection, user_id_str}, socket) do
+ case parse_positive_int(user_id_str) do
+ {:ok, user_id} ->
+ selected_users = socket.assigns.form.selected_users
+
+ updated_users =
+ case Enum.find_index(selected_users, &(&1.id == user_id)) do
+ nil ->
+ case Enum.find(socket.assigns.search.results, &(&1.id == user_id)) do
+ %User{} = user -> [user | selected_users]
+ nil -> selected_users
+ end
+
+ index ->
+ List.delete_at(selected_users, index)
+ end
+
+ {:noreply, assign(socket, :form, %{socket.assigns.form | selected_users: updated_users})}
+
+ :error ->
+ {:noreply, socket}
+ end
+ end
+
+ def handle_info({:send_message, message_content}, socket) do
+ trimmed_content = String.trim(message_content)
+
+ if trimmed_content != "" do
+ case socket.assigns.conversation.selected do
+ nil ->
+ {:noreply, socket}
+
+ conversation ->
+ reply_to_id =
+ if socket.assigns.message.reply_to, do: socket.assigns.message.reply_to.id, else: nil
+
+ case Messaging.create_text_message(
+ conversation.id,
+ socket.assigns.current_user.id,
+ trimmed_content,
+ reply_to_id
+ ) do
+ {:ok, _message} ->
+ {:noreply,
+ socket
+ |> assign(:message, %{socket.assigns.message | new_message: "", reply_to: nil})
+ |> push_event("clear_message_input", %{})}
+
+ {:error, _} ->
+ {:noreply,
+ socket
+ |> notify_error("Failed to send message")}
+ end
+ end
+ else
+ {:noreply, socket}
+ end
+ end
+
+ def handle_info({:react_to_message, message_id_str, emoji}, socket) do
+ case parse_positive_int(message_id_str) do
+ {:ok, message_id} ->
+ case Messaging.add_chat_reaction(message_id, socket.assigns.current_user.id, emoji) do
+ {:ok, _reaction} -> {:noreply, socket}
+ {:error, _} -> {:noreply, socket}
+ end
+
+ :error ->
+ {:noreply, socket}
+ end
+ end
+
+ def handle_info({:load_conversation_messages, conversation_id}, socket) do
+ if socket.assigns.conversation.selected &&
+ socket.assigns.conversation.selected.id == conversation_id do
+ user_id = socket.assigns.current_user.id
+
+ data = Messaging.get_conversation_messages(conversation_id, user_id, limit: 50)
+ messages = data.messages |> Enum.reverse() |> Helpers.dedupe_messages()
+
+ first_unread_message_id =
+ Helpers.find_first_unread_message(messages, conversation_id, user_id)
+
+ # Mark conversation as read after UI settles.
+ Process.send_after(self(), {:mark_conversation_read, conversation_id}, 200)
+
+ message_ids = Enum.map(messages, & &1.id)
+
+ if message_ids != [] do
+ # Load read receipts asynchronously to keep initial message paint snappy.
+ Process.send_after(self(), {:load_read_status, message_ids, conversation_id}, 150)
+ end
+
+ user_ids =
+ socket.assigns.conversation.selected.members
+ |> Enum.map(& &1.user_id)
+ |> Enum.uniq()
+
+ timeout_status = Helpers.load_timeout_status(user_ids, conversation_id)
+
+ updated_socket =
+ socket
+ |> assign(:messages, messages)
+ |> assign(:moderation, %{
+ socket.assigns.moderation
+ | user_timeout_status: timeout_status
+ })
+ |> assign(:first_unread_message_id, first_unread_message_id)
+ |> assign(:has_more_older_messages, data.has_more_older)
+ |> assign(:has_more_newer_messages, data.has_more_newer)
+ |> assign(:oldest_message_id, data.oldest_id)
+ |> assign(:newest_message_id, data.newest_id)
+ |> assign(:loading_older_messages, false)
+ |> assign(:loading_newer_messages, false)
+ |> assign(:initial_messages_loading, false)
+ |> push_event("restore_conversation_scroll", %{conversation_id: conversation_id})
+
+ {:noreply, updated_socket}
+ else
+ {:noreply, socket}
+ end
+ end
+
+ def handle_info({:chat_e2ee_devices_changed, conversation_id}, socket) do
+ if socket.assigns.conversation.selected &&
+ socket.assigns.conversation.selected.id == conversation_id do
+ {:noreply,
+ assign(
+ socket,
+ :chat_e2ee_devices,
+ Messaging.list_chat_encryption_devices_for_conversation(conversation_id)
+ )}
+ else
+ {:noreply, socket}
+ end
+ end
+
+ def handle_info({:scroll_to_unread, _message_id}, socket) do
+ # Send event to scroll to the unread indicator
+ {:noreply,
+ push_event(socket, "scroll_to_element", %{
+ element_id: "unread-indicator",
+ position: "top-third"
+ })}
+ end
+
+ # Scroll handler for media messages - ensures images have time to load
+ def handle_info({:ensure_scroll_after_media, _message_id}, socket) do
+ # Trigger scroll to bottom - the client-side handler includes retries for image loading
+ {:noreply, push_event(socket, "scroll_to_bottom", %{})}
+ end
+
+ # Async load read status for faster initial render
+ def handle_info({:load_read_status, message_ids, conversation_id}, socket) do
+ # Only load if still viewing the same conversation
+ if socket.assigns.conversation.selected &&
+ socket.assigns.conversation.selected.id == conversation_id do
+ read_status = Messaging.get_read_status_for_messages(message_ids, conversation_id)
+
+ {:noreply, assign(socket, :message, %{socket.assigns.message | read_status: read_status})}
+ else
+ {:noreply, socket}
+ end
+ end
+
+ def handle_info({:mark_conversation_read, conversation_id}, socket) do
+ # Mark conversation as read after user has had time to see it
+ if socket.assigns.conversation.selected &&
+ socket.assigns.conversation.selected.id == conversation_id do
+ user_id = socket.assigns.current_user.id
+
+ # Update last read message ID to the last message in the conversation
+ read_result =
+ if socket.assigns[:messages] && socket.assigns.messages != [] do
+ last_message = List.last(socket.assigns.messages)
+ Messaging.update_last_read_message(conversation_id, user_id, last_message.id)
+ else
+ Messaging.mark_as_read(conversation_id, user_id)
+ end
+
+ # Mark any related notifications as read
+ # Chat notifications use source_type "message", so clear all message notifications in this conversation
+ if socket.assigns[:messages] && socket.assigns.messages != [] do
+ message_ids = Enum.map(socket.assigns.messages, & &1.id)
+ # This broadcasts :notification_updated and notification_count_updated automatically
+ Elektrine.Notifications.mark_as_read_by_sources(user_id, "message", message_ids)
+ end
+
+ # Update unread counts and clear unread indicator
+ updated_unread_counts =
+ Map.put(socket.assigns.conversation.unread_counts, conversation_id, 0)
+
+ if read_status_saved?(read_result) do
+ broadcast_chat_read_status(conversation_id, user_id)
+ end
+
+ {:noreply,
+ socket
+ |> assign(:conversation, %{
+ socket.assigns.conversation
+ | unread_counts: updated_unread_counts
+ })
+ |> assign(:first_unread_message_id, nil)}
+ else
+ {:noreply, socket}
+ end
+ end
+
+ def handle_info({:reply_to_message, message_id_str}, socket) do
+ reply_to_message =
+ case parse_positive_int(message_id_str) do
+ {:ok, message_id} -> Enum.find(socket.assigns.messages, &(&1.id == message_id))
+ :error -> nil
+ end
+
+ {:noreply, assign(socket, :message, %{socket.assigns.message | reply_to: reply_to_message})}
+ end
+
+ def handle_info({:user_read_messages, user_id}, socket) do
+ # User (possibly current user or another) read messages, update read status immediately
+
+ # Update conversation list read status
+ conversations = socket.assigns.conversation.list
+
+ last_message_read_status =
+ Helpers.calculate_last_message_read_status(conversations, socket.assigns.current_user.id)
+
+ socket =
+ assign(
+ socket,
+ :conversation,
+ Map.put(socket.assigns.conversation, :last_message_read_status, last_message_read_status)
+ )
+
+ # Also update message read status if viewing a conversation
+ socket =
+ if socket.assigns.conversation.selected do
+ conversation_id = socket.assigns.conversation.selected.id
+ message_ids = Enum.map(socket.assigns.messages, & &1.id)
+
+ # Fetch fresh read status
+ read_status =
+ if message_ids != [] do
+ Messaging.get_read_status_for_messages(message_ids, conversation_id)
+ else
+ %{}
+ end
+
+ # If the current user read messages, also clear the unread divider
+ socket =
+ socket
+ |> assign(:message, %{socket.assigns.message | read_status: read_status})
+
+ if user_id == socket.assigns.current_user.id do
+ assign(socket, :first_unread_message_id, nil)
+ else
+ socket
+ end
+ else
+ socket
+ end
+
+ {:noreply, socket}
+ end
+
+ def handle_info(
+ {:user_read_messages_in_conversation,
+ %{conversation_id: conversation_id, reader_id: _reader_id}},
+ socket
+ ) do
+ # Someone read messages in a conversation - update conversation list read status
+ conversations = socket.assigns.conversation.list
+
+ last_message_read_status =
+ Helpers.calculate_last_message_read_status(conversations, socket.assigns.current_user.id)
+
+ socket =
+ assign(
+ socket,
+ :conversation,
+ Map.put(socket.assigns.conversation, :last_message_read_status, last_message_read_status)
+ )
+
+ socket =
+ if socket.assigns.conversation.selected &&
+ socket.assigns.conversation.selected.id == conversation_id do
+ message_ids = Enum.map(socket.assigns.messages, & &1.id)
+
+ read_status =
+ if message_ids != [] do
+ Messaging.get_read_status_for_messages(message_ids, conversation_id)
+ else
+ %{}
+ end
+
+ assign(socket, :message, %{socket.assigns.message | read_status: read_status})
+ else
+ socket
+ end
+
+ {:noreply, socket}
+ end
+
+ def handle_info({:cancel_reply}, socket) do
+ {:noreply, assign(socket, :message, %{socket.assigns.message | reply_to: nil})}
+ end
+
+ def handle_info({:timeout_added, timeout}, socket) do
+ # Update timeout status in assigns
+ updated_status = Map.put(socket.assigns.moderation.user_timeout_status, timeout.user_id, true)
+ {:noreply, assign(socket, :user_timeout_status, updated_status)}
+ end
+
+ def handle_info({:timeout_removed, timeout}, socket) do
+ # Update timeout status in assigns
+ updated_status =
+ Map.put(socket.assigns.moderation.user_timeout_status, timeout.user_id, false)
+
+ {:noreply, assign(socket, :user_timeout_status, updated_status)}
+ end
+
+ def handle_info({:user_kicked, %{user_id: user_id, conversation_id: conversation_id}}, socket) do
+ if socket.assigns.conversation.selected &&
+ socket.assigns.conversation.selected.id == conversation_id do
+ # Refresh the conversation to remove the kicked member from the member list
+ updated_conversation =
+ socket.assigns.conversation.selected
+ |> Map.update!(:members, fn members ->
+ Enum.reject(members, fn member ->
+ member.user_id == user_id && is_nil(member.left_at)
+ end)
+ end)
+
+ # Update timeout status map to remove kicked user
+ updated_timeout_status = Map.delete(socket.assigns.moderation.user_timeout_status, user_id)
+
+ {:noreply,
+ socket
+ |> assign(:selected_conversation, updated_conversation)
+ |> assign(:user_timeout_status, updated_timeout_status)}
+ else
+ {:noreply, socket}
+ end
+ end
+
+ def handle_info({:member_joined, %{user_id: user_id, conversation_id: conversation_id}}, socket) do
+ # Refresh conversation list when someone joins
+ socket = maybe_schedule_conversation_refresh(socket, 50)
+
+ if socket.assigns.conversation.selected &&
+ socket.assigns.conversation.selected.id == conversation_id do
+ # Refresh the conversation to show the new member
+ case Messaging.get_conversation!(conversation_id, socket.assigns.current_user.id) do
+ {:ok, updated_conversation} ->
+ # Update timeout status for the new member
+ timeout_status =
+ Map.put(
+ socket.assigns.moderation.user_timeout_status,
+ user_id,
+ Messaging.user_timed_out?(user_id, conversation_id)
+ )
+
+ {:noreply,
+ socket
+ |> assign(:selected_conversation, updated_conversation)
+ |> assign(:user_timeout_status, timeout_status)}
+
+ {:error, _} ->
+ {:noreply, socket}
+ end
+ else
+ {:noreply, socket}
+ end
+ end
+
+ def handle_info({:added_to_conversation, %{conversation_id: _conversation_id}}, socket) do
+ {:noreply, maybe_schedule_conversation_refresh(socket, 50)}
+ end
+
+ def handle_info({:conversation_activity, %{conversation_id: _conversation_id}}, socket) do
+ {:noreply, maybe_schedule_conversation_refresh(socket, 50)}
+ end
+
+ def handle_info({:member_left, %{user_id: _user_id, conversation_id: conversation_id}}, socket) do
+ if socket.assigns.conversation.selected &&
+ socket.assigns.conversation.selected.id == conversation_id do
+ # Refresh the conversation to show updated member list
+ case Messaging.get_conversation!(conversation_id, socket.assigns.current_user.id) do
+ {:ok, updated_conversation} ->
+ {:noreply, assign(socket, :selected_conversation, updated_conversation)}
+
+ {:error, _} ->
+ {:noreply, socket}
+ end
+ else
+ {:noreply, socket}
+ end
+ end
+
+ def handle_info({:kicked_from_conversation, %{conversation_id: conversation_id}}, socket) do
+ # If the current user was kicked, redirect them away from the conversation
+ if socket.assigns.conversation.selected &&
+ socket.assigns.conversation.selected.id == conversation_id do
+ {:noreply,
+ socket
+ |> notify_error("You have been removed from this conversation")
+ |> push_navigate(to: Elektrine.Paths.chat_root_path())}
+ else
+ # Refresh conversations list to remove the conversation they were kicked from
+ all_conversations = Messaging.list_chat_conversations(socket.assigns.current_user.id)
+ conversations = Enum.reject(all_conversations, &(&1.type in ["timeline", "community"]))
+ {:noreply, assign(socket, :conversations, conversations)}
+ end
+ end
+
+ def handle_info({:conversation_deleted, conversation_id}, socket) do
+ # If viewing the deleted conversation, redirect to main chat
+ if socket.assigns.conversation.selected &&
+ socket.assigns.conversation.selected.id == conversation_id do
+ {:noreply,
+ socket
+ |> notify_info("This conversation has been disbanded")
+ |> push_navigate(to: Elektrine.Paths.chat_root_path())}
+ else
+ # Remove from conversations list
+ conversations = Enum.reject(socket.assigns.conversation.list, &(&1.id == conversation_id))
+ {:noreply, assign(socket, :conversations, conversations)}
+ end
+ end
+
+ def handle_info({:report_submitted, _reportable_type, _reportable_id}, socket) do
+ {:noreply,
+ socket
+ |> assign(:show_report_modal, false)
+ |> assign(:report_type, nil)
+ |> assign(:report_id, nil)
+ |> assign(:report_metadata, %{})
+ |> notify_info("Report submitted successfully. Our team will review it shortly.")}
+ end
+
+ def handle_info(:refresh_conversations, socket) do
+ # Refresh conversation list after a small delay for database consistency
+ all_conversations = Messaging.list_chat_conversations(socket.assigns.current_user.id)
+ # Filter out Timeline and Community channels from chat view
+ conversations_filtered =
+ Enum.reject(all_conversations, &(&1.type in ["timeline", "community"]))
+
+ unread_count = Messaging.get_unread_count(socket.assigns.current_user.id)
+
+ unread_counts =
+ Helpers.calculate_unread_counts(conversations_filtered, socket.assigns.current_user.id)
+
+ # Sort conversations with unread ones first
+ conversations =
+ Helpers.sort_conversations_by_unread(
+ conversations_filtered,
+ unread_counts,
+ socket.assigns.current_user.id
+ )
+
+ # Calculate read status for last messages
+ last_message_read_status =
+ Helpers.calculate_last_message_read_status(conversations, socket.assigns.current_user.id)
+
+ joined_servers = Messaging.list_servers(socket.assigns.current_user.id)
+
+ # Re-filter conversations if search is active
+ filtered_conversations =
+ refresh_conversation_filter(
+ conversations,
+ socket.assigns.search.conversation_query,
+ socket.assigns.current_user.id,
+ socket.assigns[:active_server_id]
+ )
+
+ {:noreply,
+ socket
+ |> assign(:conversation, %{
+ socket.assigns.conversation
+ | list: conversations,
+ filtered: filtered_conversations,
+ unread_count: unread_count,
+ unread_counts: unread_counts,
+ last_message_read_status: last_message_read_status
+ })
+ |> assign(:joined_servers, joined_servers)
+ |> assign(:refresh_conversations_scheduled, false)
+ |> VoiceChannelOperations.sync_voice_channels(conversations)}
+ end
+
+ @impl true
+ def handle_info(:clear_typing, socket) do
+ # Clear own typing indicator
+ case socket.assigns.conversation.selected do
+ nil ->
+ {:noreply, socket}
+
+ conversation ->
+ Phoenix.PubSub.broadcast_from(
+ Elektrine.PubSub,
+ self(),
+ "conversation:#{conversation.id}",
+ {:user_stopped_typing, socket.assigns.current_user.id}
+ )
+
+ Elektrine.Messaging.Federation.publish_typing_stopped(
+ conversation.id,
+ socket.assigns.current_user.id
+ )
+
+ {:noreply, assign(socket, :typing_timer, nil)}
+ end
+ end
+
+ def handle_info({:user_typing, user_id, username}, socket) do
+ # Add user to typing list if not already present
+ typing_users = socket.assigns.message.typing_users
+ current_time = System.system_time(:second)
+
+ # Clean up stale typing indicators (older than 5 seconds)
+ typing_users =
+ Enum.filter(typing_users, fn u ->
+ current_time - u.started_at < 5
+ end)
+
+ if Enum.any?(typing_users, fn u -> u.id == user_id end) do
+ # User already in typing list, just update the timestamp
+ updated_typing =
+ Enum.map(typing_users, fn u ->
+ if u.id == user_id do
+ %{id: user_id, username: username, started_at: current_time}
+ else
+ u
+ end
+ end)
+
+ {:noreply,
+ assign(socket, :message, %{socket.assigns.message | typing_users: updated_typing})}
+ else
+ # Add new user to typing list
+ new_typing_user = %{id: user_id, username: username, started_at: current_time}
+
+ {:noreply,
+ assign(socket, :message, %{
+ socket.assigns.message
+ | typing_users: [new_typing_user | typing_users]
+ })}
+ end
+ end
+
+ def handle_info({:user_stopped_typing, user_id}, socket) do
+ # Remove user from typing list
+ typing_users = Enum.reject(socket.assigns.message.typing_users, fn u -> u.id == user_id end)
+ {:noreply, assign(socket, :message, %{socket.assigns.message | typing_users: typing_users})}
+ end
+
+ def handle_info({:new_message_notification, message}, socket) do
+ # Keep this path light: update unread state locally and debounce full refreshes.
+ if socket.assigns.conversation.selected &&
+ socket.assigns.conversation.selected.id == message.conversation_id do
+ {:noreply, maybe_schedule_conversation_refresh(socket, 300)}
+ else
+ unread_counts = socket.assigns.conversation.unread_counts || %{}
+ updated_unread_counts = Map.update(unread_counts, message.conversation_id, 1, &(&1 + 1))
+
+ socket =
+ socket
+ |> assign(:conversation, %{
+ socket.assigns.conversation
+ | unread_count: (socket.assigns.conversation.unread_count || 0) + 1,
+ unread_counts: updated_unread_counts
+ })
+ |> update_conversation_preview(message)
+ |> maybe_schedule_conversation_refresh(500)
+
+ {:noreply, socket}
+ end
+ end
+
+ @impl true
+ def handle_info({:new_email, message}, socket) do
+ # Handle email notifications while in chat
+ payload =
+ if private_email?(message) do
+ %{
+ from: "Encrypted sender",
+ subject: "Encrypted message"
+ }
+ else
+ %{
+ from: extract_email_address(message.from || "Unknown"),
+ subject: String.slice(message.subject || "No Subject", 0, 100)
+ }
+ end
+
+ socket = push_event(socket, "new_email", payload)
+
+ {:noreply, socket}
+ end
+
+ @impl true
+ # Chat message handlers (from ChatMessages module for DMs/groups/channels)
+ def handle_info({:new_chat_message, message}, socket) do
+ if socket.assigns.conversation.selected &&
+ socket.assigns.conversation.selected.id == message.conversation_id do
+ if Enum.any?(socket.assigns.messages, &(&1.id == message.id)) do
+ {:noreply, socket}
+ else
+ # ChatMessage is already decrypted from the broadcast
+ messages = Helpers.dedupe_messages(socket.assigns.messages ++ [message])
+
+ # Update read status for the new message
+ current_read_status = socket.assigns.message.read_status || %{}
+ updated_read_status = Map.put(current_read_status, message.id, [])
+
+ # If this is not my message, mark it as read immediately
+ if message.sender_id != socket.assigns.current_user.id do
+ user_id = socket.assigns.current_user.id
+
+ if read_status_saved?(
+ Messaging.mark_chat_messages_read(message.conversation_id, user_id, message.id)
+ ) do
+ broadcast_chat_read_status(message.conversation_id, user_id)
+ end
+ end
+
+ updated_unread_counts =
+ Map.put(socket.assigns.conversation.unread_counts || %{}, message.conversation_id, 0)
+
+ updated_socket =
+ socket
+ |> assign(:messages, messages)
+ |> assign(:message, %{socket.assigns.message | read_status: updated_read_status})
+ |> assign(:conversation, %{
+ socket.assigns.conversation
+ | unread_counts: updated_unread_counts
+ })
+ |> update_conversation_preview(message)
+
+ # Scroll to bottom
+ {:noreply, push_event(updated_socket, "scroll_to_bottom", %{})}
+ end
+ else
+ {:noreply, maybe_schedule_conversation_refresh(socket, 200)}
+ end
+ end
+
+ def handle_info(:room_presence_heartbeat, socket) do
+ {:noreply, refresh_room_presence_tracking(socket)}
+ end
+
+ def handle_info(:resume_active_call, socket) do
+ {:noreply, maybe_resume_active_call(socket)}
+ end
+
+ def handle_info(info, socket) do
+ case ThreadOperations.route_info(info, socket) do
+ {:handled, result} ->
+ result
+
+ :unhandled ->
+ case MessageInfoOperations.route_info(info, socket) do
+ {:handled, result} ->
+ result
+
+ :unhandled ->
+ case CallInfoOperations.route_info(info, socket) do
+ {:handled, result} ->
+ result
+
+ :unhandled ->
+ case VoiceChannelOperations.route_info(info, socket) do
+ {:handled, result} -> result
+ :unhandled -> {:noreply, socket}
+ end
+ end
+ end
+ end
+ end
+
+ defp chat_overlay_panels(assigns) do
+ ~H"""
+
+ <%= if @ui.show_server_modal do %>
+
+
+
+
Create Server
+
+ <.icon name="hero-x-mark" class="w-5 h-5" />
+
+
+
+
+
+
+ Server Name
+
+
+
+
+
+
+ Description
+
+
+
+
+
+
+ Server Icon
+
+
+ <%= if @uploads.server_icon_upload.entries != [] do %>
+ <% entry = List.first(@uploads.server_icon_upload.entries) %>
+
+ <.live_img_preview entry={entry} class="w-full h-full object-cover" />
+
+ <% else %>
+
+ <.icon name="hero-photo" class="w-6 h-6 text-base-content/60" />
+
+ <% end %>
+
+ Choose Image
+ <.live_file_input
+ upload={@uploads.server_icon_upload}
+ class="hidden"
+ phx-change="validate_upload"
+ />
+
+
+ <%= for entry <- @uploads.server_icon_upload.entries do %>
+
+
{entry.client_name}
+
+
+
+ <.icon name="hero-x-mark" class="w-3 h-3" />
+
+
+ <% end %>
+
+
+
+
+
+ Show in server directory
+
+
+ Public servers are discoverable and users can request membership.
+
+
+
+
+
+ <.icon name="hero-plus-circle" class="w-4 h-4 mr-2" /> Create Server
+
+
+ Cancel
+
+
+
+
+
+ <% end %>
+
+
+ <%= if @ui.show_category_modal do %>
+
+
+
+
Create Category
+
+ <.icon name="hero-x-mark" class="w-5 h-5" />
+
+
+
+
+
+
+ Category Name
+
+
+
+ Categories group this server's channels in the sidebar. Assign channels to a
+ category when creating them.
+
+
+
+
+
+ <.icon name="hero-folder-plus" class="w-4 h-4 mr-2" /> Create Category
+
+
+ Cancel
+
+
+
+
+
+ <% end %>
+
+
+ <%= if @ui.show_settings_modal && @conversation.selected do %>
+
+
+
+
+ {conversation_type_label(@conversation.selected.type)} Settings
+
+
+ <.icon name="hero-x-mark" class="w-4 h-4" />
+
+
+
+
+
+
+
+ Name
+
+
+ {route_label(@conversation.selected, @current_user.id)}
+
+
+
+ <%= if @conversation.selected.description do %>
+
+
+ Description
+
+
+ {@conversation.selected.description}
+
+
+ <% end %>
+
+
+
+ Type
+
+
+ <%= case @conversation.selected.type do %>
+ <% "group" -> %>
+ <.icon name="hero-users" class="w-4 h-4" />
+
+ {if @conversation.selected.is_public, do: "Public", else: "Private"} Group
+
+ <% "channel" -> %>
+ <.icon name="hero-megaphone" class="w-4 h-4" />
+
+ <%= if @conversation.selected.server_id do %>
+ {if @conversation.selected.is_public, do: "Server", else: "Private Server"} Channel
+ <% else %>
+ {if @conversation.selected.is_public, do: "Public", else: "Private"} Channel
+ <% end %>
+
+ <% _ -> %>
+ <.icon name="hero-user" class="w-4 h-4" />
+ Direct Message
+ <% end %>
+
+
+
+
+
+ Members
+
+
+ {@conversation.selected.member_count} members
+
+
+
+ <%= if @conversation.selected.creator_id do %>
+
+
+ Created by
+
+ <% creator =
+ Enum.find(
+ @conversation.selected.members,
+ &(&1.user_id == @conversation.selected.creator_id)
+ ) %>
+ <%= if creator do %>
+
+ {user_at_handle(creator.user)}
+
+ <% end %>
+
+ <% end %>
+
+
+
+ <% current_member =
+ Enum.find(
+ @conversation.selected.members,
+ &(&1.user_id == @current_user.id and is_nil(&1.left_at))
+ ) %>
+ <%= if current_member && current_member.role == "admin" && @conversation.selected.type != "dm" do %>
+
Admin Actions
+
+
+ <.icon name="hero-pencil" class="w-4 h-4 mr-2" />
+ Edit {conversation_type_label(@conversation.selected.type)}
+
+ <%= if @conversation.selected.creator_id == @current_user.id do %>
+
+ <.icon name="hero-trash" class="w-4 h-4 mr-2" />
+ Delete {conversation_type_label(@conversation.selected.type)}
+
+ <% end %>
+
+ <% end %>
+
+
+ <% end %>
+
+
+ <%= if @ui.show_edit_modal && @conversation.selected do %>
+
+
+
+
+ Edit {conversation_type_label(@conversation.selected.type)}
+
+
+ <.icon name="hero-x-mark" class="w-4 h-4" />
+
+
+
+ <.form for={%{}} phx-submit="update_conversation" class="space-y-4">
+
+
+ Name
+
+
+
+
+
+
+ Description
+
+ {@form.edit_description}
+
+
+ <%= if @conversation.selected.type == "group" do %>
+
+ <% end %>
+
+ <%= if @conversation.selected.type == "channel" && @conversation.selected.server_id do %>
+
+ <% end %>
+
+ <%= if @conversation.selected.type == "channel" && is_nil(@conversation.selected.server_id) do %>
+
+ <% end %>
+
+
+
+ <.icon name="hero-check" class="w-4 h-4 mr-2" /> Save Changes
+
+
+ Cancel
+
+
+
+
+
+ <% end %>
+
+
+ <%= if @ui.show_add_members_modal && @conversation.selected do %>
+
+
+
+
+ Add Members to {route_label(@conversation.selected, @current_user.id)}
+
+
+ <.icon name="hero-x-mark" class="w-4 h-4" />
+
+
+
+
+ <%= if @pending_remote_join_requests != [] do %>
+
+
+
Pending Remote Join Requests
+
+ Review remote participants waiting for room approval.
+
+
+
+
+ <%= for request <- @pending_remote_join_requests do %>
+
+
+
+ <%= if avatar_url = safe_chat_image_url(request.avatar_url) do %>
+
+ <% else %>
+ <.icon name="hero-globe-alt" class="w-4 h-4 opacity-60" />
+ <% end %>
+
+
+
{request.display_label}
+
+ Requested role: {request.role} · {request.origin_domain}
+
+
+
+
+
+
+ Approve
+
+
+ Decline
+
+
+
+ <% end %>
+
+
+ <% end %>
+
+
+
+
+
+
+
+ <%= if @search.results != [] do %>
+
+ <%= for user <- @search.results do %>
+
+
+
+ <.user_avatar user={user} size="sm" user_statuses={@user_statuses} />
+
+
+
+ <.username_with_effects
+ user={user}
+ display_name={true}
+ verified_size="xs"
+ />
+
+
{user_at_handle(user)}
+
+
+
+ Add
+
+
+ <% end %>
+
+ <% else %>
+
+ <.icon name="hero-magnifying-glass" class="w-8 h-8 mx-auto opacity-50 mb-2" />
+
Search for users to add
+
+ <% end %>
+
+
+
+ <% end %>
+
+
+ <%= if @ui.show_message_search_modal && @conversation.selected do %>
+
+
+
+
+ Search Messages in {route_label(
+ @conversation.selected,
+ @current_user.id
+ )}
+
+
+ <.icon name="hero-x-mark" class="w-4 h-4" />
+
+
+
+
+
+
+
+
+
+
+ <%= if @search.message_results != [] do %>
+
+ <%= for message <- @search.message_results do %>
+
+
+
+ <.user_avatar
+ user={message_sender(message)}
+ size="xs"
+ user_statuses={@user_statuses}
+ />
+
+
+ {message_sender_tag(message)}
+
+
+ <.local_time
+ datetime={message.inserted_at}
+ format="datetime"
+ timezone={@timezone}
+ time_format={@time_format}
+ />
+
+
+ <% client_encrypted_payload =
+ Map.get(message, :client_encrypted_payload) ||
+ Map.get(message, "client_encrypted_payload") %>
+ <%= if client_encrypted_payload do %>
+
+ Decrypting encrypted message...
+
+ <% else %>
+
{message.content}
+ <% end %>
+
+ <% end %>
+
+ <% else %>
+
+ <.icon name="hero-chat-bubble-left" class="w-8 h-8 mx-auto opacity-50 mb-2" />
+
+ <%= if @search.message_query == "" do %>
+ Type to search messages
+ <% else %>
+ No messages found
+ <% end %>
+
+
+ <% end %>
+
+
+
+ <% end %>
+
+
+ <%= if @context_menu.conversation do %>
+
+ <% member =
+ Enum.find(
+ @context_menu.conversation.members,
+ &(&1.user_id == @current_user.id and is_nil(&1.left_at))
+ ) %>
+
+
+ <%= if member && member.pinned do %>
+
+ <.icon name="hero-bookmark" class="w-4 h-4" /> Unpin
+
+ <% else %>
+
+ <.icon name="hero-bookmark" class="w-4 h-4" /> Pin to Top
+
+ <% end %>
+
+
+
+ <.icon name="hero-check-circle" class="w-4 h-4" /> Mark as Read
+
+
+
+
+
+
+ <.icon name="hero-trash" class="w-4 h-4" /> Clear History
+
+
+
+ <%= if @context_menu.conversation.type != "dm" do %>
+
+ <.icon name="hero-arrow-left-on-rectangle" class="w-4 h-4" />
+ Leave {conversation_type_label(@context_menu.conversation.type)}
+
+ <% end %>
+
+ <% end %>
+
+
+ <%= if @context_menu.message do %>
+
+ <%= if @context_menu.selected_text do %>
+
+ <% end %>
+
+
+ <.icon name="hero-clipboard" class="w-4 h-4" /> Copy Message
+
+
+
+
+ <.icon name="hero-arrow-uturn-left" class="w-4 h-4" /> Reply
+
+
+
+ <%= if @conversation.selected && @conversation.selected.type == "channel" && @context_menu.message.message_type != "system" do %>
+ <% existing_thread = thread_for_message(@threads, @context_menu.message.id) %>
+ <%= if existing_thread do %>
+
+ <.icon name="hero-chat-bubble-left-right" class="w-4 h-4" /> Open Thread
+
+ <% else %>
+
+ <.icon name="hero-chat-bubble-left-right" class="w-4 h-4" /> Create Thread
+
+ <% end %>
+ <% end %>
+
+
+ <%= if Map.get(@context_menu.message, :is_pinned, false) do %>
+
+ <.icon name="hero-bookmark-slash" class="w-4 h-4" /> Unpin Message
+
+ <% else %>
+
+ <.icon name="hero-bookmark" class="w-4 h-4" /> Pin Message
+
+ <% end %>
+
+ <%= if @context_menu.message.sender_id == @current_user.id do %>
+
+
+
+
+ <.icon name="hero-trash" class="w-4 h-4" /> Delete Message
+
+ <% end %>
+
+ <%= if @current_user.is_admin do %>
+
+
+
+ Admin Actions
+
+
+
+
+ <.icon name="hero-trash" class="w-4 h-4" /> Delete Message
+
+
+ <%= if is_integer(@context_menu.message.sender_id) do %>
+
+
+ <.icon name="hero-clock" class="w-4 h-4" /> Timeout User (5min)
+
+
+
+
+ <.icon name="hero-clock" class="w-4 h-4" /> Timeout User (1hr)
+
+ <% end %>
+ <% end %>
+
+ <% end %>
+
+
+ <%= if @ui.show_profile_modal && @profile_user do %>
+
+
+
+
User Profile
+
+ <.icon name="hero-x-mark" class="w-4 h-4" />
+
+
+
+
+
+
+
+ <.user_avatar user={@profile_user} size="2xl" />
+
+
+
+
+ <.username_with_effects user={@profile_user} display_name={true} verified_size="md" />
+
+
{user_at_handle(@profile_user)}
+
+ <%= if Ecto.assoc_loaded?(@profile_user.profile) && @profile_user.profile && @profile_user.profile.description do %>
+
+
{@profile_user.profile.description}
+
+ <% end %>
+
+ <%= if Ecto.assoc_loaded?(@profile_user.profile) && @profile_user.profile && @profile_user.profile.location do %>
+
+ <.icon name="hero-map-pin" class="w-4 h-4 opacity-70" />
+ {@profile_user.profile.location}
+
+ <% end %>
+
+
+
+
+
+
+ <.icon name="hero-user" class="w-4 h-4 mr-2" /> View Full Profile
+
+
+ <%= if @profile_user.id != @current_user.id do %>
+
+
+ <.icon name="hero-chat-bubble-left-right" class="w-4 h-4 mr-2" /> Send Message
+
+
+
+ <%= if Elektrine.Accounts.user_blocked?(@current_user.id, @profile_user.id) do %>
+
+ <.icon name="hero-check" class="w-4 h-4 mr-2" /> Unblock User
+
+ <% else %>
+
+ <.icon name="hero-no-symbol" class="w-4 h-4 mr-2" /> Block User
+
+ <% end %>
+
+
+
+ <.icon name="hero-flag" class="w-4 h-4 mr-2" /> Report User
+
+ <% end %>
+
+
+
+ <% end %>
+
+
+ <%= if @ui.show_browse_modal do %>
+
+
+
+
Explore Servers and Groups
+
+ <.icon name="hero-x-mark" class="w-4 h-4" />
+
+
+
+
+
+
+
+ <.icon name="hero-globe-alt" class="w-4 h-4 mr-2" /> Servers
+
+
+ <.icon name="hero-users" class="w-4 h-4 mr-2" /> Public Groups
+
+
+
+
+
+ "Search servers..."
+ _ -> "Search groups..."
+ end
+ }
+ class="input input-bordered w-full"
+ value={@search.browse_query}
+ phx-debounce="300"
+ phx-change="browse_search"
+ name="search"
+ />
+
+
+
+
+ <%= if @browse.tab == "servers" do %>
+ <%= if @browse.filtered_servers == [] do %>
+
+ <.icon name="hero-globe-alt" class="w-8 h-8 mx-auto opacity-50 mb-2" />
+
No servers found
+
Check back soon for newly shared servers.
+
+ <% else %>
+
+ <%= for server <- @browse.filtered_servers do %>
+
+
+
+ <.icon name="hero-globe-alt" class="w-4 h-4 opacity-70" />
+
{server.name}
+ <%= if server.is_federated_mirror do %>
+
+ From another server
+
+ <% end %>
+
+ <%= if server.description do %>
+
{server.description}
+ <% end %>
+
+
+ {server.member_count} members
+
+
+ {if server.origin_domain,
+ do: "from #{server.origin_domain}",
+ else: "created here"}
+
+
+ {if server.creator,
+ do: "by @#{server.creator.username}",
+ else: "shared server"}
+
+
+
+
+ Join Server
+
+
+ <% end %>
+
+ <% end %>
+ <% else %>
+ <%= if @browse.filtered_groups == [] do %>
+
+ <.icon name="hero-users" class="w-8 h-8 mx-auto opacity-50 mb-2" />
+
No public groups found
+
Create the first public group chat.
+
+ <% else %>
+
+ <%= for group <- @browse.filtered_groups do %>
+
+
+
+ <.icon name="hero-users" class="w-4 h-4 opacity-70" />
+
{group.name}
+
+ <%= if group.description do %>
+
{group.description}
+ <% end %>
+
+
+ {group.member_count} members
+
+
+ by {user_at_handle(group.creator)}
+
+
+
+
+ Join Group
+
+
+ <% end %>
+
+ <% end %>
+ <% end %>
+
+
+
+
+ <% end %>
+
+
+ <%= if assigns[:show_member_management] && @ui.show_member_management do %>
+
+
+
+
+
+ <.icon name="hero-users" class="w-5 h-5 mr-2" /> Manage Members
+
+
+ <.icon name="hero-x-mark" class="w-4 h-4" />
+
+
+
+
+
+ <%= if @conversation.selected && @conversation.selected.members do %>
+
+ <%= for member <- @conversation.selected.members do %>
+ <%= if is_nil(member.left_at) do %>
+
+
+
+ <.user_avatar user={member.user} size="sm" user_statuses={@user_statuses} />
+
+
+
+ <.username_with_effects
+ user={member.user}
+ display_name={true}
+ verified_size="xs"
+ />
+
+
+ {user_at_handle(member.user)}
+
+ <%= if member.user.is_admin do %>
+
Admin
+ <% end %>
+ <%= if member.role == "admin" do %>
+
Chat Admin
+ <% end %>
+
+
+
+ <%= if (@current_user.is_admin or Helpers.conversation_admin?(@conversation.selected, @current_user)) && member.user_id != @current_user.id do %>
+
+ <%= if Map.get(@moderation.user_timeout_status, member.user_id, false) do %>
+
+ <.icon name="hero-clock" class="w-3 h-3" />
+
+ <% else %>
+
+
+ <.icon name="hero-shield-exclamation" class="w-3 h-3" />
+
+
+
+ <% end %>
+
+
+ <.icon name="hero-user-minus" class="w-3 h-3" />
+
+
+ <% end %>
+
+ <% end %>
+ <% end %>
+
+ <% end %>
+
+
+
+ <% end %>
+
+
+ <%= if assigns[:show_moderation_log] && @ui.show_moderation_log do %>
+
+
+
+
+
+ <.icon name="hero-clipboard-document-list" class="w-5 h-5 mr-2" /> Moderation Log
+
+
+ <.icon name="hero-x-mark" class="w-4 h-4" />
+
+
+
+
+
+ <%= if @moderation.log == [] do %>
+
+ <.icon name="hero-clipboard-document-list" class="w-16 h-16 mx-auto mb-4" />
+
No moderation actions recorded
+
+ <% else %>
+
+ <%= for action <- @moderation.log do %>
+
+
+ <%= case action.action_type do %>
+ <% "timeout" -> %>
+
+ <.icon name="hero-clock" class="w-4 h-4 text-warning" />
+
+ <% "kick" -> %>
+
+ <.icon name="hero-user-minus" class="w-4 h-4 text-error" />
+
+ <% "delete_message" -> %>
+
+ <.icon name="hero-trash" class="w-4 h-4 text-error" />
+
+ <% _ -> %>
+
+ <.icon name="hero-shield-exclamation" class="w-4 h-4" />
+
+ <% end %>
+
+
+
+
+ {String.capitalize(action.action_type)}
+
+ <.local_time
+ datetime={action.inserted_at}
+ format="datetime"
+ timezone={@timezone}
+ time_format={@time_format}
+ />
+
+
+
+
+ {action.moderator.username}
+ {action.action_type}ed
+
+ {action.target_user.handle || action.target_user.username}
+
+ <%= if action.conversation do %>
+ in chat #{action.conversation.name}
+ <% end %>
+ <%= if action.duration do %>
+ for
+
+ {Helpers.format_duration(action.duration)}
+
+ <% end %>
+
+
+ <%= if action.reason do %>
+
+ Reason: {action.reason}
+
+ <% end %>
+
+
+ <% end %>
+
+ <% end %>
+
+
+
+ <% end %>
+
+
+ <%= if @show_report_modal do %>
+ <.live_component
+ module={Elektrine.Components.ReportModal}
+ id="report-modal"
+ reporter_id={@current_user.id}
+ reportable_type={@report_type}
+ reportable_id={@report_id}
+ additional_metadata={@report_metadata}
+ />
+ <% end %>
+
+ <%= if @call.incoming_call do %>
+ <.incoming_call_modal call={@call.incoming_call} show={@ui.show_incoming_call} />
+ <% end %>
+
+ <%= if @call.active_call do %>
+ <.active_call_overlay
+ call={@call.active_call}
+ show={true}
+ audio_enabled={@call.audio_enabled}
+ video_enabled={@call.video_enabled}
+ call_status={@call.status}
+ is_caller={@call.active_call.caller_id == @current_user.id}
+ />
+ <% end %>
+
+ <.image_modal
+ show={@show_image_modal}
+ image_url={@modal_image_url}
+ images={@modal_images}
+ image_index={@modal_image_index}
+ post={nil}
+ timezone={@timezone}
+ time_format={@time_format}
+ current_user={nil}
+ is_liked={false}
+ like_count={nil}
+ />
+ """
+ end
+
+ @impl true
+ def terminate(_reason, socket) do
+ # Clean up any active calls when user disconnects
+ if socket.assigns.call && socket.assigns.call.active_call do
+ case socket.assigns.call.active_call do
+ %{source: :federated, id: session_id} ->
+ VoiceCalls.end_session(session_id, socket.assigns.current_user.id, "disconnected")
+ Elektrine.Messaging.Federation.publish_dm_call_end(session_id)
+
+ %{id: call_id} ->
+ Calls.end_call(call_id)
+ end
+ end
+
+ :ok
+ end
+
+ defp maybe_schedule_conversation_refresh(socket, delay_ms) do
+ if socket.assigns[:refresh_conversations_scheduled] do
+ socket
+ else
+ Process.send_after(self(), :refresh_conversations, delay_ms)
+ assign(socket, :refresh_conversations_scheduled, true)
+ end
+ end
+
+ # Voice channels have no text timeline; they are joined from the sidebar
+ # instead of being opened as a conversation.
+ defp open_conversation_or_redirect(socket, %{type: "voice_channel"}) do
+ socket
+ |> notify_info("Voice channels are joined from the sidebar")
+ |> push_navigate(to: Elektrine.Paths.chat_root_path())
+ end
+
+ defp open_conversation_or_redirect(socket, conversation),
+ do: open_conversation(socket, conversation)
+
+ defp can_write_to_conversation?(conversation_id, user_id) do
+ RoomACL.authorize_local_user_action(conversation_id, user_id, :write) == :ok
+ end
+
+ defp open_conversation(socket, conversation) do
+ conversation_id = conversation.id
+ active_server_id = conversation_server_id(conversation) || socket.assigns[:active_server_id]
+
+ federation_presence =
+ presence_map_for_conversation(conversation, socket.assigns.current_user.id)
+
+ current_unread_counts = socket.assigns.conversation.unread_counts || %{}
+ updated_unread_counts = Map.put(current_unread_counts, conversation_id, 0)
+
+ current_member =
+ Enum.find(
+ conversation.members,
+ &(&1.user_id == socket.assigns.current_user.id and is_nil(&1.left_at))
+ )
+
+ can_send =
+ current_member &&
+ Elektrine.Social.ConversationMember.can_send_messages?(current_member) &&
+ can_write_to_conversation?(conversation_id, socket.assigns.current_user.id)
+
+ updated_socket =
+ socket
+ |> maybe_update_conversation_subscriptions(conversation_id)
+ |> assign(:conversation, %{
+ socket.assigns.conversation
+ | selected: conversation,
+ unread_counts: updated_unread_counts,
+ filtered:
+ refresh_conversation_filter(
+ socket.assigns.conversation.list,
+ socket.assigns.search.conversation_query,
+ socket.assigns.current_user.id,
+ active_server_id
+ )
+ })
+ |> assign(:active_server_id, active_server_id)
+ |> assign(:server_categories, Helpers.server_categories(active_server_id))
+ |> assign(:federation_presence, federation_presence)
+ |> assign(
+ :chat_e2ee_devices,
+ Messaging.list_chat_encryption_devices_for_conversation(conversation_id)
+ )
+ |> assign(:messages, [])
+ |> assign(:pinned_messages, Messaging.list_pinned_chat_messages(conversation_id))
+ |> assign(:threads, %ArblargWeb.ChatLive.State.Threads{
+ list:
+ if(conversation.type == "channel",
+ do: Messaging.list_chat_threads(conversation_id),
+ else: []
+ )
+ })
+ |> assign(:message, %{
+ socket.assigns.message
+ | read_status: %{},
+ typing_users: []
+ })
+ |> assign(:moderation, %{
+ socket.assigns.moderation
+ | user_timeout_status: %{}
+ })
+ |> assign(:can_send_messages, can_send)
+ |> assign(:first_unread_message_id, nil)
+ |> assign(:typing_timer, nil)
+ |> assign(:has_more_older_messages, false)
+ |> assign(:has_more_newer_messages, false)
+ |> assign(:oldest_message_id, nil)
+ |> assign(:newest_message_id, nil)
+ |> assign(:loading_older_messages, false)
+ |> assign(:loading_newer_messages, false)
+ |> assign(:initial_messages_loading, true)
+
+ updated_socket =
+ if connected?(updated_socket) do
+ send(self(), {:load_conversation_messages, conversation_id})
+ refresh_room_presence_tracking(updated_socket)
+ else
+ updated_socket
+ end
+
+ updated_socket
+ end
+
+ defp maybe_update_conversation_subscriptions(socket, conversation_id) do
+ if connected?(socket) do
+ previous_conversation_id =
+ socket.assigns.conversation.selected && socket.assigns.conversation.selected.id
+
+ if is_integer(previous_conversation_id) and previous_conversation_id != conversation_id do
+ Phoenix.PubSub.unsubscribe(Elektrine.PubSub, "conversation:#{previous_conversation_id}")
+ Phoenix.PubSub.unsubscribe(Elektrine.PubSub, "chat:#{previous_conversation_id}")
+ end
+
+ if previous_conversation_id != conversation_id do
+ Phoenix.PubSub.subscribe(Elektrine.PubSub, "conversation:#{conversation_id}")
+ Phoenix.PubSub.subscribe(Elektrine.PubSub, "chat:#{conversation_id}")
+ end
+ end
+
+ socket
+ end
+
+ # Group messages that share an author when they arrive close together, so
+ # follow-up lines can drop the repeated avatar/name header. A new header is
+ # forced by a date change, a different sender, a system message, or a gap
+ # longer than @message_group_gap_seconds.
+ @message_group_gap_seconds 300
+
+ defp messages_with_date_separators(messages) when is_list(messages) do
+ {entries, _prev} =
+ Enum.reduce(messages, {[], nil}, fn message, {acc, previous} ->
+ current_date = message_inserted_date(message)
+
+ show_separator =
+ is_nil(previous) or Date.compare(current_date, previous.date) != :eq
+
+ grouped =
+ not show_separator and not is_nil(previous) and
+ message.message_type != "system" and previous.type != "system" and
+ message_sender_key(message) == previous.key and
+ within_group_gap?(message, previous.inserted_at)
+
+ current = %{
+ date: current_date,
+ key: message_sender_key(message),
+ type: message.message_type,
+ inserted_at: message.inserted_at
+ }
+
+ {[{message, show_separator, grouped} | acc], current}
+ end)
+
+ Enum.reverse(entries)
+ end
+
+ defp messages_with_date_separators(_), do: []
+
+ # Stable per-author key: local users key by id, federated senders by handle.
+ defp message_sender_key(message) do
+ case local_sender_id(message) do
+ id when is_integer(id) -> {:local, id}
+ _ -> {:remote, message_sender_name(message)}
+ end
+ end
+
+ defp within_group_gap?(%{inserted_at: current}, previous)
+ when not is_nil(current) and not is_nil(previous) do
+ case {to_naive(current), to_naive(previous)} do
+ {%NaiveDateTime{} = c, %NaiveDateTime{} = p} ->
+ diff = NaiveDateTime.diff(c, p)
+ diff >= 0 and diff <= @message_group_gap_seconds
+
+ _ ->
+ false
+ end
+ end
+
+ defp within_group_gap?(_, _), do: false
+
+ defp to_naive(%NaiveDateTime{} = naive), do: naive
+ defp to_naive(%DateTime{} = datetime), do: DateTime.to_naive(datetime)
+ defp to_naive(_), do: nil
+
+ defp read_status_saved?(:ok), do: true
+ defp read_status_saved?({:ok, _}), do: true
+ defp read_status_saved?(_), do: false
+
+ defp broadcast_chat_read_status(conversation_id, user_id) do
+ Phoenix.PubSub.broadcast(
+ Elektrine.PubSub,
+ "conversation:#{conversation_id}",
+ {:user_read_messages, user_id}
+ )
+
+ conversation_id
+ |> Messaging.get_conversation_members()
+ |> Enum.each(fn member ->
+ Phoenix.PubSub.broadcast(
+ Elektrine.PubSub,
+ "user:#{member.user_id}",
+ {:user_read_messages_in_conversation,
+ %{conversation_id: conversation_id, reader_id: user_id}}
+ )
+ end)
+ end
+
+ defp message_inserted_date(%{inserted_at: %NaiveDateTime{} = inserted_at}),
+ do: NaiveDateTime.to_date(inserted_at)
+
+ defp message_inserted_date(%{inserted_at: %DateTime{} = inserted_at}),
+ do: DateTime.to_date(inserted_at)
+
+ defp message_inserted_date(_), do: Date.utc_today()
+
+ defp update_conversation_preview(socket, message) do
+ conversations = socket.assigns.conversation.list
+
+ {updated_conversations, conversation_found?} =
+ Enum.map_reduce(conversations, false, fn conversation, found? ->
+ if conversation.id == message.conversation_id do
+ last_message_at =
+ case message.inserted_at do
+ %DateTime{} = datetime -> datetime
+ %NaiveDateTime{} = naive_datetime -> DateTime.from_naive!(naive_datetime, "Etc/UTC")
+ _ -> conversation.last_message_at
+ end
+
+ {%{conversation | messages: [message], last_message_at: last_message_at}, true}
+ else
+ {conversation, found?}
+ end
+ end)
+
+ if conversation_found? do
+ sorted_conversations =
+ Helpers.sort_conversations_by_unread(
+ updated_conversations,
+ socket.assigns.conversation.unread_counts || %{},
+ socket.assigns.current_user.id
+ )
+
+ assign(socket, :conversation, %{
+ socket.assigns.conversation
+ | list: sorted_conversations,
+ filtered:
+ refresh_conversation_filter(
+ sorted_conversations,
+ socket.assigns.search.conversation_query,
+ socket.assigns.current_user.id,
+ socket.assigns[:active_server_id]
+ )
+ })
+ else
+ socket
+ end
+ end
+
+ defp refresh_conversation_filter(conversations, query, current_user_id, active_server_id) do
+ scoped_conversations = Helpers.scope_conversations_to_server(conversations, active_server_id)
+
+ case String.trim(query || "") do
+ "" ->
+ scoped_conversations
+
+ search_query ->
+ Helpers.filter_conversations(scoped_conversations, search_query, current_user_id)
+ end
+ end
+
+ defp get_cached_conversations(user_id) do
+ case Elektrine.AppCache.get_conversations(user_id, fn ->
+ all_conversations = Messaging.list_chat_conversations(user_id)
+ Enum.reject(all_conversations, &(&1.type in ["timeline", "community"]))
+ end) do
+ {:ok, conversations} ->
+ conversations
+
+ {:error, reason} ->
+ Logger.warning(
+ "failed to fetch cached conversations for user #{user_id}: #{inspect(reason)}"
+ )
+
+ all_conversations = Messaging.list_chat_conversations(user_id)
+ Enum.reject(all_conversations, &(&1.type in ["timeline", "community"]))
+ end
+ end
+
+ defp get_cached_unread_count(user_id) do
+ case Elektrine.AppCache.get_chat_unread_count(user_id, fn ->
+ Messaging.get_unread_count(user_id)
+ end) do
+ {:ok, unread_count} ->
+ unread_count
+
+ {:error, reason} ->
+ Logger.warning(
+ "failed to fetch cached unread count for user #{user_id}: #{inspect(reason)}"
+ )
+
+ Messaging.get_unread_count(user_id)
+ end
+ end
+
+ defp build_federation_preview do
+ config = Application.get_env(:elektrine, :messaging_federation, [])
+
+ %{
+ enabled: Keyword.get(config, :enabled, false),
+ relay_operator:
+ config
+ |> Keyword.get(:official_relay_operator, "Community-operated")
+ |> normalize_relay_operator_label(),
+ official_relays:
+ config
+ |> Keyword.get(:official_relays, [])
+ |> Enum.map(&normalize_official_relay/1)
+ |> Enum.reject(&is_nil/1)
+ }
+ end
+
+ defp normalize_official_relay(relay) when is_binary(relay) do
+ case String.trim(relay) do
+ "" -> nil
+ url -> %{url: url, name: nil}
+ end
+ end
+
+ defp normalize_official_relay(relay) when is_map(relay) do
+ url = relay[:url] || relay["url"]
+ name = relay[:name] || relay["name"]
+
+ if Elektrine.Strings.present?(url) do
+ normalized_name =
+ if Elektrine.Strings.present?(name) do
+ String.trim(name)
+ else
+ nil
+ end
+
+ %{url: String.trim(url), name: normalized_name}
+ else
+ nil
+ end
+ end
+
+ defp normalize_official_relay(_), do: nil
+
+ defp normalize_relay_operator_label(value) do
+ value
+ |> to_string()
+ |> String.trim()
+ |> case do
+ "" ->
+ "Community-operated"
+
+ label ->
+ label
+ end
+ end
+
+ # Delegate helper functions for use in templates
+ defdelegate conversation_name(conversation, current_user_id), to: Helpers
+ defdelegate format_duration(seconds), to: Helpers
+ defdelegate popular_emojis(), to: Helpers
+ defdelegate conversation_admin?(conversation, user), to: Helpers
+ defdelegate format_reactions(reactions), to: Helpers
+ defdelegate user_reacted?(reactions, emoji, user_id), to: Helpers
+ defdelegate linkify_urls(text), to: Helpers
+ defdelegate render_reaction_emoji(emoji), to: Helpers
+ defdelegate thread_for_message(threads, message_id), to: ThreadOperations
+
+ defp route_label(conversation, current_user_id) do
+ name = Helpers.conversation_name(conversation, current_user_id) |> to_string()
+
+ case conversation do
+ %{type: "channel"} ->
+ name
+ |> String.trim()
+ |> case do
+ "" -> "#channel"
+ "#" <> _ = prefixed -> prefixed
+ trimmed -> "#" <> trimmed
+ end
+
+ _ ->
+ name
+ end
+ end
+
+ defp conversation_type_label("group"), do: "Group"
+ defp conversation_type_label("channel"), do: "Channel"
+ defp conversation_type_label("dm"), do: "Direct Message"
+
+ defp conversation_type_label(type) when is_binary(type),
+ do: type |> String.replace("_", " ") |> String.capitalize()
+
+ defp conversation_type_label(_), do: "Chat"
+
+ defp conversation_type_label_lower(type), do: conversation_type_label(type) |> String.downcase()
+
+ defp remote_conversation?(conversation) when is_map(conversation) do
+ Map.get(conversation, :is_federated_mirror, false) ||
+ Messaging.remote_dm_conversation?(conversation)
+ end
+
+ defp remote_conversation?(_), do: false
+
+ # Helper to get display content for either Message or ChatMessage structs
+ # Use __struct__ field matching to avoid cyclic dependency issues at compile time
+ defp message_display_content(%{__struct__: Elektrine.Social.Message} = msg),
+ do: Message.display_content(msg)
+
+ defp message_display_content(%{__struct__: Elektrine.Messaging.ChatMessage} = msg),
+ do: ChatMessage.display_content(msg)
+
+ defp message_display_content(message) when is_map(message) do
+ content =
+ message
+ |> map_message_value(:content)
+ |> fallback_message_text(message)
+ |> normalize_message_text()
+
+ if content != "", do: content, else: fallback_message_label(message)
+ end
+
+ defp fallback_message_text(nil, message), do: map_message_value(message, :body)
+ defp fallback_message_text("", message), do: map_message_value(message, :body)
+ defp fallback_message_text(content, _message), do: content
+
+ defp fallback_message_label(message) do
+ client_encrypted_payload = map_message_value(message, :client_encrypted_payload)
+ message_type = map_message_value(message, :message_type)
+ media_urls = map_message_value(message, :media_urls) || []
+
+ cond do
+ is_map(client_encrypted_payload) ->
+ "Encrypted message"
+
+ message_type == "voice" ->
+ "Voice message"
+
+ message_type == "image" ->
+ "Photo"
+
+ message_type == "file" ->
+ "File"
+
+ message_type == "system" ->
+ "[System message]"
+
+ is_list(media_urls) and media_urls != [] ->
+ "[Attachment]"
+
+ true ->
+ ""
+ end
+ end
+
+ defp normalize_message_text(nil), do: ""
+
+ defp normalize_message_text(text) when is_binary(text) do
+ text
+ |> String.trim()
+ end
+
+ defp normalize_message_text(text) when is_atom(text), do: Atom.to_string(text)
+ defp normalize_message_text(text) when is_integer(text), do: Integer.to_string(text)
+ defp normalize_message_text(text) when is_float(text), do: Float.to_string(text)
+ defp normalize_message_text(_), do: ""
+
+ defp map_message_value(map, key) when is_map(map) do
+ Map.get(map, key) || Map.get(map, Atom.to_string(key))
+ end
+
+ defp message_sender(message) when is_map(message) do
+ case Map.get(message, :sender) do
+ %Ecto.Association.NotLoaded{} ->
+ %{}
+
+ sender when is_map(sender) ->
+ sender
+
+ _ ->
+ %{}
+ end
+ end
+
+ defp message_sender(_), do: %{}
+
+ defp sender_name(sender) when is_map(sender), do: HandleFormatter.handle(sender)
+
+ defp message_sender_name(message), do: message |> message_sender() |> sender_name()
+
+ defp message_sender_tag(message), do: "@" <> message_sender_name(message)
+
+ defp user_at_handle(user), do: HandleFormatter.at_handle(user)
+ defp user_domain(user), do: HandleFormatter.domain(user)
+
+ # Only federated senders should surface an @domain suffix; local users are
+ # implied to be on this instance, so the suffix is just noise for them.
+ defp sender_federated?(message) do
+ user = message_sender(message)
+ user != %{} and user_domain(user) != HandleFormatter.local_domain()
+ end
+
+ # Id of the current user's most recent non-system message, used to anchor the
+ # single "Seen by" receipt instead of a per-message tick.
+ defp last_own_message_id(messages, user_id) when is_list(messages) do
+ messages
+ |> Enum.reduce(nil, fn message, acc ->
+ if message.sender_id == user_id and message.message_type != "system" do
+ message.id
+ else
+ acc
+ end
+ end)
+ end
+
+ defp last_own_message_id(_, _), do: nil
+
+ defp local_sender_id(message) when is_map(message) do
+ case Map.get(message, :sender_id) do
+ id when is_integer(id) -> id
+ _ -> nil
+ end
+ end
+
+ defp local_sender_id(_), do: nil
+
+ defp local_sender?(message), do: is_integer(local_sender_id(message))
+
+ defp sender_timeout?(message, timeout_status) when is_map(timeout_status) do
+ case local_sender_id(message) do
+ id when is_integer(id) -> Map.get(timeout_status, id, false)
+ _ -> false
+ end
+ end
+
+ defp sender_timeout?(_message, _timeout_status), do: false
+
+ defp sender_loaded?(%Ecto.Association.NotLoaded{}), do: false
+ defp sender_loaded?(nil), do: false
+ defp sender_loaded?(sender) when is_map(sender), do: true
+ defp sender_loaded?(_), do: false
+
+ # Load custom emojis that are visible in the picker
+ defp load_custom_emojis do
+ Elektrine.Emojis.list_picker_emojis()
+ end
+
+ defp filter_custom_emojis(emojis, query) when is_binary(query) and query != "" do
+ query = String.downcase(query)
+ Enum.filter(emojis, fn e -> String.contains?(String.downcase(e.shortcode), query) end)
+ end
+
+ defp filter_custom_emojis(emojis, _), do: emojis
+
+ defp selected_server_id(socket) do
+ case socket.assigns.conversation.selected do
+ %{server_id: server_id} when is_integer(server_id) ->
+ server_id
+
+ _ ->
+ case socket.assigns[:active_server_id] do
+ server_id when is_integer(server_id) -> server_id
+ _ -> nil
+ end
+ end
+ end
+
+ defp conversation_server_id(%{server_id: server_id}) when is_integer(server_id), do: server_id
+ defp conversation_server_id(_), do: nil
+
+ defp presence_map_for_conversation(%{type: "channel", id: conversation_id}, user_id)
+ when is_integer(conversation_id) and is_integer(user_id) do
+ conversation_id
+ |> Messaging.list_visible_room_presence_states(user_id)
+ |> Map.new(fn state -> {state.remote_actor_id, state} end)
+ end
+
+ defp presence_map_for_conversation(_, _), do: %{}
+
+ defp remote_presence_online_count(presence_map) when is_map(presence_map) do
+ presence_map
+ |> Map.values()
+ |> Enum.count(fn state ->
+ status = state[:status] || state["status"]
+ status in ["online", "idle", "dnd"]
+ end)
+ end
+
+ defp remote_presence_online_count(_), do: 0
+
+ defp refresh_room_presence_tracking(socket) do
+ socket = cancel_room_presence_timer(socket)
+
+ case socket.assigns.conversation.selected do
+ %{type: "channel", id: conversation_id} when is_integer(conversation_id) ->
+ publish_room_presence(conversation_id, socket.assigns.current_user.id, socket)
+
+ timer_ref =
+ Process.send_after(self(), :room_presence_heartbeat, @room_presence_heartbeat_ms)
+
+ assign(socket, :room_presence_timer_ref, timer_ref)
+
+ _ ->
+ assign(socket, :room_presence_timer_ref, nil)
+ end
+ end
+
+ defp cancel_room_presence_timer(socket) do
+ case socket.assigns[:room_presence_timer_ref] do
+ timer_ref when is_reference(timer_ref) ->
+ Process.cancel_timer(timer_ref)
+ assign(socket, :room_presence_timer_ref, nil)
+
+ _ ->
+ assign(socket, :room_presence_timer_ref, nil)
+ end
+ end
+
+ defp publish_room_presence(conversation_id, user_id, _socket)
+ when is_integer(conversation_id) and is_integer(user_id) do
+ Elektrine.Messaging.Federation.publish_room_presence_update(
+ conversation_id,
+ user_id,
+ "online",
+ []
+ )
+ end
+
+ defp normalize_optional_text(nil), do: nil
+
+ defp normalize_optional_text(value) when is_binary(value) do
+ case String.trim(value) do
+ "" -> nil
+ trimmed -> trimmed
+ end
+ end
+
+ defp normalize_optional_text(_), do: nil
+
+ defp restore_active_call_state(socket, user_id) when is_integer(user_id) do
+ case latest_active_call_for_user(user_id) do
+ nil ->
+ socket
+
+ {:local, %{} = call} ->
+ assign(socket, :call, %{
+ socket.assigns.call
+ | active_call: call,
+ incoming_call: nil,
+ status: call_status(call),
+ audio_enabled: true,
+ video_enabled: call.call_type == "video"
+ })
+
+ {:federated, %{} = session} ->
+ call = VoiceCalls.ui_call(session)
+
+ assign(socket, :call, %{
+ socket.assigns.call
+ | active_call: call,
+ incoming_call: nil,
+ status: call_status(call),
+ audio_enabled: true,
+ video_enabled: call.call_type == "video"
+ })
+ end
+ end
+
+ defp latest_active_call_for_user(user_id) do
+ local_call = Calls.get_active_call(user_id)
+ federated_call = VoiceCalls.get_active_session_for_local_user(user_id)
+
+ case {local_call, federated_call} do
+ {nil, nil} ->
+ nil
+
+ {%{} = local, nil} ->
+ {:local, local}
+
+ {nil, %{} = federated} ->
+ {:federated, federated}
+
+ {%{} = local, %{} = federated} ->
+ if DateTime.compare(naive_or_utc(local.updated_at), naive_or_utc(federated.updated_at)) in [
+ :gt,
+ :eq
+ ] do
+ {:local, local}
+ else
+ {:federated, federated}
+ end
+ end
+ end
+
+ defp maybe_resume_active_call(socket) do
+ case socket.assigns.call.active_call do
+ nil ->
+ socket
+
+ %{} = call ->
+ transport = CallTransport.descriptor_for_user(socket.assigns.current_user.id, call.id)
+
+ push_event(socket, "resume_call", %{
+ call_id: call.id,
+ call_type: call.call_type,
+ ice_servers: transport["ice_servers"],
+ transport: transport,
+ user_token: socket.assigns.user_token,
+ user_id: socket.assigns.current_user.id,
+ initiator: call_initiator?(call, socket.assigns.current_user.id)
+ })
+ end
+ end
+
+ defp call_initiator?(%{source: :federated, caller_id: caller_id}, current_user_id)
+ when is_integer(caller_id) and is_integer(current_user_id),
+ do: caller_id == current_user_id
+
+ defp call_initiator?(%{caller_id: caller_id}, current_user_id)
+ when is_integer(caller_id) and is_integer(current_user_id),
+ do: caller_id == current_user_id
+
+ defp call_initiator?(_call, _current_user_id), do: false
+
+ defp call_status(%{status: status}) when status in ["initiated", "ringing"], do: "connecting"
+ defp call_status(%{status: "active"}), do: "connected"
+ defp call_status(%{status: status}) when is_binary(status), do: status
+ defp call_status(_call), do: "connecting"
+
+ defp naive_or_utc(%NaiveDateTime{} = value), do: DateTime.from_naive!(value, "Etc/UTC")
+ defp naive_or_utc(%DateTime{} = value), do: value
+
+ defp parse_checkbox_value(value) when is_list(value) do
+ Enum.any?(value, &parse_checkbox_value/1)
+ end
+
+ defp parse_checkbox_value(value) do
+ value in [true, "true", "on", "1", 1]
+ end
+
+ defp consume_entity_image_upload(socket, upload_name) do
+ upload_results =
+ consume_uploaded_entries(socket, upload_name, fn %{path: path}, entry ->
+ upload = %Plug.Upload{
+ path: path,
+ content_type: entry.client_type,
+ filename: entry.client_name
+ }
+
+ case Uploads.upload_avatar(upload, socket.assigns.current_user.id) do
+ {:ok, metadata} ->
+ {:ok, %{ok: true, url: Uploads.avatar_url(metadata.key)}}
+
+ {:error, reason} ->
+ {:ok, %{ok: false, reason: reason}}
+ end
+ end)
+
+ case Enum.find(upload_results, &(!&1.ok)) do
+ %{reason: reason} ->
+ {:error, upload_error_message(reason)}
+
+ nil ->
+ uploaded_url =
+ upload_results
+ |> Enum.find_value(fn
+ %{ok: true, url: url} when is_binary(url) and url != "" -> url
+ _ -> nil
+ end)
+
+ {:ok, uploaded_url}
+ end
+ end
+
+ defp clear_upload_entries(socket, upload_name) do
+ refs =
+ case socket.assigns[:uploads] && socket.assigns.uploads[upload_name] do
+ %{entries: entries} when is_list(entries) -> Enum.map(entries, & &1.ref)
+ _ -> []
+ end
+
+ Enum.reduce(refs, socket, fn ref, acc ->
+ cancel_upload(acc, upload_name, ref)
+ end)
+ end
+
+ defp upload_error_message({_, message}) when is_binary(message), do: message
+
+ defp upload_error_message(reason) do
+ "Image upload failed: #{inspect(reason)}"
+ end
+
+ defp maybe_add_remote_dm_search_result(results, query)
+ when is_list(results) and is_binary(query) do
+ case normalize_remote_dm_handle_query(query) do
+ {:ok, remote_handle} ->
+ already_present? =
+ Enum.any?(results, fn user ->
+ user_handle = Map.get(user, :handle) || Map.get(user, "handle")
+ String.downcase(to_string(user_handle || "")) == remote_handle
+ end)
+
+ if already_present? do
+ results
+ else
+ [remote_dm_search_result(remote_handle) | results]
+ end
+
+ :error ->
+ results
+ end
+ end
+
+ defp maybe_add_remote_dm_search_result(results, _query), do: results
+
+ defp start_local_dm(socket, user_id) do
+ current_user_id = socket.assigns.current_user.id
+
+ case Messaging.create_dm_conversation(current_user_id, user_id) do
+ {:ok, conversation} ->
+ {:noreply,
+ socket
+ |> assign(:ui, Map.put(socket.assigns.ui, :show_new_chat, false))
+ |> assign(:search, %{socket.assigns.search | query: "", results: []})
+ |> push_navigate(to: Elektrine.Paths.chat_path(conversation))}
+
+ {:error, :rate_limited} ->
+ {:noreply,
+ socket
+ |> notify_error(
+ "You are creating too many conversations. Please wait a moment and try again."
+ )
+ |> assign(:ui, Map.put(socket.assigns.ui, :show_profile_modal, false))}
+
+ {:error, reason} ->
+ error_message = Elektrine.Privacy.privacy_error_message(reason)
+
+ {:noreply,
+ socket
+ |> notify_error(error_message)
+ |> assign(:ui, Map.put(socket.assigns.ui, :show_profile_modal, false))}
+ end
+ end
+
+ defp parse_positive_int(value) do
+ case Integer.parse(to_string(value)) do
+ {int, ""} when int > 0 -> {:ok, int}
+ _ -> :error
+ end
+ end
+
+ defp parse_optional_positive_int(value) do
+ case parse_positive_int(value) do
+ {:ok, int} -> int
+ :error -> nil
+ end
+ end
+
+ defp normalize_remote_dm_handle_query(handle) when is_binary(handle) do
+ normalized =
+ handle
+ |> String.trim()
+ |> String.trim_leading("@")
+ |> String.downcase()
+
+ case Regex.run(~r/^([a-z0-9_]{1,64})@([a-z0-9.-]+\.[a-z]{2,})$/, normalized) do
+ [_, username, domain] -> {:ok, "#{username}@#{domain}"}
+ _ -> :error
+ end
+ end
+
+ defp remote_dm_search_result(remote_handle) do
+ [username, _domain] = String.split(remote_handle, "@", parts: 2)
+
+ %{
+ id: nil,
+ username: username,
+ handle: remote_handle,
+ display_name: "@#{remote_handle}",
+ avatar: nil,
+ remote_handle: remote_handle
+ }
+ end
+
+ defp social_link_preview_success?(preview) do
+ social_link_preview?(preview) and Map.get(preview, :status) == "success"
+ end
+
+ defp social_link_preview?(%{__struct__: :"Elixir.Elektrine.Social.LinkPreview"}), do: true
+
+ defp social_link_preview?(_), do: false
+
+ @doc false
+ def safe_chat_image_url(url) when is_binary(url) do
+ trimmed = String.trim(url)
+
+ cond do
+ trimmed == "" ->
+ nil
+
+ Regex.match?(~r/[\x00-\x1F\x7F]/, trimmed) ->
+ nil
+
+ String.starts_with?(trimmed, "uploads/") ->
+ "/" <> trimmed
+
+ String.starts_with?(trimmed, ["/uploads/", "/api/private-attachments/"]) ->
+ trimmed
+
+ true ->
+ ElektrineWeb.HtmlHelpers.safe_external_image_url(trimmed)
+ end
+ end
+
+ def safe_chat_image_url(_), do: nil
+
+ defp private_email?(%{client_encrypted_payload: payload}) when is_map(payload), do: true
+ defp private_email?(_message), do: false
+
+ defp extract_email_address(email_string) when is_binary(email_string) do
+ case Regex.run(~r/<([^>]+)>/, email_string) do
+ [_, email] -> String.trim(email)
+ nil -> String.trim(email_string)
+ end
+ end
+
+ defp extract_email_address(_email_string), do: "Unknown"
+
+ defp get_emojis_for_category("Smileys"),
+ do:
+ ~w(😀 😃 😄 😁 😆 😅 🤣 😂 🙂 🙃 😉 😊 😇 🥰 😍 🤩 😘 😗 ☺️ 😚 😙 🥲 😋 😛 😜 🤪 😝 🤑 🤗 🤭 🤫 🤔 🤐 🤨 😐 😑 😶 😏 😒 🙄 😬 🤥 😌 😔 😪 🤤 😴 😷)
+
+ defp get_emojis_for_category("Gestures"),
+ do: ~w(👋 🤚 🖐️ ✋ 🖖 👌 🤌 🤏 ✌️ 🤞 🤟 🤘 🤙 👈 👉 👆 🖕 👇 ☝️ 👍 👎 ✊ 👊 🤛 🤜 👏 🙌 👐 🤲 🤝 🙏 ✍️ 💪)
+
+ defp get_emojis_for_category("Hearts"), do: ~w(❤️ 🧡 💛 💚 💙 💜 🖤 🤍 🤎 💔 ❣️ 💕 💞 💓 💗 💖 💘 💝 💟 ♥️)
+
+ defp get_emojis_for_category("Animals"),
+ do: ~w(🐶 🐱 🐭 🐹 🐰 🦊 🐻 🐼 🐨 🐯 🦁 🐮 🐷 🐸 🐵 🙈 🙉 🙊 🐒 🐔 🐧 🐦 🐤 🐣 🐥 🦆 🦅 🦉 🦇 🐺 🐗 🐴 🦄 🐝)
+
+ defp get_emojis_for_category("Food"),
+ do: ~w(🍏 🍎 🍐 🍊 🍋 🍌 🍉 🍇 🍓 🫐 🍈 🍒 🍑 🥭 🍍 🥥 🥝 🍅 🍆 🥑 🥦 🥬 🥒 🌽 🥕 🥔 🍠 🥐 🥖 🍞 🥨 🍳 🥚 🧀)
+
+ defp get_emojis_for_category(_), do: Helpers.popular_emojis()
+end
diff --git a/apps/arblarg/lib/arblarg_web/live/chat_live/index.html.heex b/apps/arblarg/lib/arblarg_web/live/chat_live/index.html.heex
new file mode 100644
index 0000000..99c1eed
--- /dev/null
+++ b/apps/arblarg/lib/arblarg_web/live/chat_live/index.html.heex
@@ -0,0 +1,2873 @@
+<% active_server_id =
+ case @active_server_id do
+ server_id when is_integer(server_id) ->
+ server_id
+
+ _ ->
+ case @conversation.selected do
+ %{server_id: server_id} when is_integer(server_id) -> server_id
+ _ -> nil
+ end
+ end %>
+<% active_server =
+ if is_integer(active_server_id) do
+ Enum.find(@joined_servers, &(&1.id == active_server_id))
+ else
+ nil
+ end %>
+<% chat_e2ee_member_ids =
+ case @conversation.selected do
+ %{members: members, type: type} when type in ["dm", "group"] ->
+ members
+ |> Enum.filter(&is_nil(&1.left_at))
+ |> Enum.map(& &1.user_id)
+ |> Enum.reject(&is_nil/1)
+ |> Enum.uniq()
+
+ _ ->
+ []
+ end %>
+<% chat_e2ee_devices = @chat_e2ee_devices || [] %>
+<% chat_e2ee_device_user_ids =
+ chat_e2ee_devices
+ |> Enum.map(& &1.user_id)
+ |> Enum.uniq() %>
+<% chat_e2ee_remote_devices = Enum.filter(chat_e2ee_devices, &Map.get(&1, :recipient_handle)) %>
+<% chat_e2ee_local_ready =
+ @conversation.selected && @conversation.selected.type in ["dm", "group"] &&
+ is_nil(@conversation.selected.federated_source) && chat_e2ee_member_ids != [] &&
+ Enum.all?(chat_e2ee_member_ids, &(&1 in chat_e2ee_device_user_ids)) %>
+<% chat_e2ee_remote_dm_ready =
+ @conversation.selected && @conversation.selected.type == "dm" &&
+ is_binary(@conversation.selected.federated_source) &&
+ @current_user && @current_user.id in chat_e2ee_device_user_ids &&
+ chat_e2ee_remote_devices != [] %>
+<% chat_e2ee_ready =
+ (chat_e2ee_local_ready || chat_e2ee_remote_dm_ready) && chat_e2ee_devices != [] &&
+ length(chat_e2ee_devices) <= 64 %>
+<% chat_e2ee_status =
+ cond do
+ is_nil(@conversation.selected) ->
+ "no_conversation"
+
+ @conversation.selected.type not in ["dm", "group"] ->
+ "not_applicable"
+
+ length(chat_e2ee_devices) > 64 ->
+ "too_many_devices"
+
+ @current_user && @current_user.id not in chat_e2ee_device_user_ids ->
+ "registering_device"
+
+ @conversation.selected.type == "dm" && is_binary(@conversation.selected.federated_source) &&
+ chat_e2ee_remote_devices == [] ->
+ "waiting_for_remote_keys"
+
+ is_binary(@conversation.selected.federated_source) ->
+ "ready"
+
+ not Enum.all?(chat_e2ee_member_ids, &(&1 in chat_e2ee_device_user_ids)) ->
+ "waiting_for_member_keys"
+
+ true ->
+ "ready"
+ end %>
+<% chat_overlay_active =
+ @show_image_modal || @show_report_modal || @show_mobile_search || @context_menu.conversation ||
+ @context_menu.message ||
+ Enum.any?(
+ [
+ :show_new_chat,
+ :show_create_group,
+ :show_create_channel,
+ :show_group_modal,
+ :show_channel_modal,
+ :show_server_modal,
+ :show_browse_channels,
+ :show_settings_modal,
+ :show_edit_modal,
+ :show_add_members_modal,
+ :show_message_search_modal,
+ :show_emoji_picker,
+ :show_profile_modal,
+ :show_member_management,
+ :show_moderation_log,
+ :show_browse_modal
+ ],
+ &Map.get(@ui, &1)
+ ) %>
+
+
+ <.e_nav
+ active_tab="chat"
+ class="mb-3 lg:mb-6"
+ current_user={@current_user}
+ badge_counts={@e_nav_badge_counts}
+ />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ <.icon name="hero-globe-alt-solid" class="w-6 h-6" />
+
+
+ Browse
+
+
+
+
+ <.icon name="hero-plus" class="w-6 h-6" />
+
+
+ New
+
+
+
+
+ <%= for server <- @joined_servers do %>
+ <% server_selected = active_server_id == server.id %>
+
+ <%= if icon_url = safe_chat_image_url(server.icon_url) do %>
+
+ <% else %>
+ <%= case String.trim(server.name || "") do %>
+ <% "" -> %>
+ ?
+ <% name -> %>
+ {name |> String.first() |> String.upcase()}
+ <% end %>
+ <% end %>
+
+ <% end %>
+
+
+
+
+
+
+
+
+
+
+
+
+
+ <.icon name="hero-magnifying-glass" class="w-4 h-4" />
+
+
+
+
+
+
+
+
+
+
+
+
+ Viewing: {active_server.name}
+
+
+
+
+
+
+
+
+ <.icon name="hero-plus" class="w-4 h-4 text-secondary" />
+
+ New
+ <.icon
+ name="hero-chevron-down"
+ class="w-4 h-4 text-base-content/50 transition-transform group-open/new:rotate-180"
+ />
+
+
+
+
+
+ <.icon name="hero-chat-bubble-left-right" class="w-4 h-4 text-base-content/80" />
+
+ New DM
+
+
+
+
+
+ <.icon name="hero-users" class="w-4 h-4 text-base-content/80" />
+
+ New Group
+
+
+
+
+
+ <.icon name="hero-plus-circle" class="w-4 h-4 text-base-content/80" />
+
+ New Server
+
+
+
+
+
+ <.icon
+ name="hero-megaphone"
+ class="w-4 h-4 text-base-content/80"
+ />
+
+
+
New Channel
+
+ Select a server first
+
+
+
+
+
+
+
+ <.icon
+ name="hero-folder-plus"
+ class="w-4 h-4 text-base-content/80"
+ />
+
+
+
New Category
+
+ Select a server first
+
+
+
+
+
+
+
+ <.icon
+ name="hero-globe-alt-solid"
+ class="w-4 h-4 text-base-content/80"
+ />
+
+
+ Explore Servers
+
+
+
+
+
+
+
+
+
+
+
+ <%= if @search.conversation_query != "" and @search.user_results != [] do %>
+
+
+ <%= for user <- @search.user_results do %>
+ <% is_remote_user =
+ is_binary(Map.get(user, :remote_handle)) and Map.get(user, :remote_handle) != "" %>
+
+
+
+ <.user_avatar user={user} size="responsive" />
+
+
+
+
+ <.username_with_effects user={user} display_name={true} verified_size="xs" />
+
+
+ {user_at_handle(user)}
+
+
+
+ <%= if is_remote_user do %>
+ From another server
+ <% end %>
+ <.icon name="hero-plus-circle" class="w-4 h-4 text-secondary" />
+
+
+ <% end %>
+
+ <% end %>
+
+
+ <%= if @search.conversation_query != "" and @public_server_search_results != [] do %>
+
+
+ <%= for server <- @public_server_search_results do %>
+ <% server_is_remote =
+ is_binary(server.origin_domain) and server.origin_domain != "" %>
+
+
+ <.icon name="hero-globe-alt" class="w-5 h-5 text-base-content/80" />
+
+
+
+ {server.name}
+
+
+ {server.member_count} {if server.member_count == 1,
+ do: "member",
+ else: "members"}
+ <%= if server.origin_domain do %>
+ • {server.origin_domain}
+ <% end %>
+ <%= if server.description do %>
+ • {String.slice(server.description, 0..50)}
+ <% end %>
+
+
+
+ <%= if server_is_remote do %>
+ From another server
+ <% end %>
+ <.icon name="hero-arrow-right-circle" class="w-4 h-4 text-secondary" />
+
+
+ <% end %>
+
+ <% end %>
+
+
+ <%= if @search.conversation_query != "" and @public_group_search_results != [] do %>
+
+
+ <%= for group <- @public_group_search_results do %>
+
+
+ <.icon name="hero-users" class="w-5 h-5 text-base-content/80" />
+
+
+
{group.name}
+
+ <%= if group.member_count do %>
+ {group.member_count} {if group.member_count == 1,
+ do: "member",
+ else: "members"}
+ <% end %>
+ <%= if group.description do %>
+ • {String.slice(group.description, 0..50)}
+ <% end %>
+
+
+
+ Group
+ <.icon name="hero-arrow-right-circle" class="w-4 h-4 text-secondary" />
+
+
+ <% end %>
+
+ <% end %>
+
+
+ <%= if @loading_conversations do %>
+
+ <%= for _i <- 1..5 do %>
+
+ <% end %>
+
+ <% else %>
+ <%= for entry <-
+ Helpers.sidebar_entries(
+ @conversation.filtered,
+ @server_categories,
+ @collapsed_category_ids,
+ active_server_id
+ ) do %>
+ <%= case entry do %>
+ <% {:category, category, collapsed?} -> %>
+
+ <.icon
+ name={if collapsed?, do: "hero-chevron-right", else: "hero-chevron-down"}
+ class="w-3 h-3 text-base-content/50"
+ />
+
+ {category.name}
+
+
+ <% {:conversation, %{type: "voice_channel"} = conversation} -> %>
+ <% voice_occupants = Map.get(@voice.occupants, conversation.id, []) %>
+ <% voice_joined = @voice.joined_id == conversation.id %>
+
+
+ "Remote voice channels can't be joined"
+
+ voice_joined ->
+ "Connected"
+
+ true ->
+ "Join voice channel"
+ end
+ }
+ >
+ <.icon
+ name="hero-speaker-wave"
+ class={[
+ "w-4 h-4 flex-shrink-0",
+ if(voice_joined, do: "text-success", else: "text-base-content/60")
+ ]}
+ />
+ {conversation.name}
+ Voice
+
+ {length(voice_occupants)}/{Elektrine.Messaging.VoiceChannels.max_occupants()}
+
+
+ <%= if voice_occupants != [] do %>
+
+ <%= for occupant <- voice_occupants do %>
+
+
+ {String.first(occupant.display_name || occupant.username || "?")}
+
+
+ {occupant.display_name || occupant.username}
+
+ <%= if occupant.muted do %>
+ <.icon
+ name="hero-speaker-x-mark"
+ class="w-3 h-3 text-error flex-shrink-0"
+ />
+ <% end %>
+
+ <% end %>
+
+ <% end %>
+
+ <% {:conversation, conversation} -> %>
+ <% is_selected =
+ @conversation.selected && @conversation.selected.id == conversation.id %>
+ <% remote_conversation = remote_conversation?(conversation) %>
+ <% unread_count = Map.get(@conversation.unread_counts, conversation.id, 0) %>
+
+ <.link
+ patch={Elektrine.Paths.chat_path(conversation)}
+ class={[
+ "flex items-center gap-3 px-3 py-2.5 cursor-pointer transition-all relative outline-none focus:outline-none focus-visible:outline-none active:opacity-90 select-none [&:active]:!outline-none [&:focus]:!outline-none border-b overflow-visible",
+ if(is_selected,
+ do: "bg-secondary/10 text-base-content border-secondary/30",
+ else:
+ if(unread_count > 0,
+ do:
+ "bg-base-200/70 hover:bg-base-200 border-base-300 text-base-content",
+ else: "hover:bg-base-200 border-base-300 text-base-content"
+ )
+ )
+ ]}
+ >
+
+
+ <.conversation_avatar
+ conversation={conversation}
+ current_user_id={@current_user.id}
+ size="responsive"
+ online_users={@online_users}
+ user_statuses={@user_statuses}
+ />
+
+
+
+
+
+
0,
+ do: "font-semibold text-sm truncate text-base-content",
+ else: "font-medium text-sm truncate text-base-content"
+ )
+ )
+ }>
+ {route_label(conversation, @current_user.id)}
+
+
+
+ <%= if unread_count > 0 do %>
+
+ {unread_count}
+
+ <% end %>
+ <%= if conversation.last_message_at do %>
+
+ <.local_time
+ datetime={conversation.last_message_at}
+ format="relative"
+ timezone={@timezone}
+ time_format={@time_format}
+ />
+
+ <% end %>
+
+
+
+ <%= if remote_conversation do %>
+
+
+ From another server
+
+
+ <% end %>
+
+ <%= if conversation.messages != [] do %>
+ <% last_message = List.first(conversation.messages) %>
+ <% last_message_encrypted_payload =
+ Map.get(last_message, :client_encrypted_payload) ||
+ Map.get(last_message, "client_encrypted_payload") %>
+ <%= if last_message.sender_id == @current_user.id do %>
+ <% read_status =
+ Map.get(
+ @conversation.last_message_read_status || %{},
+ conversation.id
+ ) %>
+
0,
+ do:
+ "text-xs sm:text-sm text-base-content flex items-center gap-1 min-w-0 font-medium",
+ else:
+ "text-xs sm:text-sm text-base-content/70 flex items-center gap-1 min-w-0"
+ )
+ )
+ }>
+ <%= if read_status && read_status.is_read do %>
+
+ ✓✓
+
+ <% else %>
+
+ ✓
+
+ <% end %>
+
+ You:
+ <%= if last_message_encrypted_payload do %>
+
+ Encrypted message
+
+ <% else %>
+ {message_display_content(last_message)}
+ <% end %>
+
+
+ <% else %>
+
0,
+ do:
+ "text-xs sm:text-sm text-base-content truncate font-medium",
+ else: "text-xs sm:text-sm text-base-content/70 truncate"
+ )
+ )
+ }>
+ <%= if last_message_encrypted_payload do %>
+
+ Encrypted message
+
+ <% else %>
+ {message_display_content(last_message)}
+ <% end %>
+
+ <% end %>
+ <% else %>
+
+ No messages yet
+
+ <% end %>
+
+
+
+
+ <%= if unread_count > 0 do %>
+
+ {unread_count}
+
+ <% end %>
+ <%= if conversation.last_message_at do %>
+
+ <.local_time
+ datetime={conversation.last_message_at}
+ format="relative"
+ timezone={@timezone}
+ time_format={@time_format}
+ />
+
+ <% end %>
+
+
+ <% member =
+ Enum.find(
+ conversation.members,
+ &(&1.user_id == @current_user.id and is_nil(&1.left_at))
+ ) %>
+ <%= if member && member.pinned do %>
+ <.icon
+ name="hero-bookmark-solid"
+ class={
+ if(is_selected,
+ do: "w-3 h-3 text-accent",
+ else: "w-3 h-3 text-error"
+ )
+ }
+ />
+ <% end %>
+
+
+
+ <% member =
+ Enum.find(
+ conversation.members,
+ &(&1.user_id == @current_user.id and is_nil(&1.left_at))
+ ) %>
+ <%= if member && member.pinned do %>
+
+ <.icon
+ name="hero-bookmark-solid"
+ class={
+ if(is_selected,
+ do: "w-3 h-3 text-accent",
+ else: "w-3 h-3 text-error"
+ )
+ }
+ />
+
+ <% end %>
+
+
+ <% end %>
+ <% end %>
+ <% end %>
+
+
+ <%= if !@loading_conversations && @conversation.filtered == [] do %>
+
+ <%= if @search.conversation_query != "" do %>
+ <.icon
+ name="hero-magnifying-glass"
+ class="w-12 h-12 mx-auto text-base-content/70 mb-4"
+ />
+
No chats found
+
Try a different search term
+ <% else %>
+ <.icon
+ name="hero-chat-bubble-left-right"
+ class="w-12 h-12 mx-auto text-base-content/70 mb-4"
+ />
+
No chats yet
+
+ Start a direct message, create a group, or make a channel
+
+ <% end %>
+
+ <% end %>
+
+
+
+
+
+
+ <%= if @conversation.selected do %>
+
+
+
+
+
+ <.icon name="hero-arrow-left" class="w-4 h-4" />
+
+
+
+
+ <.conversation_avatar
+ conversation={@conversation.selected}
+ current_user_id={@current_user.id}
+ size="sm"
+ online_users={@online_users}
+ user_statuses={@user_statuses}
+ />
+
+
+
+
+
+ {route_label(@conversation.selected, @current_user.id)}
+
+
+ <%= if remote_conversation?(@conversation.selected) do %>
+ From another server
+ <% end %>
+ <%= if @conversation.selected.type != "dm" do %>
+
+ {@conversation.selected.member_count} members
+
+ <% end %>
+ <%= if @conversation.selected.type == "channel" and @conversation.selected.server_id do %>
+ <% remote_online = remote_presence_online_count(@federation_presence) %>
+ <%= if remote_online > 0 do %>
+
+ {remote_online} remote present
+
+ <% end %>
+ <% end %>
+
+
+
+ <%= if @conversation.selected.type == "dm" do %>
+ <% other_user =
+ Enum.find(@conversation.selected.members, fn member ->
+ member.user_id != @current_user.id and is_nil(member.left_at)
+ end) %>
+ <% remote_handle = Messaging.remote_dm_handle(@conversation.selected) %>
+ <%= if (other_user && other_user.user) || is_binary(remote_handle) do %>
+ <.call_buttons
+ user={other_user && other_user.user}
+ conversation_id={@conversation.selected.id}
+ remote_handle={remote_handle}
+ />
+ <% end %>
+ <% end %>
+
+ <%= if @conversation.selected.type == "channel" do %>
+
+ <.icon name="hero-chat-bubble-left-right" class="w-4 h-4 sm:w-5 sm:h-5" />
+
+ <% end %>
+
+
+
+ <.icon name="hero-ellipsis-vertical" class="w-4 h-4 sm:w-5 sm:h-5" />
+
+
+
+
+
+
+
+ <%= if @threads.show_panel && @conversation.selected.type == "channel" do %>
+
+
+ <%= if @threads.active do %>
+
+ <.icon name="hero-arrow-left" class="w-4 h-4" />
+
+
+
{@threads.active.title}
+
+ {@threads.active.message_count} {if @threads.active.message_count == 1,
+ do: "reply",
+ else: "replies"}
+ <%= if @threads.active.archived_at do %>
+ · archived
+ <% end %>
+
+
+ <%= if @threads.active.archived_at do %>
+
+ <.icon name="hero-arrow-path" class="w-4 h-4" />
+
+ <% else %>
+
+ <.icon name="hero-archive-box" class="w-4 h-4" />
+
+ <% end %>
+ <% else %>
+ <.icon name="hero-chat-bubble-left-right" class="w-4 h-4 text-secondary" />
+
Threads
+ <% end %>
+
+ <.icon name="hero-x-mark" class="w-4 h-4" />
+
+
+
+ <%= if @threads.active do %>
+
+
+ <% root = @threads.active.root_message %>
+ <% thread_entries =
+ if(root && is_nil(root.deleted_at), do: [{root, true}], else: []) ++
+ Enum.map(@threads.messages, &{&1, false}) %>
+ <%= for {message, is_root} <- thread_entries do %>
+
+
+ <.user_avatar user={message_sender(message)} size="xs" />
+
+
+
+ {message_sender_name(message)}
+ <.sender_badge sender={message_sender(message)} />
+
+ <.local_time
+ datetime={message.inserted_at}
+ format="time"
+ timezone={@timezone}
+ time_format={@time_format}
+ />
+
+ <%= if is_root do %>
+ original message
+ <% end %>
+
+
+ {Phoenix.HTML.raw(linkify_urls(message_display_content(message) || ""))}
+
+
+
+ <% end %>
+ <%= if @threads.messages == [] do %>
+
+ No replies yet. Start the thread below.
+
+ <% end %>
+
+
+ <%= if is_nil(@threads.active.archived_at) do %>
+
+
+
+ <.icon name="hero-paper-airplane" class="w-4 h-4" />
+
+
+ <% else %>
+
+ This thread is archived.
+
+ <% end %>
+ <% else %>
+
+
+ <%= if @threads.list == [] do %>
+
+ No active threads. Use a message's context menu to start one.
+
+ <% end %>
+ <%= for thread <- @threads.list do %>
+
+
+ {thread.title}
+
+ {thread.message_count}
+
+
+
+ <%= if thread.last_activity_at do %>
+ <.local_time
+ datetime={thread.last_activity_at}
+ format="datetime"
+ timezone={@timezone}
+ time_format={@time_format}
+ />
+ <% end %>
+
+
+ <% end %>
+
+
+ <.icon
+ name={
+ if @threads.show_archived,
+ do: "hero-chevron-down",
+ else: "hero-chevron-right"
+ }
+ class="w-3 h-3"
+ /> Archived threads
+
+ <%= if @threads.show_archived do %>
+ <%= if @threads.archived == [] do %>
+
No archived threads.
+ <% end %>
+ <%= for thread <- @threads.archived do %>
+
+
+ <.icon name="hero-archive-box" class="w-3 h-3 flex-shrink-0" />
+ {thread.title}
+ {thread.message_count}
+
+
+ <% end %>
+ <% end %>
+
+ <% end %>
+
+ <% end %>
+
+
+
+
+ <%= if @loading_older_messages do %>
+
+ <.spinner size="sm" />
+ Loading older messages...
+
+ <% end %>
+
+
+ <%= if @has_more_older_messages && !@loading_older_messages do %>
+
+
+ Load older messages
+
+
+ <% end %>
+
+ <%= if @initial_messages_loading do %>
+
+ <.spinner size="sm" />
+ Loading messages...
+
+ <% end %>
+
+
+ <%= if @pinned_messages != [] do %>
+
+
+ <.icon name="hero-bookmark-solid" class="w-4 h-4 text-secondary" />
+
+ Pinned Messages ({length(@pinned_messages)})
+
+ <.icon
+ name={
+ if @ui.show_pinned_panel, do: "hero-chevron-up", else: "hero-chevron-down"
+ }
+ class="w-4 h-4 text-secondary"
+ />
+
+ <%= if @ui.show_pinned_panel do %>
+
+ <%= for message <- @pinned_messages do %>
+
+
+
+ <.user_avatar user={message_sender(message)} size="xs" />
+
+
+
+
+ {message_sender_name(message)}
+
+
+ {Calendar.strftime(message.inserted_at, "%b %d")}
+
+
+
+ <%= if message.message_type == "voice" do %>
+ Voice message
+ <% else %>
+ {message.content || "[Media]"}
+ <% end %>
+
+
+
+
+ <.icon name="hero-bookmark-slash" class="w-3 h-3" />
+
+
+ <% end %>
+
+ <% end %>
+
+ <% end %>
+
+ <% last_own_message_id = last_own_message_id(@messages, @current_user.id) %>
+ <%= for {message, show_date_separator, grouped_with_previous} <- messages_with_date_separators(@messages) do %>
+ <% grouped = grouped_with_previous and message.id != @first_unread_message_id %>
+
+ <%= if show_date_separator do %>
+
+
+
+ <% message_date = message_inserted_date(message) %>
+ <%= if Date.compare(message_date, Date.utc_today()) == :eq do %>
+ Today
+ <% else %>
+ <%= if Date.diff(Date.utc_today(), message_date) == 1 do %>
+ Yesterday
+ <% else %>
+ <.local_time
+ datetime={message.inserted_at}
+ format="date"
+ timezone={@timezone}
+ time_format={@time_format}
+ />
+ <% end %>
+ <% end %>
+
+
+
+ <% end %>
+
+
+ <%= if @first_unread_message_id && message.id == @first_unread_message_id do %>
+
+ <% end %>
+
+ <%!-- System messages (call logs, etc.) --%>
+ <%= if message.message_type == "system" do %>
+
+
+ <%= if message.media_metadata["call_type"] do %>
+ <.icon
+ name={
+ if message.media_metadata["call_type"] == "video",
+ do: "hero-video-camera",
+ else: "hero-phone"
+ }
+ class="w-3 h-3"
+ />
+ <% end %>
+ {message.content}
+
+
+ <% else %>
+ <%!-- Regular user messages --%>
+
+
+
+ <%= if grouped do %>
+
+ <.local_time
+ datetime={message.inserted_at}
+ format="time"
+ timezone={@timezone}
+ time_format={@time_format}
+ />
+
+ <% else %>
+ <%= if local_sender?(message) do %>
+
+
+ <.user_avatar user={message_sender(message)} size="sm" />
+
+
+ <% else %>
+
+
+ <.user_avatar user={message_sender(message)} size="sm" />
+
+
+ <% end %>
+ <% end %>
+
+
+
+
+
+ <%= unless grouped do %>
+
+ <%= if @conversation.selected.type != "dm" or message.sender_id != @current_user.id do %>
+ <%= if local_sender?(message) do %>
+
+ <.username_with_effects
+ user={message_sender(message)}
+ show_at={false}
+ verified_size="xs"
+ />
+ <%= if sender_federated?(message) do %>
+
+ @{user_domain(message_sender(message))}
+
+ <% end %>
+
+ <% else %>
+ {message_sender_tag(message)}
+ <% end %>
+ <.sender_badge sender={message_sender(message)} />
+ <%= if sender_timeout?(message, @moderation.user_timeout_status) do %>
+
+ <.icon name="hero-clock" class="w-2 h-2 mr-1" /> Timed Out
+
+ <% end %>
+ <% end %>
+
+ <.local_time
+ datetime={message.inserted_at}
+ format="time"
+ timezone={@timezone}
+ time_format={@time_format}
+ />
+
+ <%= if message.edited_at do %>
+ edited
+ <% end %>
+
+ <% end %>
+
+
+
+
+ <%= if message.reply_to && sender_loaded?(message.reply_to.sender) do %>
+ <% reply_content = message_display_content(message.reply_to) || "" %>
+
+
+ Replying to {user_at_handle(message.reply_to.sender)}
+
+ <%= if reply_content != "" do %>
+
+ {String.slice(reply_content, 0, 50)}{if String.length(reply_content) >
+ 50,
+ do: "..."}
+
+ <% end %>
+
+ <% end %>
+
+
+ <%= if Map.get(message, :is_pinned, false) do %>
+
+ <.icon name="hero-bookmark-solid" class="w-3 h-3" />
+ Pinned
+
+ <% end %>
+
+
+ <% client_encrypted_payload =
+ Map.get(message, :client_encrypted_payload) ||
+ Map.get(message, "client_encrypted_payload") %>
+ <%= if client_encrypted_payload do %>
+
+ Decrypting encrypted message...
+
+ <% end %>
+ <%= if is_nil(client_encrypted_payload) && message.content && message.content != "" do %>
+
+ {Phoenix.HTML.raw(linkify_urls(message_display_content(message)))}
+
+ <% end %>
+
+
+ <%= if Map.get(message, :shared_message_id) && Map.get(message, :shared_message) do %>
+ <.embedded_post
+ message={message}
+ shared_message={message.shared_message}
+ class="mt-2"
+ />
+ <% end %>
+
+
+ <%= if message.message_type == "voice" && message.media_urls && message.media_urls != [] do %>
+
+
+ <.icon
+ name="hero-microphone"
+ class="w-5 h-5 text-secondary flex-shrink-0"
+ />
+
+ Your browser does not support the audio element.
+
+ <%= if message.media_metadata["duration"] do %>
+
+ {div(message.media_metadata["duration"], 60)}:{rem(
+ message.media_metadata["duration"],
+ 60
+ )
+ |> Integer.to_string()
+ |> String.pad_leading(2, "0")}
+
+ <% end %>
+
+
+ <% end %>
+
+
+ <%= if message.message_type != "voice" && message.media_urls && message.media_urls != [] do %>
+ <% uploaded_image_urls =
+ message.media_urls
+ |> Enum.filter(&String.match?(&1, ~r/\.(jpg|jpeg|png|gif|webp)$/i))
+ |> Enum.map(&Uploads.attachment_url(&1, @conversation.selected)) %>
+
+ <% end %>
+
+
+ <%= if (message.media_urls == nil || message.media_urls == []) do %>
+ <% image_urls =
+ message.content
+ |> Message.extract_image_urls()
+ |> ElektrineWeb.HtmlHelpers.safe_external_image_urls() %>
+ <%= if image_urls != [] do %>
+
+ <%= for {image_url, index} <- Enum.with_index(image_urls) do %>
+
+
+
+ <% end %>
+
+ <% end %>
+ <% end %>
+
+
+ <% youtube_url = Message.extract_youtube_embed_url(message.content) %>
+ <%= if youtube_url do %>
+ <.youtube_preview
+ url={youtube_url}
+ wrapper_class="mt-2 rounded-lg overflow-hidden"
+ />
+ <% end %>
+
+
+ <% link_preview = Map.get(message, :link_preview) %>
+ <% link_preview_url =
+ if social_link_preview_success?(link_preview),
+ do: ElektrineWeb.HtmlHelpers.safe_external_href(link_preview.url) %>
+ <%= if !youtube_url && link_preview_url do %>
+
+ <%= if preview_image_url =
+ ElektrineWeb.HtmlHelpers.safe_external_image_url(
+ link_preview.image_url
+ ) do %>
+
+
+
+ <% end %>
+
+ <%= if link_preview.title do %>
+
+ {link_preview.title}
+
+ <% end %>
+ <%= if link_preview.description do %>
+
+ {link_preview.description}
+
+ <% end %>
+
+ <%= if favicon_url =
+ ElektrineWeb.HtmlHelpers.safe_external_image_url(
+ link_preview.favicon_url
+ ) do %>
+
+ <% end %>
+
+ {URI.parse(link_preview_url).host}
+
+
+
+
+ <% end %>
+
+
+ <.content_journey message={message} context="chat" />
+
+
+ <%= if message.reactions != [] do %>
+
+ <%= for {emoji, count, users} <- format_reactions(message.reactions) do %>
+
+ {Phoenix.HTML.raw(render_reaction_emoji(emoji))} {count}
+
+ <% end %>
+
+ <% end %>
+
+
+ <%= if @conversation.selected.type == "channel" do %>
+ <% message_thread = thread_for_message(@threads, message.id) %>
+ <%= if message_thread do %>
+
+ <.icon name="hero-chat-bubble-left-right" class="w-3 h-3" />
+
+ {message_thread.message_count} {if message_thread.message_count == 1,
+ do: "reply",
+ else: "replies"} in thread<%= if message_thread.archived_at do %>
+ (archived)
+ <% end %>
+
+
+ <% end %>
+ <% end %>
+
+
+
+
+
+
+ <.icon name="hero-arrow-uturn-left" class="w-4 h-4" />
+
+
+ <%= if @conversation.selected.type == "channel" && message.message_type != "system" do %>
+ <% message_thread = thread_for_message(@threads, message.id) %>
+
+ <.icon name="hero-chat-bubble-left-right" class="w-4 h-4" />
+
+ <% end %>
+
+
+ <.icon
+ name={
+ if Map.get(message, :is_pinned, false),
+ do: "hero-bookmark-slash",
+ else: "hero-bookmark"
+ }
+ class="w-4 h-4"
+ />
+
+
+
+
+ <%= if @current_user.is_admin and local_sender?(message) && message.sender_id != @current_user.id do %>
+
+
+
+ <.icon name="hero-shield-exclamation" class="w-4 h-4" />
+
+
+
+ <% end %>
+
+
+
+
+ <%= if message.sender_id == @current_user.id and message.id == last_own_message_id do %>
+ <% readers = Map.get(@message.read_status, message.id, []) %>
+ <%= if readers != [] do %>
+
+
Seen by
+
+ <%= for reader <- Enum.take(readers, 5) do %>
+
+ {String.first(reader.username || "?")}
+
+ <% end %>
+
+ <%= if length(readers) > 5 do %>
+
+{length(readers) - 5}
+ <% end %>
+
+ <% end %>
+ <% end %>
+ <% end %>
+ <% end %>
+
+
+
+ <%= if @ui.show_emoji_picker do %>
+
+
+
+
+
+
+
+ <%= if @search.emoji_query == "" do %>
+
+ <%= if !Enum.empty?(@custom_emojis) do %>
+
+ Custom
+
+ <% end %>
+
+ Faces
+
+
+ Hands
+
+
+ Hearts
+
+
+ Animals
+
+
+ Food
+
+
+ <% end %>
+
+
+
+ <%= if @search.emoji_query != "" do %>
+
+ <%= for emoji <- filter_custom_emojis(@custom_emojis, @search.emoji_query) do %>
+
emoji.shortcode <> ":"}
+ >
+ <%= if emoji_url =
+ ElektrineWeb.HtmlHelpers.safe_external_image_url(emoji.image_url) do %>
+
+ <% end %>
+
+ <% end %>
+ <% else %>
+
+ <%= if @search.emoji_tab == "Custom" do %>
+ <%= for emoji <- @custom_emojis do %>
+
emoji.shortcode <> ":"}
+ >
+ <%= if emoji_url =
+ ElektrineWeb.HtmlHelpers.safe_external_image_url(emoji.image_url) do %>
+
+ <% end %>
+
+ <% end %>
+ <%= if Enum.empty?(@custom_emojis) do %>
+
+ No custom emojis
+
+ <% end %>
+ <% else %>
+ <%= for emoji <- get_emojis_for_category(@search.emoji_tab) do %>
+
+ {emoji}
+
+ <% end %>
+ <% end %>
+ <% end %>
+
+
+ <% end %>
+
+
+
+
+
+ <%= if @message.reply_to && sender_loaded?(@message.reply_to.sender) do %>
+
+
+ Replying to
+ {user_at_handle(@message.reply_to.sender)} :
+
+ {message_display_content(@message.reply_to)}
+
+
+
+ <.icon name="hero-x-mark" class="w-3 h-3" />
+
+
+ <% end %>
+
+
+
+
+ <.icon name="hero-arrow-down" class="w-4 h-4" />
+ New messages
+
+
+
+
+ <%= if @message.typing_users != [] do %>
+
+
+ <%= case length(@message.typing_users) do %>
+ <% 1 -> %>
+ {hd(@message.typing_users).username} is typing
+ <% 2 -> %>
+ {Enum.at(@message.typing_users, 0).username} and {Enum.at(
+ @message.typing_users,
+ 1
+ ).username} are typing
+ <% _ -> %>
+ {Enum.at(@message.typing_users, 0).username} and {length(
+ @message.typing_users
+ ) - 1} others are typing
+ <% end %>
+
+ <.spinner size="sm" variant="dots" />
+
+ <% end %>
+
+ <%= if @conversation.selected.type in ["dm", "group"] do %>
+
+
+ <.icon name="hero-lock-open" class="w-3.5 h-3.5" />
+ Encrypted chat off
+
+
+
+
+ <% end %>
+
+ <%= if Map.get(@moderation.user_timeout_status, @current_user.id, false) do %>
+
+ <.icon name="hero-clock" class="w-5 h-5 mx-auto mb-2 text-warning" />
+
You are currently timed out
+
+ You cannot send messages or reactions in this chat
+
+
+ <% else %>
+ <.form
+ for={%{}}
+ phx-submit="send_message"
+ phx-change="validate_upload"
+ class="space-y-2"
+ data-chat-e2ee-ready={if chat_e2ee_ready, do: "true", else: "false"}
+ data-message-loading={if @message.loading_messages, do: "true", else: "false"}
+ data-has-uploads={
+ if @uploads.chat_attachments.entries == [], do: "false", else: "true"
+ }
+ >
+
+ <%= for entry <- @uploads.chat_attachments.entries do %>
+
+
+ <%= if String.starts_with?(entry.client_type, "image/") do %>
+ <.icon name="hero-photo" class="w-6 h-6" />
+ <% else %>
+ <%= if String.starts_with?(entry.client_type, "audio/") do %>
+ <.icon name="hero-microphone" class="w-6 h-6" />
+ <% else %>
+ <.icon name="hero-document" class="w-6 h-6" />
+ <% end %>
+ <% end %>
+
+
+
{entry.client_name}
+
+
+
+
+ <.icon name="hero-x-mark" class="w-4 h-4" />
+
+
+ <% end %>
+
+
+
+ <%= if @can_send_messages do %>
+
+
+ <.icon name="hero-paper-clip" class="w-5 h-5" />
+ <.live_file_input upload={@uploads.chat_attachments} class="hidden" />
+
+
+
<%= @message.new_message %>
+
+
+
+ <.icon name="hero-face-smile" class="w-5 h-5" />
+
+
+
+
+ <.icon name="hero-microphone" class="w-5 h-5" />
+
+
+
+
+
+ 0:00
+
+ <.icon name="hero-x-mark" class="w-3 h-3" />
+
+
+
+
+
+ <.icon name="hero-paper-airplane" class="w-5 h-5" />
+
+ <% else %>
+
+ <% end %>
+
+ <%= if @message.new_message && String.length(@message.new_message) > 3600 do %>
+
+ {String.length(@message.new_message)}/4000
+
+ <% end %>
+
+ <% end %>
+
+
+ <% else %>
+
+
+
+
+ <.icon name="hero-chat-bubble-left-right" class="w-8 h-8" />
+
+
+ Select a chat to start messaging
+
+
+ Choose a channel, group, or direct message from the sidebar.
+
+
+
+ <.icon name="hero-chat-bubble-left-right" class="w-4 h-4" /> New DM
+
+
+ <.icon name="hero-globe-alt" class="w-4 h-4" /> Explore Servers
+
+
+
+
+ <% end %>
+
+
+
+ <%= if @conversation.selected do %>
+
+
+
+ Chat Info
+
+
+ {route_label(@conversation.selected, @current_user.id)}
+
+
+
+
+
+
+
+ <%= if @conversation.selected.type == "dm" do %>
+ <% other_user =
+ Enum.find(
+ @conversation.selected.members,
+ &(&1.user_id != @current_user.id and is_nil(&1.left_at))
+ ) %>
+ <%= if other_user do %>
+
+ <.conversation_avatar
+ conversation={@conversation.selected}
+ current_user_id={@current_user.id}
+ size="2xl"
+ online_users={@online_users}
+ user_statuses={@user_statuses}
+ />
+
+ <% else %>
+
+ <.conversation_avatar
+ conversation={@conversation.selected}
+ current_user_id={@current_user.id}
+ size="2xl"
+ online_users={@online_users}
+ user_statuses={@user_statuses}
+ />
+
+ <% end %>
+ <% else %>
+
+ <.conversation_avatar
+ conversation={@conversation.selected}
+ current_user_id={@current_user.id}
+ size="2xl"
+ online_users={@online_users}
+ user_statuses={@user_statuses}
+ />
+
+ <% end %>
+
+
+
+ {route_label(@conversation.selected, @current_user.id)}
+
+
+
+ From another server
+
+
+ <%= if @conversation.selected.type == "channel" do %>
+
+ <%= if @conversation.selected.server_id do %>
+ <%= if @conversation.selected.is_public do %>
+ <.icon name="hero-user-group" class="w-3.5 h-3.5 text-success" />
+ Access: server members
+ <% else %>
+ <.icon name="hero-lock-closed" class="w-3.5 h-3.5 text-warning" />
+ Access: private channel
+ <% end %>
+ <% else %>
+ <%= if @conversation.selected.is_public do %>
+ <.icon name="hero-globe-alt" class="w-3.5 h-3.5 text-success" />
+ Visibility: public
+ <% else %>
+ <.icon name="hero-lock-closed" class="w-3.5 h-3.5 text-warning" />
+ Visibility: private
+ <% end %>
+ <% end %>
+
+ <% end %>
+
+ <%= if @conversation.selected.type == "channel" and @conversation.selected.channel_topic do %>
+
+ Topic: {@conversation.selected.channel_topic}
+
+ <% end %>
+
+ <%= if @conversation.selected.description do %>
+
+ {@conversation.selected.description}
+
+ <% end %>
+
+
+
+ <%= if @conversation.selected.type != "dm" do %>
+
+
+
+
Members
+
+ {@conversation.selected.member_count}
+
+
+
+
+ <%= for member <- @conversation.selected.members do %>
+ <%= if member.left_at == nil do %>
+
+
+ <.user_avatar
+ user={member.user}
+ size="sm"
+ user_statuses={@user_statuses}
+ />
+
+
+
+
+ <.username_with_effects
+ user={member.user}
+ display_name={true}
+ verified_size="xs"
+ />
+
+
+ {user_at_handle(member.user)}
+
+
+
+
+ <%= if member.role == "admin" do %>
+
admin
+ <% end %>
+
+ <% current_member =
+ Enum.find(
+ @conversation.selected.members,
+ &(&1.user_id == @current_user.id and is_nil(&1.left_at))
+ ) %>
+ <%= if current_member && current_member.role == "admin" && member.user_id != @current_user.id do %>
+
+
+ <.icon name="hero-ellipsis-vertical" class="w-3 h-3" />
+
+
+
+ <% end %>
+
+
+ <% end %>
+ <% end %>
+
+
+
+ <% end %>
+
+
+
+
Actions
+
+ <%= if @conversation.selected.type == "dm" do %>
+ <% other_user =
+ Enum.find(
+ @conversation.selected.members,
+ &(&1.user_id != @current_user.id and is_nil(&1.left_at))
+ ) %>
+ <%= if other_user do %>
+
+ <.icon name="hero-user" class="w-4 h-4 mr-2" /> View Profile
+
+ <% end %>
+ <% end %>
+
+
+ <.icon name="hero-magnifying-glass" class="w-4 h-4 mr-2" /> Search Messages
+
+
+ <%= if @conversation.selected.type == "group" do %>
+
+ <.icon name="hero-user-plus" class="w-4 h-4 mr-2" /> Add Members
+
+ <% end %>
+
+ <%= if @conversation.selected.type == "channel" do %>
+ <% current_member =
+ Enum.find(
+ @conversation.selected.members,
+ &(&1.user_id == @current_user.id and is_nil(&1.left_at))
+ ) %>
+ <%= if @current_user.is_admin ||
+ (current_member &&
+ current_member.role in ["owner", "admin", "moderator"]) do %>
+
+ <.icon name="hero-user-plus" class="w-4 h-4 mr-2" /> Add Subscribers
+
+ <% end %>
+ <% end %>
+
+ <%= if @conversation.selected.type != "dm" do %>
+
+ <.icon name="hero-arrow-left-on-rectangle" class="w-4 h-4 mr-2" />
+ Leave {conversation_type_label(@conversation.selected.type)}
+
+ <% end %>
+
+ <%= if @conversation.selected.is_public and @conversation.selected.type != "dm" do %>
+
+ <.icon name="hero-share" class="w-4 h-4 mr-2" /> Share Link
+
+ <% end %>
+
+ <%= if @conversation.selected.type == "dm" do %>
+ <% other_user =
+ Enum.find(@conversation.selected.members, fn member ->
+ member.user_id != @current_user.id and is_nil(member.left_at)
+ end) %>
+ <%= if other_user do %>
+ <%= if Elektrine.Accounts.user_blocked?(@current_user.id, other_user.user_id) do %>
+
+ <.icon name="hero-check" class="w-4 h-4 mr-2" /> Unblock User
+
+ <% else %>
+
+ <.icon name="hero-no-symbol" class="w-4 h-4 mr-2" /> Block User
+
+ <% end %>
+ <% end %>
+ <% end %>
+
+
+ <.icon name="hero-cog-6-tooth" class="w-4 h-4 mr-2" /> Settings
+
+
+
+
+
+
+ <% end %>
+
+
+
+ <%= if @ui.show_new_chat do %>
+
+
+
+
New Direct Message
+
+ <.icon name="hero-x-mark" class="w-5 h-5" />
+
+
+
+
+
+
+
+
+ Search local users, or type a full remote handle for federated DMs.
+
+
+ <%= if @search.results != [] do %>
+
+ <%= for user <- @search.results do %>
+ <% remote_handle = Map.get(user, :remote_handle) %>
+ <% is_remote_user = is_binary(remote_handle) and remote_handle != "" %>
+
+
+
+ <.user_avatar user={user} size="sm" />
+
+
+
+
+ <.username_with_effects user={user} display_name={true} verified_size="xs" />
+
+
{user_at_handle(user)}
+
+ <%= if is_remote_user do %>
+ Remote
+ <% end %>
+
+ <% end %>
+
+ <% else %>
+
+ <.icon
+ name="hero-chat-bubble-left-right"
+ class="w-8 h-8 mx-auto mb-2 text-base-content/50"
+ />
+
+ Type at least 2 characters to find people
+
+
+ <% end %>
+
+
+
+ <% end %>
+
+
+ <%= if @ui.show_group_modal do %>
+ <.live_component
+ module={ArblargWeb.ChatLive.Components.GroupModal}
+ id="group-modal"
+ search_query={@search.query}
+ search_results={@search.results}
+ selected_users={@form.selected_users}
+ group_name={@form.group_name}
+ group_description={@form.group_description}
+ group_is_public={@form.group_is_public}
+ uploads={@uploads}
+ />
+ <% end %>
+
+
+ <%= if @ui.show_channel_modal do %>
+ <.live_component
+ module={ArblargWeb.ChatLive.Components.ChannelModal}
+ id="channel-modal"
+ uploads={@uploads}
+ categories={@server_categories}
+ />
+ <% end %>
+
+ <.chat_overlay_panels {assigns} />
+
+
+ <%= if @voice.joined_id do %>
+
+ <.icon name="hero-speaker-wave" class="w-5 h-5 text-success flex-shrink-0" />
+
+
+ Voice connected
+
+
{@voice.joined_name}
+
+
+ <.icon
+ name={if @voice.muted, do: "hero-speaker-x-mark", else: "hero-microphone"}
+ class={["w-4 h-4", if(@voice.muted, do: "text-error", else: "")]}
+ />
+
+
+ <.icon name="hero-phone-x-mark" class="w-4 h-4" />
+
+
+ <% end %>
+
diff --git a/apps/arblarg/lib/arblarg_web/live/chat_live/operations/call_info_operations.ex b/apps/arblarg/lib/arblarg_web/live/chat_live/operations/call_info_operations.ex
new file mode 100644
index 0000000..d70ea29
--- /dev/null
+++ b/apps/arblarg/lib/arblarg_web/live/chat_live/operations/call_info_operations.ex
@@ -0,0 +1,103 @@
+defmodule ArblargWeb.ChatLive.Operations.CallInfoOperations do
+ @moduledoc false
+
+ import Phoenix.Component, only: [assign: 3]
+ import Phoenix.LiveView, only: [push_event: 3]
+ import ElektrineWeb.Live.NotificationHelpers, only: [notify_info: 2]
+
+ @doc false
+ def route_info(info, socket) do
+ case info do
+ {:incoming_call, call} -> {:handled, handle_incoming_call(socket, call)}
+ {:call_rejected, call} -> {:handled, handle_call_rejected(socket, call)}
+ {:call_ended, call} -> {:handled, handle_call_ended(socket, call)}
+ {:call_missed, call} -> {:handled, handle_call_missed(socket, call)}
+ _ -> :unhandled
+ end
+ end
+
+ def handle_incoming_call(socket, call) do
+ # Deduplicate - ignore if already showing this call
+ if socket.assigns.call && socket.assigns.call.incoming_call &&
+ socket.assigns.call.incoming_call.id == call.id do
+ {:noreply, socket}
+ else
+ call =
+ if local_call_with_loaded_users?(call) do
+ call
+ else
+ maybe_reload_local_call(call)
+ end
+
+ socket =
+ socket
+ |> assign(:call, %{socket.assigns.call | incoming_call: call, status: "ringing"})
+ |> assign(:ui, Map.put(socket.assigns.ui, :show_incoming_call, true))
+ |> push_event("play_incoming_ringtone", %{})
+
+ {:noreply, socket}
+ end
+ end
+
+ def handle_call_rejected(socket, call),
+ do: handle_call_terminal_event(socket, call, "call_rejected", "Call was rejected")
+
+ def handle_call_ended(socket, call),
+ do: handle_call_terminal_event(socket, call, "call_ended", "Call ended")
+
+ def handle_call_missed(socket, call),
+ do: handle_call_terminal_event(socket, call, "call_missed", "Call timed out")
+
+ defp handle_call_terminal_event(socket, call, event_type, message) do
+ event_key = {event_type, call.id}
+
+ if MapSet.member?(socket.assigns.processed_call_events, event_key) do
+ {:noreply, socket}
+ else
+ has_incoming =
+ socket.assigns.call && socket.assigns.call.incoming_call &&
+ socket.assigns.call.incoming_call.id == call.id
+
+ has_active =
+ socket.assigns.call && socket.assigns.call.active_call &&
+ socket.assigns.call.active_call.id == call.id
+
+ processed = MapSet.put(socket.assigns.processed_call_events, event_key)
+
+ if has_incoming || has_active do
+ cleared_call_state = reset_call_state(socket.assigns.call)
+
+ socket =
+ socket
+ |> assign(:ui, Map.put(socket.assigns.ui, :show_incoming_call, false))
+ |> assign(:call, cleared_call_state)
+ |> assign(:processed_call_events, processed)
+ |> push_event("stop_ringtone", %{})
+ |> notify_info(message)
+
+ {:noreply, socket}
+ else
+ {:noreply, assign(socket, :processed_call_events, processed)}
+ end
+ end
+ end
+
+ defp reset_call_state(call_state) do
+ call_state
+ |> Map.put(:incoming_call, nil)
+ |> Map.put(:active_call, nil)
+ |> Map.put(:status, nil)
+ |> Map.put(:audio_enabled, true)
+ |> Map.put(:video_enabled, true)
+ end
+
+ defp local_call_with_loaded_users?(%{source: :federated}), do: true
+
+ defp local_call_with_loaded_users?(call) do
+ Ecto.assoc_loaded?(call.caller) and Ecto.assoc_loaded?(call.callee)
+ end
+
+ defp maybe_reload_local_call(call) do
+ Elektrine.Calls.get_call_with_users(call.id) || call
+ end
+end
diff --git a/apps/arblarg/lib/arblarg_web/live/chat_live/operations/call_operations.ex b/apps/arblarg/lib/arblarg_web/live/chat_live/operations/call_operations.ex
new file mode 100644
index 0000000..92dc0e6
--- /dev/null
+++ b/apps/arblarg/lib/arblarg_web/live/chat_live/operations/call_operations.ex
@@ -0,0 +1,497 @@
+defmodule ArblargWeb.ChatLive.Operations.CallOperations do
+ @moduledoc "Handles voice/video call operations: initiate, answer, reject, end, audio/video toggle.\nExtracted from ChatLive.Home.\n"
+ import Phoenix.LiveView
+ import Phoenix.Component
+ import ElektrineWeb.Live.NotificationHelpers
+ alias ArblargWeb.ChatLive.Operations.Helpers
+ alias Elektrine.Calls
+ alias Elektrine.Calls.Transport, as: CallTransport
+ alias Elektrine.Messaging.Federation
+ alias Elektrine.Messaging.Federation.VoiceCalls
+
+ def handle_event(
+ "initiate_call",
+ %{"call_type" => call_type, "conversation_id" => conversation_id_str} = params,
+ socket
+ ) do
+ case parse_optional_integer(conversation_id_str) do
+ {:ok, conversation_id} ->
+ case remote_call_target(params, conversation_id, socket) do
+ {:remote, remote_handle, remote_conversation_id} ->
+ initiate_federated_call(socket, remote_handle, remote_conversation_id, call_type)
+
+ :local ->
+ initiate_local_call(socket, params, conversation_id, call_type)
+
+ {:error, :invalid_remote_handle} ->
+ {:noreply, notify_error(socket, "Invalid remote call target")}
+ end
+
+ {:error, :invalid_integer} ->
+ {:noreply, notify_error(socket, "Invalid call request")}
+ end
+ end
+
+ def handle_event("answer_call", %{"call_id" => call_id}, socket) do
+ case parse_integer(call_id) do
+ {:ok, call_id} ->
+ answer_call(socket, call_id)
+
+ {:error, :invalid_integer} ->
+ {:noreply, notify_error(socket, "Invalid call request")}
+ end
+ end
+
+ def handle_event("reject_call", %{"call_id" => call_id}, socket) do
+ case parse_integer(call_id) do
+ {:ok, call_id} ->
+ reject_call(socket, call_id)
+
+ {:error, :invalid_integer} ->
+ {:noreply, notify_error(socket, "Invalid call request")}
+ end
+ end
+
+ def handle_event("end_call", %{"call_id" => call_id}, socket) do
+ case parse_integer(call_id) do
+ {:ok, call_id} ->
+ end_call(socket, call_id)
+
+ {:error, :invalid_integer} ->
+ {:noreply, notify_error(socket, "Invalid call request")}
+ end
+ end
+
+ def handle_event("toggle_audio", _params, socket) do
+ {:noreply, socket}
+ end
+
+ def handle_event("toggle_video", _params, socket) do
+ {:noreply, socket}
+ end
+
+ def handle_event("audio_toggled", %{"enabled" => enabled}, socket) do
+ {:noreply, assign(socket, :call, %{socket.assigns.call | audio_enabled: truthy?(enabled)})}
+ end
+
+ def handle_event("video_toggled", %{"enabled" => enabled}, socket) do
+ {:noreply, assign(socket, :call, %{socket.assigns.call | video_enabled: truthy?(enabled)})}
+ end
+
+ def handle_event("call_error", %{"error" => error}, socket) do
+ maybe_fail_active_call(socket)
+
+ {:noreply,
+ socket
+ |> assign(:ui, Map.put(socket.assigns.ui, :show_incoming_call, false))
+ |> assign(:call, reset_call_state(socket.assigns.call))
+ |> push_event("stop_ringtone", %{})
+ |> notify_error("Call error: #{error}")}
+ end
+
+ def handle_event("call_started", _params, socket) do
+ {:noreply, assign(socket, :call, %{socket.assigns.call | status: "connecting"})}
+ end
+
+ def handle_event("remote_stream_ready", _params, socket) do
+ {:noreply, socket}
+ end
+
+ def handle_event("call_answered", _params, socket) do
+ {:noreply, assign(socket, :call, %{socket.assigns.call | status: "connecting"})}
+ end
+
+ def handle_event("call_connected", _params, socket) do
+ {:noreply, assign(socket, :call, %{socket.assigns.call | status: "connected"})}
+ end
+
+ def handle_event("call_ended", params, socket) do
+ maybe_finish_active_call(socket, params["reason"])
+
+ {:noreply,
+ socket
+ |> assign(:ui, Map.put(socket.assigns.ui, :show_incoming_call, false))
+ |> assign(:call, reset_call_state(socket.assigns.call))
+ |> push_event("stop_ringtone", %{})}
+ end
+
+ def handle_event("call_ended_by_user", _params, socket) do
+ maybe_end_active_call(socket)
+
+ {:noreply,
+ socket
+ |> assign(:ui, Map.put(socket.assigns.ui, :show_incoming_call, false))
+ |> assign(:call, reset_call_state(socket.assigns.call))
+ |> push_event("stop_ringtone", %{})}
+ end
+
+ def handle_event("call_rejected", _params, socket) do
+ {:noreply,
+ socket
+ |> assign(:ui, Map.put(socket.assigns.ui, :show_incoming_call, false))
+ |> assign(:call, reset_call_state(socket.assigns.call))
+ |> push_event("stop_ringtone", %{})
+ |> notify_info("Call was rejected")}
+ end
+
+ defp reset_call_state(call_state) do
+ %{
+ call_state
+ | active_call: nil,
+ incoming_call: nil,
+ status: nil,
+ audio_enabled: true,
+ video_enabled: true
+ }
+ end
+
+ defp initiate_local_call(socket, params, conversation_id, call_type) do
+ case parse_integer(params["user_id"]) do
+ {:ok, callee_id} ->
+ caller_id = socket.assigns.current_user.id
+
+ case VoiceCalls.local_user_busy_reason(caller_id) do
+ :federated_call_active ->
+ {:noreply, notify_error(socket, "You're already in another call")}
+
+ _ ->
+ case Calls.initiate_call(caller_id, callee_id, call_type, conversation_id) do
+ {:ok, call} ->
+ full_call = Calls.get_call_with_users(call.id) || call
+ transport = call_transport(full_call.id, socket)
+
+ Phoenix.PubSub.broadcast(
+ Elektrine.PubSub,
+ "user:#{full_call.callee_id}",
+ {:incoming_call, full_call}
+ )
+
+ {:noreply,
+ socket
+ |> assign(:ui, Map.put(socket.assigns.ui, :show_incoming_call, false))
+ |> assign(:call, call_state_for(socket.assigns.call, full_call, "calling"))
+ |> push_event("start_call", %{
+ call_id: full_call.id,
+ call_type: full_call.call_type,
+ ice_servers: transport["ice_servers"],
+ transport: transport,
+ user_token: user_token(socket),
+ user_id: caller_id
+ })}
+
+ {:error, :caller_already_in_call} ->
+ {:noreply, notify_error(socket, "You're already in a call")}
+
+ {:error, :callee_already_in_call} ->
+ {:noreply, notify_error(socket, "This user is already in another call")}
+
+ {:error, :rate_limit_exceeded} ->
+ {:noreply,
+ notify_error(socket, "You're starting calls too quickly. Try again shortly.")}
+
+ {:error, :invalid_conversation} ->
+ {:noreply,
+ notify_error(socket, "Call can only be started from your shared DM conversation")}
+
+ {:error, reason} ->
+ error_msg = Elektrine.Privacy.privacy_error_message(reason)
+ {:noreply, notify_error(socket, error_msg)}
+ end
+ end
+
+ {:error, :invalid_integer} ->
+ {:noreply, notify_error(socket, "Invalid call request")}
+ end
+ end
+
+ defp initiate_federated_call(socket, _remote_handle, conversation_id, call_type)
+ when is_integer(conversation_id) do
+ caller_id = socket.assigns.current_user.id
+
+ with {:ok, session} <-
+ VoiceCalls.start_outbound_session(caller_id, conversation_id, call_type),
+ :ok <- Federation.publish_dm_call_invite(session.id) do
+ full_call = VoiceCalls.ui_call(session)
+ transport = call_transport(full_call.id, socket)
+
+ {:noreply,
+ socket
+ |> assign(:ui, Map.put(socket.assigns.ui, :show_incoming_call, false))
+ |> assign(:call, call_state_for(socket.assigns.call, full_call, "calling"))
+ |> push_event("start_call", %{
+ call_id: full_call.id,
+ call_type: full_call.call_type,
+ ice_servers: transport["ice_servers"],
+ transport: transport,
+ user_token: user_token(socket),
+ user_id: caller_id
+ })}
+ else
+ {:error, :remote_call_already_active} ->
+ {:noreply, notify_error(socket, "You already have an active remote call in this DM")}
+
+ {:error, :local_call_already_active} ->
+ {:noreply, notify_error(socket, "You're already in another call")}
+
+ {:error, :invalid_remote_call} ->
+ {:noreply, notify_error(socket, "Call can only be started from a federated DM")}
+
+ {:error, reason} ->
+ {:noreply, notify_error(socket, "Failed to start remote call: #{format_reason(reason)}")}
+ end
+ end
+
+ defp answer_call(socket, call_id) do
+ case current_call_source(socket, call_id) do
+ :federated ->
+ case VoiceCalls.accept_session(call_id, socket.assigns.current_user.id) do
+ {:ok, session} ->
+ full_call = socket.assigns.call.incoming_call || VoiceCalls.ui_call(session)
+ transport = call_transport(call_id, socket)
+
+ {:noreply,
+ socket
+ |> assign(:ui, Map.put(socket.assigns.ui, :show_incoming_call, false))
+ |> assign(:call, call_state_for(socket.assigns.call, full_call, "connecting"))
+ |> push_event("stop_ringtone", %{})
+ |> push_event("answer_call", %{
+ call_id: call_id,
+ ice_servers: transport["ice_servers"],
+ transport: transport,
+ user_token: user_token(socket),
+ user_id: socket.assigns.current_user.id
+ })}
+
+ {:error, _reason} ->
+ {:noreply, notify_error(socket, "Failed to answer call")}
+ end
+
+ _ ->
+ case Calls.answer_call(call_id) do
+ {:ok, call} ->
+ full_call =
+ socket.assigns.call.incoming_call || socket.assigns.call.active_call ||
+ Calls.get_call_with_users(call.id)
+
+ transport = call_transport(call_id, socket)
+
+ {:noreply,
+ socket
+ |> assign(:ui, Map.put(socket.assigns.ui, :show_incoming_call, false))
+ |> assign(:call, call_state_for(socket.assigns.call, full_call, "connecting"))
+ |> push_event("stop_ringtone", %{})
+ |> push_event("answer_call", %{
+ call_id: call_id,
+ ice_servers: transport["ice_servers"],
+ transport: transport,
+ user_token: user_token(socket),
+ user_id: socket.assigns.current_user.id
+ })}
+
+ {:error, _reason} ->
+ {:noreply, notify_error(socket, "Failed to answer call")}
+ end
+ end
+ end
+
+ defp reject_call(socket, call_id) do
+ if current_call_source(socket, call_id) == :federated do
+ _ = VoiceCalls.reject_session(call_id, socket.assigns.current_user.id)
+ else
+ _ = Calls.reject_call(call_id)
+ end
+
+ {:noreply,
+ socket
+ |> assign(:ui, Map.put(socket.assigns.ui, :show_incoming_call, false))
+ |> assign(:call, reset_call_state(socket.assigns.call))
+ |> push_event("stop_ringtone", %{})
+ |> push_event("reject_call", %{call_id: call_id, user_token: user_token(socket)})}
+ end
+
+ defp end_call(socket, call_id) do
+ if current_call_source(socket, call_id) == :federated do
+ _ = VoiceCalls.end_session(call_id, socket.assigns.current_user.id)
+ else
+ _ = Calls.end_call(call_id)
+ end
+
+ {:noreply,
+ socket
+ |> assign(:ui, Map.put(socket.assigns.ui, :show_incoming_call, false))
+ |> assign(:call, reset_call_state(socket.assigns.call))
+ |> push_event("stop_ringtone", %{})}
+ end
+
+ defp call_state_for(current_call_state, call, status) do
+ %{
+ current_call_state
+ | active_call: call,
+ incoming_call: nil,
+ status: status,
+ audio_enabled: true,
+ video_enabled: not is_nil(call) and call.call_type == "video"
+ }
+ end
+
+ defp current_call_source(socket, call_id) do
+ active_call = socket.assigns.call.active_call
+ incoming_call = socket.assigns.call.incoming_call
+
+ cond do
+ is_map(active_call) and active_call.id == call_id ->
+ call_source(active_call)
+
+ is_map(incoming_call) and incoming_call.id == call_id ->
+ call_source(incoming_call)
+
+ match?(%{source: :federated}, active_call) or match?(%{source: :federated}, incoming_call) ->
+ :federated
+
+ true ->
+ :local
+ end
+ end
+
+ defp call_source(%{source: source}) when not is_nil(source), do: source
+ defp call_source(_call), do: :local
+
+ defp maybe_fail_active_call(socket) do
+ case socket.assigns.call.active_call do
+ %{source: :federated, id: session_id} ->
+ _ = VoiceCalls.fail_session(session_id, socket.assigns.current_user.id)
+ _ = Federation.publish_dm_call_end(session_id)
+
+ %{id: call_id} ->
+ _ = Calls.update_call_status(call_id, "failed")
+
+ _ ->
+ :ok
+ end
+ end
+
+ defp maybe_finish_active_call(socket, reason) do
+ case normalize_call_end_reason(reason) do
+ :failed -> maybe_fail_active_call(socket)
+ :rejected -> maybe_reject_active_call(socket)
+ :missed -> maybe_miss_active_call(socket)
+ :ended -> maybe_end_active_call(socket)
+ end
+ end
+
+ defp normalize_call_end_reason(reason) when reason in ["failed", "network_offline"], do: :failed
+ defp normalize_call_end_reason("failed: " <> _reason), do: :failed
+ defp normalize_call_end_reason("rejected"), do: :rejected
+ defp normalize_call_end_reason("missed"), do: :missed
+ defp normalize_call_end_reason(_reason), do: :ended
+
+ defp maybe_reject_active_call(socket) do
+ case socket.assigns.call.active_call do
+ %{source: :federated, id: session_id} ->
+ _ = VoiceCalls.reject_session(session_id, socket.assigns.current_user.id)
+
+ %{id: call_id} ->
+ _ = Calls.reject_call(call_id)
+
+ _ ->
+ :ok
+ end
+ end
+
+ defp maybe_miss_active_call(socket) do
+ case socket.assigns.call.active_call do
+ %{source: :federated, id: session_id} ->
+ _ = VoiceCalls.end_session(session_id, socket.assigns.current_user.id, "missed")
+
+ %{id: call_id} ->
+ _ = Calls.miss_call(call_id)
+
+ _ ->
+ :ok
+ end
+ end
+
+ defp maybe_end_active_call(socket) do
+ case socket.assigns.call.active_call do
+ %{source: :federated, id: session_id} ->
+ _ = VoiceCalls.end_session(session_id, socket.assigns.current_user.id)
+
+ %{id: call_id} ->
+ _ = Calls.end_call(call_id)
+
+ _ ->
+ :ok
+ end
+ end
+
+ defp remote_call_target(params, conversation_id, _socket) do
+ remote_handle = normalize_remote_handle(params["remote_handle"])
+
+ cond do
+ is_binary(remote_handle) and is_integer(conversation_id) ->
+ {:remote, remote_handle, conversation_id}
+
+ is_binary(remote_handle) ->
+ {:error, :invalid_remote_handle}
+
+ true ->
+ :local
+ end
+ end
+
+ defp normalize_remote_handle(handle) when is_binary(handle) do
+ case String.trim(handle) do
+ "" -> nil
+ trimmed -> trimmed
+ end
+ end
+
+ defp normalize_remote_handle(_handle), do: nil
+
+ defp format_reason(reason) when is_atom(reason), do: Atom.to_string(reason)
+ defp format_reason(reason) when is_binary(reason), do: reason
+ defp format_reason(_reason), do: "unknown"
+
+ defp parse_integer(value) when is_binary(value) do
+ case Integer.parse(value) do
+ {parsed, ""} -> {:ok, parsed}
+ _ -> {:error, :invalid_integer}
+ end
+ end
+
+ defp parse_integer(value) when is_integer(value) do
+ {:ok, value}
+ end
+
+ defp parse_integer(_value) do
+ {:error, :invalid_integer}
+ end
+
+ defp parse_optional_integer(nil) do
+ {:ok, nil}
+ end
+
+ defp parse_optional_integer("") do
+ {:ok, nil}
+ end
+
+ defp parse_optional_integer(value) do
+ parse_integer(value)
+ end
+
+ defp truthy?(value) when value in [true, "true", 1, "1"] do
+ true
+ end
+
+ defp truthy?(_value) do
+ false
+ end
+
+ defp user_token(socket) do
+ socket.assigns[:user_token] || Helpers.generate_user_token(socket.assigns.current_user.id)
+ end
+
+ defp call_transport(call_id, socket) when is_integer(call_id) do
+ CallTransport.descriptor_for_user(socket.assigns.current_user.id, call_id)
+ end
+end
diff --git a/apps/arblarg/lib/arblarg_web/live/chat_live/operations/context_menu_operations.ex b/apps/arblarg/lib/arblarg_web/live/chat_live/operations/context_menu_operations.ex
new file mode 100644
index 0000000..65014f4
--- /dev/null
+++ b/apps/arblarg/lib/arblarg_web/live/chat_live/operations/context_menu_operations.ex
@@ -0,0 +1,102 @@
+defmodule ArblargWeb.ChatLive.Operations.ContextMenuOperations do
+ @moduledoc """
+ Handles context menu operations: show/hide conversation and message context menus.
+ Extracted from ChatLive.Home.
+ """
+
+ import Phoenix.Component
+
+ def handle_event(
+ "show_context_menu",
+ %{"conversation_id" => conversation_id, "x" => x, "y" => y},
+ socket
+ ) do
+ conversation = find_by_id(socket.assigns.conversation.list, conversation_id)
+
+ {:noreply,
+ assign(socket, :context_menu, %{
+ socket.assigns.context_menu
+ | conversation: conversation,
+ selected_text: nil,
+ position: %{x: x, y: y}
+ })}
+ end
+
+ def handle_event("show_context_menu", %{"conversation_id" => conversation_id}, socket) do
+ conversation = find_by_id(socket.assigns.conversation.list, conversation_id)
+
+ {:noreply,
+ assign(socket, :context_menu, %{
+ socket.assigns.context_menu
+ | conversation: conversation,
+ selected_text: nil
+ })}
+ end
+
+ def handle_event("hide_context_menu", _params, socket) do
+ {:noreply,
+ assign(socket, :context_menu, %{
+ socket.assigns.context_menu
+ | conversation: nil,
+ selected_text: nil
+ })}
+ end
+
+ def handle_event(
+ "show_message_context_menu",
+ %{"message_id" => message_id, "x" => x, "y" => y} = params,
+ socket
+ ) do
+ message = find_by_id(socket.assigns.messages, message_id)
+
+ {:noreply,
+ assign(socket, :context_menu, %{
+ socket.assigns.context_menu
+ | message: message,
+ selected_text: selected_text(params),
+ position: %{x: x, y: y}
+ })}
+ end
+
+ def handle_event("show_message_context_menu", %{"message_id" => message_id}, socket) do
+ message = find_by_id(socket.assigns.messages, message_id)
+
+ {:noreply,
+ assign(socket, :context_menu, %{
+ socket.assigns.context_menu
+ | message: message,
+ selected_text: nil
+ })}
+ end
+
+ def handle_event("hide_message_context_menu", _params, socket) do
+ {:noreply,
+ assign(socket, :context_menu, %{
+ socket.assigns.context_menu
+ | message: nil,
+ selected_text: nil
+ })}
+ end
+
+ defp find_by_id(items, id) when is_integer(id) do
+ Enum.find(items, &(&1.id == id))
+ end
+
+ defp find_by_id(items, id) do
+ case Integer.parse(to_string(id)) do
+ {parsed_id, ""} when parsed_id > 0 -> Enum.find(items, &(&1.id == parsed_id))
+ _ -> nil
+ end
+ end
+
+ defp selected_text(%{"selected_text" => text}) when is_binary(text) do
+ text
+ |> String.trim()
+ |> case do
+ "" -> nil
+ value -> value
+ end
+ end
+
+ defp selected_text(_params), do: nil
+end
diff --git a/apps/arblarg/lib/arblarg_web/live/chat_live/operations/conversation_operations.ex b/apps/arblarg/lib/arblarg_web/live/chat_live/operations/conversation_operations.ex
new file mode 100644
index 0000000..342ba43
--- /dev/null
+++ b/apps/arblarg/lib/arblarg_web/live/chat_live/operations/conversation_operations.ex
@@ -0,0 +1,417 @@
+defmodule ArblargWeb.ChatLive.Operations.ConversationOperations do
+ @moduledoc """
+ Handles conversation management: selection, search, pin, settings, edit, delete, leave.
+ Extracted from ChatLive.Home.
+ """
+
+ import Phoenix.LiveView
+ import Phoenix.Component
+ import ElektrineWeb.Live.NotificationHelpers
+
+ use Phoenix.VerifiedRoutes,
+ endpoint: ElektrineWeb.Endpoint,
+ router: ElektrineWeb.Router
+
+ alias Elektrine.Accounts
+ alias Elektrine.Messaging, as: Messaging
+
+ def handle_event("select_conversation", %{"id" => conversation_id}, socket) do
+ conversation_ref =
+ socket.assigns.conversation.list
+ |> Enum.find(&(Integer.to_string(&1.id) == conversation_id))
+ |> case do
+ nil -> conversation_id
+ conversation -> conversation
+ end
+
+ {:noreply, push_patch(socket, to: Elektrine.Paths.chat_path(conversation_ref))}
+ end
+
+ def handle_event("search_conversations", params, socket) do
+ query = Map.get(params, "query") || Map.get(params, "value") || ""
+
+ scoped_conversations =
+ ArblargWeb.ChatLive.Operations.Helpers.scope_conversations_to_server(
+ socket.assigns.conversation.list,
+ socket.assigns[:active_server_id]
+ )
+
+ filtered =
+ if query == "" do
+ scoped_conversations
+ else
+ ArblargWeb.ChatLive.Operations.Helpers.filter_conversations(
+ scoped_conversations,
+ query,
+ socket.assigns.current_user.id
+ )
+ end
+
+ # Also search for users and public servers/groups when query is 2+ characters
+ {user_results, public_server_results, public_group_results} =
+ if String.length(query) >= 2 do
+ users =
+ query
+ |> Accounts.search_users(socket.assigns.current_user.id)
+ |> maybe_add_remote_handle_result(query)
+
+ servers =
+ Messaging.list_public_servers(socket.assigns.current_user.id, query: query, limit: 10)
+
+ public_conversations =
+ Messaging.search_public_chat_conversations(query, socket.assigns.current_user.id)
+
+ groups = Enum.filter(public_conversations, &(&1.type == "group"))
+ {users, servers, groups}
+ else
+ {[], [], []}
+ end
+
+ {:noreply,
+ socket
+ |> assign(:conversation, %{socket.assigns.conversation | filtered: filtered})
+ |> assign(:search, %{
+ socket.assigns.search
+ | conversation_query: query,
+ user_results: user_results
+ })
+ |> assign(:public_server_search_results, public_server_results)
+ |> assign(:public_group_search_results, public_group_results)}
+ end
+
+ def handle_event("clear_selection", _params, socket) do
+ {:noreply, push_patch(socket, to: Elektrine.Paths.chat_root_path())}
+ end
+
+ def handle_event("pin_conversation", %{"conversation_id" => conversation_id}, socket) do
+ user_id = socket.assigns.current_user.id
+
+ with {:ok, conversation_id} <- parse_positive_int(conversation_id),
+ {:ok, _} <- Messaging.pin_conversation(conversation_id, user_id) do
+ {:noreply, socket}
+ else
+ _ -> {:noreply, notify_error(socket, "Failed to pin chat")}
+ end
+ end
+
+ def handle_event("unpin_conversation", %{"conversation_id" => conversation_id}, socket) do
+ user_id = socket.assigns.current_user.id
+
+ with {:ok, conversation_id} <- parse_positive_int(conversation_id),
+ {:ok, _} <- Messaging.unpin_conversation(conversation_id, user_id) do
+ {:noreply, socket}
+ else
+ _ -> {:noreply, notify_error(socket, "Failed to unpin chat")}
+ end
+ end
+
+ def handle_event("mark_as_read", %{"conversation_id" => conversation_id_str}, socket) do
+ case parse_positive_int(conversation_id_str) do
+ {:ok, conversation_id} ->
+ user_id = socket.assigns.current_user.id
+
+ case Messaging.mark_as_read(conversation_id, user_id) do
+ {:ok, _} ->
+ updated_unread_counts =
+ Map.put(socket.assigns.conversation.unread_counts, conversation_id, 0)
+
+ {:noreply,
+ socket
+ |> assign(:conversation, %{
+ socket.assigns.conversation
+ | unread_counts: updated_unread_counts,
+ unread_count: Messaging.get_unread_count(user_id)
+ })
+ |> assign(:context_menu, %{socket.assigns.context_menu | conversation: nil})
+ |> assign(:first_unread_message_id, nil)
+ |> notify_info("Marked as read")}
+
+ {:error, :unauthorized} ->
+ {:noreply,
+ socket
+ |> assign(:context_menu, %{socket.assigns.context_menu | conversation: nil})
+ |> notify_error("You are not a member of this chat")}
+
+ {:error, _} ->
+ {:noreply,
+ socket
+ |> assign(:context_menu, %{socket.assigns.context_menu | conversation: nil})
+ |> notify_error("Failed to mark as read")}
+ end
+
+ :error ->
+ {:noreply,
+ socket
+ |> assign(:context_menu, %{socket.assigns.context_menu | conversation: nil})
+ |> notify_error("Failed to mark as read")}
+ end
+ end
+
+ def handle_event("clear_history", %{"conversation_id" => conversation_id_str}, socket) do
+ case parse_positive_int(conversation_id_str) do
+ {:ok, conversation_id} ->
+ user_id = socket.assigns.current_user.id
+
+ case Messaging.clear_history_for_user(conversation_id, user_id) do
+ {:ok, :cleared} ->
+ selected_conversation = socket.assigns.conversation.selected
+
+ socket =
+ if selected_conversation && selected_conversation.id == conversation_id do
+ socket
+ |> assign(:messages, [])
+ |> assign(:oldest_message_id, nil)
+ |> assign(:newest_message_id, nil)
+ |> assign(:has_more_older_messages, false)
+ |> assign(:has_more_newer_messages, false)
+ |> assign(:first_unread_message_id, nil)
+ else
+ socket
+ end
+
+ Process.send_after(self(), :refresh_conversations, 50)
+
+ {:noreply,
+ socket
+ |> assign(:context_menu, %{socket.assigns.context_menu | conversation: nil})
+ |> notify_info("History cleared")}
+
+ {:error, :unauthorized} ->
+ {:noreply,
+ socket
+ |> assign(:context_menu, %{socket.assigns.context_menu | conversation: nil})
+ |> notify_error("You are not a member of this chat")}
+
+ {:error, _} ->
+ {:noreply,
+ socket
+ |> assign(:context_menu, %{socket.assigns.context_menu | conversation: nil})
+ |> notify_error("Failed to clear history")}
+ end
+
+ :error ->
+ {:noreply,
+ socket
+ |> assign(:context_menu, %{socket.assigns.context_menu | conversation: nil})
+ |> notify_error("Failed to clear history")}
+ end
+ end
+
+ def handle_event("show_settings", _params, socket) do
+ updated_ui = Map.put(socket.assigns.ui, :show_settings_modal, true)
+ {:noreply, assign(socket, :ui, updated_ui)}
+ end
+
+ def handle_event("hide_settings", _params, socket) do
+ updated_ui = Map.put(socket.assigns.ui, :show_settings_modal, false)
+ {:noreply, assign(socket, :ui, updated_ui)}
+ end
+
+ def handle_event("show_edit_conversation", _params, socket) do
+ updated_ui = Map.put(socket.assigns.ui, :show_edit_modal, true)
+ {:noreply, assign(socket, :ui, updated_ui)}
+ end
+
+ def handle_event("hide_edit_conversation", _params, socket) do
+ updated_ui = Map.put(socket.assigns.ui, :show_edit_modal, false)
+ {:noreply, assign(socket, :ui, updated_ui)}
+ end
+
+ def handle_event("update_conversation", params, socket) do
+ conversation = socket.assigns.conversation.selected
+ conversation_params = Map.get(params, "conversation", params)
+ name = Map.get(conversation_params, "name", conversation.name)
+ description = Map.get(conversation_params, "description", conversation.description)
+
+ visibility_attrs =
+ cond do
+ conversation.type == "channel" and is_integer(conversation.server_id) ->
+ is_private = parse_checkbox_value(Map.get(conversation_params, "is_private"))
+ %{is_public: !is_private}
+
+ conversation.type in ["channel", "group"] ->
+ is_public = parse_checkbox_value(Map.get(conversation_params, "is_public"))
+ %{is_public: is_public}
+
+ true ->
+ %{}
+ end
+
+ attrs =
+ %{
+ name: normalize_optional_text(name),
+ description: normalize_optional_text(description)
+ }
+ |> Map.merge(visibility_attrs)
+
+ case Messaging.update_conversation(conversation, attrs) do
+ {:ok, _} ->
+ {:noreply,
+ socket
+ |> assign(:ui, Map.put(socket.assigns.ui, :show_edit_modal, false))
+ |> notify_info("Chat updated")}
+
+ {:error, _} ->
+ {:noreply, notify_error(socket, "Failed to update chat")}
+ end
+ end
+
+ def handle_event("delete_conversation", %{"conversation_id" => conversation_id}, socket) do
+ with {:ok, conversation_id} <- parse_positive_int(conversation_id),
+ {:ok, conversation} <-
+ Messaging.get_conversation!(conversation_id, socket.assigns.current_user.id),
+ {:ok, _} <- Messaging.delete_conversation(conversation) do
+ {:noreply,
+ socket
+ |> push_patch(to: Elektrine.Paths.chat_root_path())
+ |> notify_info("Chat deleted")}
+ else
+ _ ->
+ {:noreply, notify_error(socket, "Failed to delete chat")}
+ end
+ end
+
+ def handle_event("leave_conversation", %{"conversation_id" => conversation_id}, socket) do
+ case parse_positive_int(conversation_id) do
+ {:ok, conversation_id} -> do_leave_conversation(conversation_id, socket)
+ :error -> {:noreply, notify_error(socket, "Failed to leave chat")}
+ end
+ end
+
+ def handle_event("leave_conversation", _params, socket) do
+ # Handle case when triggered without conversation_id (use selected conversation)
+ case socket.assigns.conversation.selected do
+ nil ->
+ {:noreply, notify_error(socket, "No chat selected")}
+
+ conversation ->
+ do_leave_conversation(conversation.id, socket)
+ end
+ end
+
+ def handle_event("view_profile", %{"user_id" => user_id}, socket) do
+ ElektrineWeb.ProfileNavigation.navigate(socket, %{"user_id" => user_id})
+ end
+
+ def handle_event("view_profile", %{"handle" => handle}, socket) do
+ ElektrineWeb.ProfileNavigation.navigate(socket, %{"handle" => handle})
+ end
+
+ def handle_event("share_conversation", _params, socket) do
+ conversation = socket.assigns.conversation.selected
+
+ share_url =
+ ElektrineWeb.Endpoint.url() <>
+ Elektrine.Paths.chat_join_path(conversation.hash || conversation.id)
+
+ {:noreply,
+ push_event(socket, "copy_to_clipboard", %{text: share_url, type: "chat invite link"})}
+ end
+
+ def handle_event("show_conversation_info", _params, socket) do
+ updated_ui = Map.put(socket.assigns.ui, :show_conversation_info, true)
+ {:noreply, assign(socket, :ui, updated_ui)}
+ end
+
+ # Private helpers
+
+ defp parse_positive_int(value) when is_integer(value) and value > 0, do: {:ok, value}
+
+ defp parse_positive_int(value) when is_binary(value) do
+ case Integer.parse(value) do
+ {id, ""} when id > 0 -> {:ok, id}
+ _ -> :error
+ end
+ end
+
+ defp parse_positive_int(_value), do: :error
+
+ defp do_leave_conversation(conversation_id, socket) do
+ user_id = socket.assigns.current_user.id
+
+ case Messaging.leave_conversation(conversation_id, user_id) do
+ {:ok, _} ->
+ {:noreply,
+ socket
+ |> push_patch(to: Elektrine.Paths.chat_root_path())
+ |> notify_info("Left chat")}
+
+ {:error, :owner_must_transfer} ->
+ {:noreply, notify_error(socket, "Transfer ownership before leaving")}
+
+ {:error, :not_a_member} ->
+ {:noreply, notify_error(socket, "You are not a member of this chat")}
+
+ {:error, _} ->
+ {:noreply, notify_error(socket, "Failed to leave chat")}
+ end
+ end
+
+ defp maybe_add_remote_handle_result(results, query)
+ when is_list(results) and is_binary(query) do
+ case normalize_remote_handle(query) do
+ {:ok, remote_handle} ->
+ already_present? =
+ Enum.any?(results, fn user ->
+ user_handle = Map.get(user, :handle) || Map.get(user, "handle")
+ String.downcase(to_string(user_handle || "")) == remote_handle
+ end)
+
+ if already_present? do
+ results
+ else
+ [remote_search_result(remote_handle) | results]
+ end
+
+ :error ->
+ results
+ end
+ end
+
+ defp maybe_add_remote_handle_result(results, _query), do: results
+
+ defp normalize_remote_handle(handle) when is_binary(handle) do
+ normalized =
+ handle
+ |> String.trim()
+ |> String.trim_leading("@")
+ |> String.downcase()
+
+ case Regex.run(~r/^([a-z0-9_]{1,64})@([a-z0-9.-]+\.[a-z]{2,})$/, normalized) do
+ [_, username, domain] -> {:ok, "#{username}@#{domain}"}
+ _ -> :error
+ end
+ end
+
+ defp remote_search_result(remote_handle) do
+ [username, _domain] = String.split(remote_handle, "@", parts: 2)
+
+ %{
+ id: nil,
+ username: username,
+ handle: remote_handle,
+ display_name: "@#{remote_handle}",
+ avatar: nil,
+ remote_handle: remote_handle
+ }
+ end
+
+ defp normalize_optional_text(nil), do: nil
+
+ defp normalize_optional_text(value) when is_binary(value) do
+ case String.trim(value) do
+ "" -> nil
+ trimmed -> trimmed
+ end
+ end
+
+ defp normalize_optional_text(value), do: value
+
+ defp parse_checkbox_value(value) when is_list(value) do
+ Enum.any?(value, &parse_checkbox_value/1)
+ end
+
+ defp parse_checkbox_value(value) do
+ value in [true, "true", "on", "1", 1]
+ end
+end
diff --git a/apps/arblarg/lib/arblarg_web/live/chat_live/operations/direct_message_operations.ex b/apps/arblarg/lib/arblarg_web/live/chat_live/operations/direct_message_operations.ex
new file mode 100644
index 0000000..1f70037
--- /dev/null
+++ b/apps/arblarg/lib/arblarg_web/live/chat_live/operations/direct_message_operations.ex
@@ -0,0 +1,174 @@
+defmodule ArblargWeb.ChatLive.Operations.DirectMessageOperations do
+ @moduledoc """
+ Handles direct messaging: starting DMs, searching users, blocking.
+ Extracted from ChatLive.Home.
+ """
+
+ import Phoenix.LiveView
+ import Phoenix.Component
+ import ElektrineWeb.Live.NotificationHelpers
+
+ use Phoenix.VerifiedRoutes,
+ endpoint: ElektrineWeb.Endpoint,
+ router: ElektrineWeb.Router
+
+ alias Elektrine.Accounts
+ alias Elektrine.Accounts.User
+ alias Elektrine.Messaging, as: Messaging
+ alias Elektrine.Repo
+
+ def handle_event("start_dm", %{"remote_handle" => remote_handle}, socket)
+ when is_binary(remote_handle) and remote_handle != "" do
+ current_user_id = socket.assigns.current_user.id
+
+ case Messaging.create_remote_dm_conversation(current_user_id, remote_handle) do
+ {:ok, conversation} ->
+ {:noreply,
+ socket
+ |> assign(
+ :ui,
+ Map.merge(socket.assigns.ui, %{show_new_chat: false, show_profile_modal: false})
+ )
+ |> push_patch(to: Elektrine.Paths.chat_path(conversation))}
+
+ {:error, :invalid_remote_handle} ->
+ {:noreply, notify_error(socket, "Use handle format user@domain")}
+
+ {:error, :unknown_peer} ->
+ {:noreply,
+ notify_error(
+ socket,
+ "That domain could not be reached through federation discovery"
+ )}
+
+ {:error, :rate_limited} ->
+ {:noreply, notify_error(socket, "You are creating chats too quickly")}
+
+ {:error, _reason} ->
+ {:noreply, notify_error(socket, "Failed to start chat")}
+ end
+ end
+
+ def handle_event("start_dm", %{"user_id" => user_id}, socket) do
+ with {:ok, user_id} <- parse_positive_int(user_id),
+ {:ok, conversation} <-
+ Messaging.create_dm_conversation(socket.assigns.current_user.id, user_id) do
+ {:noreply,
+ socket
+ |> assign(
+ :ui,
+ Map.merge(socket.assigns.ui, %{show_new_chat: false, show_profile_modal: false})
+ )
+ |> push_patch(to: Elektrine.Paths.chat_path(conversation))}
+ else
+ _ -> {:noreply, notify_error(socket, "Failed to start chat")}
+ end
+ end
+
+ def handle_event("search_users", params, socket) do
+ query = Map.get(params, "query") || Map.get(params, "value") || ""
+
+ results =
+ if String.length(query) >= 2 do
+ query
+ |> Accounts.search_users(socket.assigns.current_user.id)
+ |> maybe_add_remote_handle_result(query)
+ else
+ []
+ end
+
+ {:noreply, assign(socket, :search, %{socket.assigns.search | query: query, results: results})}
+ end
+
+ def handle_event("block_user", %{"user_id" => user_id}, socket) do
+ with {:ok, user_id} <- parse_positive_int(user_id),
+ {:ok, _} <- Accounts.block_user(socket.assigns.current_user.id, user_id) do
+ {:noreply, notify_info(socket, "User blocked")}
+ else
+ _ -> {:noreply, notify_error(socket, "Failed to block user")}
+ end
+ end
+
+ def handle_event("unblock_user", %{"user_id" => user_id}, socket) do
+ with {:ok, user_id} <- parse_positive_int(user_id),
+ {:ok, _} <- Accounts.unblock_user(socket.assigns.current_user.id, user_id) do
+ {:noreply, notify_info(socket, "User unblocked")}
+ else
+ _ -> {:noreply, notify_error(socket, "Failed to unblock user")}
+ end
+ end
+
+ def handle_event("show_user_profile", %{"user_id" => user_id}, socket) do
+ with {:ok, user_id} <- parse_positive_int(user_id),
+ %User{} = user <- Repo.get(User, user_id) do
+ user_with_profile = Repo.preload(user, :profile)
+
+ {:noreply,
+ socket
+ |> assign(:ui, Map.put(socket.assigns.ui, :show_profile_modal, true))
+ |> assign(:profile_user, user_with_profile)}
+ else
+ _ -> {:noreply, notify_error(socket, "User not found")}
+ end
+ end
+
+ def handle_event("hide_profile_modal", _params, socket) do
+ {:noreply, assign(socket, :ui, Map.put(socket.assigns.ui, :show_profile_modal, false))}
+ end
+
+ defp parse_positive_int(value) do
+ case Integer.parse(to_string(value)) do
+ {int, ""} when int > 0 -> {:ok, int}
+ _ -> :error
+ end
+ end
+
+ defp maybe_add_remote_handle_result(results, query)
+ when is_list(results) and is_binary(query) do
+ case normalize_remote_handle(query) do
+ {:ok, remote_handle} ->
+ already_present? =
+ Enum.any?(results, fn user ->
+ user_handle = Map.get(user, :handle) || Map.get(user, "handle")
+ String.downcase(to_string(user_handle || "")) == remote_handle
+ end)
+
+ if already_present? do
+ results
+ else
+ [remote_search_result(remote_handle) | results]
+ end
+
+ :error ->
+ results
+ end
+ end
+
+ defp maybe_add_remote_handle_result(results, _query), do: results
+
+ defp normalize_remote_handle(handle) when is_binary(handle) do
+ normalized =
+ handle
+ |> String.trim()
+ |> String.trim_leading("@")
+ |> String.downcase()
+
+ case Regex.run(~r/^([a-z0-9_]{1,64})@([a-z0-9.-]+\.[a-z]{2,})$/, normalized) do
+ [_, username, domain] -> {:ok, "#{username}@#{domain}"}
+ _ -> :error
+ end
+ end
+
+ defp remote_search_result(remote_handle) do
+ [username, _domain] = String.split(remote_handle, "@", parts: 2)
+
+ %{
+ id: nil,
+ username: username,
+ handle: remote_handle,
+ display_name: "@#{remote_handle}",
+ avatar: nil,
+ remote_handle: remote_handle
+ }
+ end
+end
diff --git a/apps/arblarg/lib/arblarg_web/live/chat_live/operations/emoji_gif_operations.ex b/apps/arblarg/lib/arblarg_web/live/chat_live/operations/emoji_gif_operations.ex
new file mode 100644
index 0000000..a22fd6f
--- /dev/null
+++ b/apps/arblarg/lib/arblarg_web/live/chat_live/operations/emoji_gif_operations.ex
@@ -0,0 +1,43 @@
+defmodule ArblargWeb.ChatLive.Operations.EmojiGifOperations do
+ @moduledoc """
+ Handles emoji picker operations.
+ Extracted from ChatLive.Home.
+ """
+
+ import Phoenix.Component
+
+ def handle_event("toggle_emoji_picker", _params, socket) do
+ {:noreply,
+ assign(
+ socket,
+ :ui,
+ Map.put(socket.assigns.ui, :show_emoji_picker, !socket.assigns.ui.show_emoji_picker)
+ )}
+ end
+
+ def handle_event("insert_emoji", %{"emoji" => emoji}, socket) do
+ current_message = socket.assigns.message.new_message
+ updated_message = "#{current_message}#{emoji}"
+
+ {:noreply,
+ socket
+ |> assign(:message, %{socket.assigns.message | new_message: updated_message})
+ |> assign(:ui, Map.put(socket.assigns.ui, :show_emoji_picker, false))}
+ end
+
+ def handle_event("emoji_search", %{"value" => query}, socket) do
+ {:noreply, assign(socket, :search, %{socket.assigns.search | emoji_query: query})}
+ end
+
+ def handle_event("emoji_search", %{"emoji_query" => query}, socket) do
+ {:noreply, assign(socket, :search, %{socket.assigns.search | emoji_query: query})}
+ end
+
+ def handle_event("emoji_search", %{"value" => query}, socket) do
+ {:noreply, assign(socket, :search, %{socket.assigns.search | emoji_query: query})}
+ end
+
+ def handle_event("emoji_tab", %{"tab" => tab}, socket) do
+ {:noreply, assign(socket, :search, %{socket.assigns.search | emoji_tab: tab})}
+ end
+end
diff --git a/apps/arblarg/lib/arblarg_web/live/chat_live/operations/group_channel_operations.ex b/apps/arblarg/lib/arblarg_web/live/chat_live/operations/group_channel_operations.ex
new file mode 100644
index 0000000..8e67401
--- /dev/null
+++ b/apps/arblarg/lib/arblarg_web/live/chat_live/operations/group_channel_operations.ex
@@ -0,0 +1,672 @@
+defmodule ArblargWeb.ChatLive.Operations.GroupChannelOperations do
+ @moduledoc """
+ Handles group and channel creation, browsing, and joining.
+ Extracted from ChatLive.Home.
+ """
+
+ import Phoenix.LiveView
+ import Phoenix.Component
+ import ElektrineWeb.Live.NotificationHelpers
+
+ use Phoenix.VerifiedRoutes,
+ endpoint: ElektrineWeb.Endpoint,
+ router: ElektrineWeb.Router
+
+ alias ArblargWeb.ChatLive.Operations.Helpers
+ alias Elektrine.Messaging, as: Messaging
+ alias Elektrine.Uploads
+
+ def handle_event("toggle_new_chat", _params, socket) do
+ show_new_chat = !socket.assigns.ui.show_new_chat
+
+ {:noreply,
+ socket
+ |> assign(:ui, Map.put(socket.assigns.ui, :show_new_chat, show_new_chat))
+ |> assign(:search, %{socket.assigns.search | query: "", results: []})}
+ end
+
+ def handle_event("show_create_group", _params, socket) do
+ updated_ui =
+ socket.assigns.ui
+ |> Map.put(:show_group_modal, true)
+ |> Map.put(:show_new_chat, false)
+
+ {:noreply,
+ socket
+ |> assign(:ui, updated_ui)
+ |> assign(:form, %{socket.assigns.form | selected_users: []})
+ |> assign(:search, %{socket.assigns.search | query: "", results: []})}
+ end
+
+ def handle_event("show_create_server", _params, socket) do
+ updated_ui =
+ socket.assigns.ui
+ |> Map.put(:show_server_modal, true)
+ |> Map.put(:show_group_modal, false)
+ |> Map.put(:show_channel_modal, false)
+ |> Map.put(:show_browse_modal, false)
+ |> Map.put(:show_new_chat, false)
+
+ {:noreply, assign(socket, :ui, updated_ui)}
+ end
+
+ def handle_event("hide_create_server", _params, socket) do
+ {:noreply,
+ socket
+ |> assign(:ui, Map.put(socket.assigns.ui, :show_server_modal, false))
+ |> clear_upload_entries(:server_icon_upload)}
+ end
+
+ def handle_event("show_create_channel", _params, socket) do
+ case selected_server_id(socket) do
+ nil ->
+ {:noreply,
+ socket
+ |> notify_error("Select a server first, then create channels inside it")}
+
+ _server_id ->
+ {:noreply,
+ assign(
+ socket,
+ :ui,
+ socket.assigns.ui
+ |> Map.put(:show_channel_modal, true)
+ |> Map.put(:show_new_chat, false)
+ )}
+ end
+ end
+
+ def handle_event("show_create_category", _params, socket) do
+ case selected_server_id(socket) do
+ nil ->
+ {:noreply,
+ socket
+ |> notify_error("Select a server first, then create categories inside it")}
+
+ _server_id ->
+ {:noreply,
+ assign(
+ socket,
+ :ui,
+ socket.assigns.ui
+ |> Map.put(:show_category_modal, true)
+ |> Map.put(:show_new_chat, false)
+ )}
+ end
+ end
+
+ def handle_event("hide_create_category", _params, socket) do
+ {:noreply, assign(socket, :ui, Map.put(socket.assigns.ui, :show_category_modal, false))}
+ end
+
+ def handle_event("create_channel_category", params, socket) do
+ category_params = Map.get(params, "category", %{})
+ name = params["name"] || category_params["name"]
+
+ with server_id when is_integer(server_id) <- selected_server_id(socket),
+ true <- Elektrine.Strings.present?(name) do
+ case Messaging.create_channel_category(server_id, socket.assigns.current_user.id, %{
+ name: String.trim(name)
+ }) do
+ {:ok, _category} ->
+ {:noreply,
+ socket
+ |> assign(:ui, Map.put(socket.assigns.ui, :show_category_modal, false))
+ |> assign(:server_categories, Helpers.server_categories(server_id))
+ |> notify_info("Category created!")}
+
+ {:error, :unauthorized} ->
+ {:noreply,
+ socket
+ |> notify_error("You don't have permission to manage channels in this server")}
+
+ {:error, :not_found} ->
+ {:noreply, notify_error(socket, "Server not found")}
+
+ {:error, _} ->
+ {:noreply, notify_error(socket, "Failed to create category")}
+ end
+ else
+ nil ->
+ {:noreply,
+ notify_error(socket, "Select a server first, then create categories inside it")}
+
+ false ->
+ {:noreply, notify_error(socket, "Please enter a category name")}
+ end
+ end
+
+ def handle_event("toggle_category_collapse", %{"category_id" => category_id}, socket) do
+ case parse_positive_int(category_id) do
+ {:ok, category_id} ->
+ collapsed = socket.assigns.collapsed_category_ids
+
+ updated =
+ if MapSet.member?(collapsed, category_id) do
+ MapSet.delete(collapsed, category_id)
+ else
+ MapSet.put(collapsed, category_id)
+ end
+
+ {:noreply, assign(socket, :collapsed_category_ids, updated)}
+
+ :error ->
+ {:noreply, socket}
+ end
+ end
+
+ def handle_event("create_group", params, socket) do
+ name = params["name"]
+ selected_users = socket.assigns.form.selected_users
+
+ if not Elektrine.Strings.present?(name) || Enum.empty?(selected_users) do
+ {:noreply, notify_error(socket, "Please enter a name and select at least one user")}
+ else
+ case Messaging.create_chat_group_conversation(
+ socket.assigns.current_user.id,
+ %{name: name},
+ selected_users
+ ) do
+ {:ok, conversation} ->
+ {:noreply,
+ socket
+ |> assign(:ui, Map.put(socket.assigns.ui, :show_group_modal, false))
+ |> push_patch(to: Elektrine.Paths.chat_path(conversation))
+ |> notify_info("Chat created!")}
+
+ {:error, _} ->
+ {:noreply, notify_error(socket, "Failed to create chat")}
+ end
+ end
+ end
+
+ def handle_event("create_server", params, socket) do
+ server_params = Map.get(params, "server", %{})
+ name = Map.get(server_params, "name", "")
+ description = Map.get(server_params, "description", "")
+ is_public = parse_checkbox_value(Map.get(server_params, "is_public"))
+
+ with true <- Elektrine.Strings.present?(name),
+ {:ok, icon_url} <- consume_entity_image_upload(socket, :server_icon_upload) do
+ attrs = %{
+ name: String.trim(name),
+ description: normalize_optional_text(description),
+ icon_url: icon_url,
+ is_public: is_public
+ }
+
+ case Messaging.create_server(socket.assigns.current_user.id, attrs) do
+ {:ok, server} ->
+ joined_servers = Messaging.list_servers(socket.assigns.current_user.id)
+ server_channel = default_server_channel(server.channels)
+
+ base_socket =
+ socket
+ |> assign(:joined_servers, joined_servers)
+ |> assign(:active_server_id, server.id)
+ |> assign(:ui, Map.put(socket.assigns.ui, :show_server_modal, false))
+ |> notify_info("Server created!")
+
+ case server_channel do
+ nil ->
+ {:noreply, push_patch(base_socket, to: Elektrine.Paths.chat_root_path())}
+
+ channel ->
+ {:noreply, push_patch(base_socket, to: Elektrine.Paths.chat_path(channel))}
+ end
+
+ {:error, %Ecto.Changeset{} = changeset} ->
+ {:noreply, notify_error(socket, first_changeset_error(changeset))}
+
+ {:error, _} ->
+ {:noreply, notify_error(socket, "Failed to create server")}
+ end
+ else
+ false ->
+ {:noreply, notify_error(socket, "Please enter a server name")}
+
+ {:error, reason} ->
+ {:noreply, notify_error(socket, reason)}
+ end
+ end
+
+ def handle_event("create_channel", params, socket) do
+ channel_params = Map.get(params, "channel", %{})
+ name = params["name"] || channel_params["name"]
+ description = params["description"] || channel_params["description"]
+ topic = params["channel_topic"] || channel_params["channel_topic"]
+ is_private = parse_checkbox_value(params["is_private"] || channel_params["is_private"])
+
+ with server_id when is_integer(server_id) <- selected_server_id(socket),
+ true <- Elektrine.Strings.present?(name) do
+ attrs = %{
+ name: String.trim(name),
+ description: normalize_optional_text(description),
+ channel_topic: normalize_optional_text(topic),
+ is_public: !is_private
+ }
+
+ case Messaging.create_server_channel(server_id, socket.assigns.current_user.id, attrs) do
+ {:ok, channel} ->
+ {:noreply,
+ socket
+ |> assign(:ui, Map.put(socket.assigns.ui, :show_channel_modal, false))
+ |> push_patch(to: Elektrine.Paths.chat_path(channel))
+ |> notify_info("Channel created!")}
+
+ {:error, :unauthorized} ->
+ {:noreply,
+ socket
+ |> notify_error("You don't have permission to create channels in this server")}
+
+ {:error, :not_found} ->
+ {:noreply, notify_error(socket, "Server not found")}
+
+ {:error, _} ->
+ {:noreply, notify_error(socket, "Failed to create channel")}
+ end
+ else
+ nil ->
+ {:noreply, notify_error(socket, "Select a server first, then create channels inside it")}
+
+ false ->
+ {:noreply, notify_error(socket, "Please enter a channel name")}
+ end
+ end
+
+ def handle_event("cancel_create", _params, socket) do
+ updated_ui =
+ socket.assigns.ui
+ |> Map.put(:show_group_modal, false)
+ |> Map.put(:show_channel_modal, false)
+ |> Map.put(:show_category_modal, false)
+ |> Map.put(:show_server_modal, false)
+ |> Map.put(:show_new_chat, false)
+
+ {:noreply,
+ socket
+ |> assign(:ui, updated_ui)
+ |> clear_upload_entries(:server_icon_upload)
+ |> clear_upload_entries(:group_avatar_upload)
+ |> clear_upload_entries(:channel_avatar_upload)
+ |> assign(:form, %{socket.assigns.form | selected_users: []})}
+ end
+
+ def handle_event("toggle_user_selection", %{"user_id" => user_id}, socket) do
+ case parse_positive_int(user_id) do
+ {:ok, user_id} ->
+ selected = socket.assigns.form.selected_users
+
+ updated =
+ if user_id in selected do
+ List.delete(selected, user_id)
+ else
+ [user_id | selected]
+ end
+
+ {:noreply, assign(socket, :form, %{socket.assigns.form | selected_users: updated})}
+
+ :error ->
+ {:noreply, socket}
+ end
+ end
+
+ def handle_event("show_browse_modal", _params, socket) do
+ public_servers = Messaging.list_public_servers(socket.assigns.current_user.id)
+ public_groups = Messaging.list_chat_public_groups()
+
+ updated_ui =
+ socket.assigns.ui
+ |> Map.put(:show_browse_modal, true)
+ |> Map.put(:show_new_chat, false)
+
+ {:noreply,
+ socket
+ |> assign(:ui, updated_ui)
+ |> assign(:search, %{socket.assigns.search | browse_query: ""})
+ |> assign(:browse, %{
+ socket.assigns.browse
+ | tab: "servers",
+ public_servers: public_servers,
+ public_channels: [],
+ public_groups: public_groups,
+ filtered_servers: public_servers,
+ filtered_channels: [],
+ filtered_groups: public_groups
+ })}
+ end
+
+ def handle_event("hide_browse_modal", _params, socket) do
+ {:noreply, assign(socket, :ui, Map.put(socket.assigns.ui, :show_browse_modal, false))}
+ end
+
+ def handle_event("browse_tab", %{"tab" => tab}, socket) do
+ normalized_tab = if tab in ["servers", "groups"], do: tab, else: "servers"
+ updated_browse = %{socket.assigns.browse | tab: normalized_tab}
+ {:noreply, assign(socket, :browse, updated_browse)}
+ end
+
+ def handle_event("browse_search", %{"search" => query}, socket) do
+ search_query = String.trim(query || "")
+ normalized_query = String.downcase(search_query)
+
+ filtered_servers =
+ if normalized_query == "" do
+ socket.assigns.browse.public_servers
+ else
+ Enum.filter(socket.assigns.browse.public_servers, fn server ->
+ String.contains?(String.downcase(server.name || ""), normalized_query) ||
+ String.contains?(String.downcase(server.description || ""), normalized_query) ||
+ String.contains?(String.downcase(server.origin_domain || ""), normalized_query)
+ end)
+ end
+
+ filtered_groups =
+ if normalized_query == "" do
+ socket.assigns.browse.public_groups
+ else
+ Enum.filter(socket.assigns.browse.public_groups, fn g ->
+ String.contains?(String.downcase(g.name || ""), normalized_query) ||
+ String.contains?(String.downcase(g.description || ""), normalized_query)
+ end)
+ end
+
+ {:noreply,
+ socket
+ |> assign(:search, %{socket.assigns.search | browse_query: search_query})
+ |> assign(:browse, %{
+ socket.assigns.browse
+ | filtered_servers: filtered_servers,
+ filtered_channels: [],
+ filtered_groups: filtered_groups
+ })}
+ end
+
+ def handle_event("join_conversation", %{"conversation_id" => conversation_id}, socket) do
+ {:noreply, push_patch(socket, to: Elektrine.Paths.chat_join_path(conversation_id))}
+ end
+
+ def handle_event("join_group", %{"group_id" => group_id}, socket) do
+ case parse_positive_int(group_id) do
+ {:ok, conversation_id} ->
+ case Messaging.join_conversation(conversation_id, socket.assigns.current_user.id) do
+ {:ok, :pending} ->
+ {:noreply, notify_info(socket, "Join request sent")}
+
+ {:ok, _} ->
+ {:noreply,
+ socket
+ |> push_patch(to: Elektrine.Paths.chat_path(conversation_id))
+ |> notify_info("Joined chat")}
+
+ {:error, _} ->
+ {:noreply, notify_error(socket, "Failed to join chat")}
+ end
+
+ :error ->
+ {:noreply, notify_error(socket, "Failed to join chat")}
+ end
+ end
+
+ def handle_event("join_channel", %{"channel_id" => channel_id}, socket) do
+ _ = channel_id
+
+ {:noreply,
+ notify_error(socket, "Channels are server-scoped. Join the server to access its channels.")}
+ end
+
+ def handle_event("filter_server", %{"server_id" => server_id}, socket) do
+ case parse_server_id(server_id) do
+ {:ok, parsed_server_id} ->
+ {:noreply,
+ socket
+ |> apply_server_scope(parsed_server_id)
+ |> push_patch(to: Elektrine.Paths.chat_root_path())}
+
+ :error ->
+ {:noreply, notify_error(socket, "Invalid server")}
+ end
+ end
+
+ def handle_event("select_server", %{"server_id" => server_id}, socket) do
+ case parse_server_id(server_id) do
+ {:ok, parsed_server_id} ->
+ socket = apply_server_scope(socket, parsed_server_id)
+
+ case first_server_channel_identifier(socket.assigns.conversation.list, parsed_server_id) do
+ nil ->
+ {:noreply,
+ socket
+ |> assign(:conversation, %{socket.assigns.conversation | selected: nil})
+ |> push_patch(to: Elektrine.Paths.chat_root_path())}
+
+ conversation_identifier ->
+ {:noreply, push_patch(socket, to: Elektrine.Paths.chat_path(conversation_identifier))}
+ end
+
+ :error ->
+ {:noreply, notify_error(socket, "Invalid server")}
+ end
+ end
+
+ def handle_event("clear_server_scope", _params, socket) do
+ {:noreply,
+ socket
+ |> apply_server_scope(nil)
+ |> push_patch(to: Elektrine.Paths.chat_root_path())}
+ end
+
+ def handle_event("join_server", %{"server_id" => server_id}, socket) do
+ current_user_id = socket.assigns.current_user.id
+
+ case parse_server_id(server_id) do
+ {:ok, parsed_server_id} ->
+ case Messaging.join_server(parsed_server_id, current_user_id) do
+ {:ok, _member} ->
+ navigate_to_joined_server(socket, parsed_server_id, "Joined server")
+
+ {:error, :already_member} ->
+ navigate_to_joined_server(socket, parsed_server_id, nil)
+
+ {:error, :not_public} ->
+ {:noreply, notify_error(socket, "This server is private")}
+
+ {:error, :not_found} ->
+ {:noreply, notify_error(socket, "Server not found")}
+
+ {:error, _reason} ->
+ {:noreply, notify_error(socket, "Failed to join server")}
+ end
+
+ :error ->
+ {:noreply, notify_error(socket, "Invalid server")}
+ end
+ end
+
+ defp navigate_to_joined_server(socket, server_id, success_message) do
+ case Messaging.get_server(server_id, socket.assigns.current_user.id) do
+ {:ok, server} ->
+ server_channel = default_server_channel(server.channels)
+
+ cond do
+ server_channel ->
+ {:noreply,
+ socket
+ |> apply_server_scope(server_id)
+ |> assign(:ui, Map.put(socket.assigns.ui, :show_browse_modal, false))
+ |> maybe_notify_info(success_message)
+ |> push_patch(to: Elektrine.Paths.chat_path(server_channel))}
+
+ success_message ->
+ {:noreply,
+ socket
+ |> apply_server_scope(server_id)
+ |> assign(:ui, Map.put(socket.assigns.ui, :show_browse_modal, false))
+ |> notify_info(success_message)
+ |> notify_warning("Joined server, but it has no channels yet")
+ |> push_patch(to: Elektrine.Paths.chat_root_path())}
+
+ true ->
+ {:noreply,
+ socket
+ |> apply_server_scope(server_id)
+ |> assign(:ui, Map.put(socket.assigns.ui, :show_browse_modal, false))
+ |> notify_warning("This server has no channels yet")
+ |> push_patch(to: Elektrine.Paths.chat_root_path())}
+ end
+
+ {:error, :not_found} ->
+ {:noreply, notify_error(socket, "Server not found")}
+ end
+ end
+
+ defp parse_server_id(server_id) when is_integer(server_id) and server_id > 0,
+ do: {:ok, server_id}
+
+ defp parse_server_id(server_id), do: parse_positive_int(server_id)
+
+ defp parse_positive_int(value) do
+ case Integer.parse(to_string(value)) do
+ {int, ""} when int > 0 -> {:ok, int}
+ _ -> :error
+ end
+ end
+
+ defp first_server_channel_identifier(conversations, server_id)
+ when is_list(conversations) and is_integer(server_id) do
+ conversations
+ |> Enum.filter(&(&1.type == "channel" and &1.server_id == server_id))
+ |> Enum.sort_by(&{&1.channel_position || 0, &1.id})
+ |> List.first()
+ |> case do
+ nil -> nil
+ conversation -> conversation.hash || conversation.id
+ end
+ end
+
+ defp first_server_channel_identifier(_conversations, _server_id), do: nil
+
+ defp default_server_channel(channels) when is_list(channels) do
+ channels
+ |> Enum.filter(&(&1.type == "channel"))
+ |> List.first()
+ end
+
+ defp default_server_channel(_), do: nil
+
+ defp maybe_notify_info(socket, nil), do: socket
+ defp maybe_notify_info(socket, message), do: notify_info(socket, message)
+
+ defp apply_server_scope(socket, active_server_id) do
+ scoped_conversations =
+ Helpers.scope_conversations_to_server(socket.assigns.conversation.list, active_server_id)
+
+ filtered_conversations =
+ case String.trim(socket.assigns.search.conversation_query || "") do
+ "" ->
+ scoped_conversations
+
+ query ->
+ Helpers.filter_conversations(
+ scoped_conversations,
+ query,
+ socket.assigns.current_user.id
+ )
+ end
+
+ socket
+ |> assign(:active_server_id, active_server_id)
+ |> assign(:server_categories, Helpers.server_categories(active_server_id))
+ |> assign(:conversation, %{socket.assigns.conversation | filtered: filtered_conversations})
+ end
+
+ defp selected_server_id(socket) do
+ case socket.assigns.conversation.selected do
+ %{server_id: server_id} when is_integer(server_id) -> server_id
+ _ -> socket.assigns[:active_server_id]
+ end
+ end
+
+ defp normalize_optional_text(nil), do: nil
+
+ defp normalize_optional_text(value) when is_binary(value) do
+ case String.trim(value) do
+ "" -> nil
+ trimmed -> trimmed
+ end
+ end
+
+ defp normalize_optional_text(_), do: nil
+
+ defp parse_checkbox_value(value) when is_list(value) do
+ Enum.any?(value, &parse_checkbox_value/1)
+ end
+
+ defp parse_checkbox_value(value) do
+ value in [true, "true", "on", "1", 1]
+ end
+
+ defp first_changeset_error(%Ecto.Changeset{} = changeset) do
+ case changeset.errors do
+ [{_field, {message, opts}} | _] ->
+ Enum.reduce(opts, message, fn {key, value}, acc ->
+ String.replace(acc, "%{#{key}}", to_string(value))
+ end)
+
+ _ ->
+ "Failed to create server"
+ end
+ end
+
+ defp consume_entity_image_upload(socket, upload_name) do
+ upload_results =
+ consume_uploaded_entries(socket, upload_name, fn %{path: path}, entry ->
+ upload = %Plug.Upload{
+ path: path,
+ content_type: entry.client_type,
+ filename: entry.client_name
+ }
+
+ case Uploads.upload_avatar(upload, socket.assigns.current_user.id) do
+ {:ok, metadata} ->
+ {:ok, %{ok: true, url: Uploads.avatar_url(metadata.key)}}
+
+ {:error, reason} ->
+ {:ok, %{ok: false, reason: reason}}
+ end
+ end)
+
+ case Enum.find(upload_results, &(!&1.ok)) do
+ %{reason: reason} ->
+ {:error, upload_error_message(reason)}
+
+ nil ->
+ uploaded_url =
+ upload_results
+ |> Enum.find_value(fn
+ %{ok: true, url: url} when is_binary(url) and url != "" -> url
+ _ -> nil
+ end)
+
+ {:ok, uploaded_url}
+ end
+ end
+
+ defp clear_upload_entries(socket, upload_name) do
+ refs =
+ case socket.assigns[:uploads] && socket.assigns.uploads[upload_name] do
+ %{entries: entries} when is_list(entries) -> Enum.map(entries, & &1.ref)
+ _ -> []
+ end
+
+ Enum.reduce(refs, socket, fn ref, acc ->
+ cancel_upload(acc, upload_name, ref)
+ end)
+ end
+
+ defp upload_error_message({_, message}) when is_binary(message), do: message
+
+ defp upload_error_message(reason) do
+ "Image upload failed: #{inspect(reason)}"
+ end
+end
diff --git a/apps/arblarg/lib/arblarg_web/live/chat_live/operations/helpers.ex b/apps/arblarg/lib/arblarg_web/live/chat_live/operations/helpers.ex
new file mode 100644
index 0000000..9200d6a
--- /dev/null
+++ b/apps/arblarg/lib/arblarg_web/live/chat_live/operations/helpers.ex
@@ -0,0 +1,757 @@
+defmodule ArblargWeb.ChatLive.Operations.Helpers do
+ @moduledoc """
+ Helper functions for ChatLive module.
+ Contains utility functions for formatting, filtering, and calculating various states.
+ """
+
+ alias Elektrine.Messaging, as: Messaging
+ alias Elektrine.Messaging.ChatMessage
+ alias Elektrine.Social.{Conversation, Message}
+
+ @doc """
+ Get the display name for a conversation based on the current user.
+ """
+ def conversation_name(conversation, current_user_id) do
+ Conversation.display_name(conversation, current_user_id)
+ end
+
+ @doc """
+ Format reactions for display, grouping by emoji.
+ """
+ def format_reactions(%Ecto.Association.NotLoaded{}), do: []
+
+ def format_reactions(reactions) when is_list(reactions) do
+ reactions
+ |> Enum.group_by(& &1.emoji)
+ |> Enum.map(fn {emoji, grouped_reactions} ->
+ users = Enum.map(grouped_reactions, &reaction_actor_label/1)
+ {emoji, length(grouped_reactions), users}
+ end)
+ end
+
+ def format_reactions(_), do: []
+
+ @doc """
+ Renders reaction text safely before replacing known custom emoji shortcodes.
+ """
+ def render_reaction_emoji(emoji) when is_binary(emoji) do
+ emoji
+ |> Phoenix.HTML.html_escape()
+ |> Phoenix.HTML.safe_to_string()
+ |> ElektrineWeb.HtmlHelpers.render_custom_emojis()
+ end
+
+ def render_reaction_emoji(_), do: ""
+
+ @doc """
+ Removes duplicate messages while preserving the first occurrence order.
+ """
+ def dedupe_messages(messages) when is_list(messages) do
+ Enum.uniq_by(messages, & &1.id)
+ end
+
+ def dedupe_messages(_), do: []
+
+ @doc """
+ Check if a specific user reacted to a message with a specific emoji.
+ """
+ def user_reacted?(%Ecto.Association.NotLoaded{}, _emoji, _user_id), do: false
+
+ def user_reacted?(reactions, emoji, user_id) when is_list(reactions) do
+ Enum.any?(reactions, fn reaction ->
+ reaction.emoji == emoji and reaction.user_id == user_id
+ end)
+ end
+
+ def user_reacted?(_, _emoji, _user_id), do: false
+
+ @doc """
+ Filter conversations based on search query.
+ Searches by conversation name, last message content, and member usernames.
+ """
+ def filter_conversations(conversations, query, current_user_id) do
+ search_term = String.downcase(String.trim(query))
+
+ Enum.filter(conversations, fn conversation ->
+ # Search by conversation name
+ name_match =
+ conversation
+ |> conversation_name(current_user_id)
+ |> String.downcase()
+ |> String.contains?(search_term)
+
+ # Search by last message content
+ message_match =
+ case conversation.messages do
+ [last_message | _] ->
+ last_message
+ |> message_display_content()
+ |> String.downcase()
+ |> String.contains?(search_term)
+
+ [] ->
+ false
+ end
+
+ # Search by member usernames (for groups/channels)
+ member_match =
+ if conversation.type != "dm" do
+ Enum.any?(conversation.members || [], fn member ->
+ if member.left_at == nil and member.user do
+ username_match =
+ (member.user.handle || member.user.username)
+ |> String.downcase()
+ |> String.contains?(search_term)
+
+ display_name_match =
+ if member.user.display_name do
+ member.user.display_name
+ |> String.downcase()
+ |> String.contains?(search_term)
+ else
+ false
+ end
+
+ username_match or display_name_match
+ else
+ false
+ end
+ end)
+ else
+ false
+ end
+
+ name_match or message_match or member_match
+ end)
+ end
+
+ @doc """
+ Scope visible channel conversations to the currently selected server.
+ DMs and group conversations stay visible across servers.
+ """
+ def scope_conversations_to_server(conversations, active_server_id)
+ when is_list(conversations) do
+ case active_server_id do
+ server_id when is_integer(server_id) ->
+ Enum.filter(conversations, fn conversation ->
+ not server_channel_type?(conversation.type) or is_nil(conversation.server_id) or
+ conversation.server_id == server_id
+ end)
+
+ _ ->
+ Enum.filter(conversations, fn conversation ->
+ not server_channel_type?(conversation.type) or is_nil(conversation.server_id)
+ end)
+ end
+ end
+
+ def scope_conversations_to_server(_conversations, _active_server_id), do: []
+
+ @doc """
+ Whether a conversation type lives inside a server's channel list (text or
+ voice channels).
+ """
+ def server_channel_type?(type), do: type in ["channel", "voice_channel"]
+
+ @doc """
+ Loads the channel categories of a server (empty list when no server is
+ selected).
+ """
+ def server_categories(server_id) when is_integer(server_id) do
+ Messaging.list_channel_categories(server_id)
+ end
+
+ def server_categories(_server_id), do: []
+
+ @doc """
+ Builds the sidebar entry list for the conversation panel.
+
+ Without an active server this is just the conversations in their given
+ order. With an active server the server's channels are grouped under their
+ category headers: uncategorized channels first, then each category (by
+ position) with its channels ordered by channel position. Non-channel
+ conversations (DMs, groups) follow at the bottom. Channels under a
+ collapsed category are skipped.
+
+ Returns entries shaped as `{:conversation, conversation}` or
+ `{:category, category, collapsed?}`.
+ """
+ def sidebar_entries(conversations, categories, collapsed_category_ids, active_server_id)
+ when is_integer(active_server_id) do
+ {channels, others} =
+ Enum.split_with(
+ conversations,
+ &(server_channel_type?(&1.type) and &1.server_id == active_server_id)
+ )
+
+ category_ids = MapSet.new(categories, & &1.id)
+
+ {categorized, uncategorized} =
+ Enum.split_with(channels, fn channel ->
+ is_integer(channel.category_id) and MapSet.member?(category_ids, channel.category_id)
+ end)
+
+ channels_by_category = Enum.group_by(categorized, & &1.category_id)
+
+ category_entries =
+ Enum.flat_map(categories, fn category ->
+ collapsed? = MapSet.member?(collapsed_category_ids, category.id)
+ header = {:category, category, collapsed?}
+
+ if collapsed? do
+ [header]
+ else
+ channel_entries =
+ channels_by_category
+ |> Map.get(category.id, [])
+ |> sort_channels()
+ |> Enum.map(&{:conversation, &1})
+
+ [header | channel_entries]
+ end
+ end)
+
+ Enum.map(sort_channels(uncategorized), &{:conversation, &1}) ++
+ category_entries ++ Enum.map(others, &{:conversation, &1})
+ end
+
+ def sidebar_entries(conversations, _categories, _collapsed_category_ids, _active_server_id) do
+ Enum.map(conversations, &{:conversation, &1})
+ end
+
+ defp sort_channels(channels) do
+ Enum.sort_by(channels, &{&1.channel_position || 0, &1.id})
+ end
+
+ @doc """
+ Load timeout status for multiple users in a conversation.
+ Returns a map of user_id => is_timed_out (boolean).
+
+ Uses batch query for efficiency (single DB query instead of N queries).
+ """
+ def load_timeout_status(user_ids, conversation_id) do
+ Elektrine.Messaging.Moderation.users_timed_out(user_ids, conversation_id)
+ end
+
+ @doc """
+ Check if user is admin of a specific conversation.
+ System admins can moderate any conversation.
+ """
+ def conversation_admin?(conversation, user) do
+ cond do
+ # No user or conversation
+ is_nil(user) or is_nil(conversation) ->
+ false
+
+ # System admins can moderate any conversation
+ user.is_admin ->
+ true
+
+ # Check if user is conversation admin
+ conversation.members != nil ->
+ member =
+ Enum.find(conversation.members, fn m ->
+ m.user_id == user.id && is_nil(m.left_at)
+ end)
+
+ not is_nil(member) and member.role == "admin"
+
+ # Default case
+ true ->
+ false
+ end
+ end
+
+ @doc """
+ Check if user is admin of the current conversation (for event handlers).
+ Accepts a socket and checks the selected_conversation assign.
+ """
+ def conversation_admin_socket?(socket) do
+ conversation = Map.get(socket.assigns, :selected_conversation)
+ current_user = Map.get(socket.assigns, :current_user)
+ conversation_admin?(conversation, current_user)
+ end
+
+ @doc """
+ Format duration in seconds to human readable string.
+ """
+ def format_duration(seconds) when is_integer(seconds) do
+ cond do
+ seconds < 60 -> "#{seconds} seconds"
+ seconds < 3600 -> "#{div(seconds, 60)} minutes"
+ seconds < 86_400 -> "#{div(seconds, 3600)} hours"
+ true -> "#{div(seconds, 86400)} days"
+ end
+ end
+
+ def format_duration(_), do: ""
+
+ @doc """
+ Popular emoji list for emoji picker.
+ """
+ def popular_emojis do
+ [
+ "😀",
+ "😃",
+ "😄",
+ "😁",
+ "😆",
+ "😅",
+ "😂",
+ "🤣",
+ "😊",
+ "😇",
+ "🙂",
+ "🙃",
+ "😉",
+ "😌",
+ "😍",
+ "🥰",
+ "😘",
+ "😗",
+ "😙",
+ "😚",
+ "😋",
+ "😛",
+ "😝",
+ "😜",
+ "🤪",
+ "🤨",
+ "🧐",
+ "🤓",
+ "😎",
+ "🤩",
+ "🥳",
+ "😏",
+ "😒",
+ "😞",
+ "😔",
+ "😟",
+ "😕",
+ "🙁",
+ "☹️",
+ "😣",
+ "😖",
+ "😫",
+ "😩",
+ "🥺",
+ "😢",
+ "😭",
+ "😤",
+ "😠",
+ "😡",
+ "🤬",
+ "🤯",
+ "😳",
+ "🥵",
+ "🥶",
+ "😱",
+ "😨",
+ "😰",
+ "😥",
+ "😓",
+ "🤗",
+ "🤔",
+ "🤭",
+ "🤫",
+ "🤥",
+ "😶",
+ "😐",
+ "😑",
+ "😬",
+ "🙄",
+ "😯",
+ "👍",
+ "👎",
+ "👌",
+ "✌️",
+ "🤞",
+ "🤟",
+ "🤘",
+ "🤙",
+ "👈",
+ "👉",
+ "👆",
+ "🖕",
+ "👇",
+ "☝️",
+ "👋",
+ "🤚",
+ "🖐",
+ "✋",
+ "🖖",
+ "👏",
+ "🙌",
+ "🤲",
+ "🙏",
+ "✍️",
+ "💪",
+ "🦾",
+ "🦿",
+ "🦵",
+ "🦶",
+ "👂",
+ "❤️",
+ "🧡",
+ "💛",
+ "💚",
+ "💙",
+ "💜",
+ "🖤",
+ "🤍",
+ "🤎",
+ "💔",
+ "❣️",
+ "💕",
+ "💞",
+ "💓",
+ "💗",
+ "💖",
+ "💘",
+ "💝",
+ "💟",
+ "☮️",
+ "🔥",
+ "⭐",
+ "🌟",
+ "✨",
+ "⚡",
+ "☄️",
+ "💥",
+ "💯",
+ "💢",
+ "💨"
+ ]
+ end
+
+ @doc """
+ Find the first unread message in a conversation for a user.
+ Used for scroll positioning.
+
+ Optimized to use a single database query for member data.
+ """
+ def find_first_unread_message(messages, conversation_id, user_id) do
+ # Early exit if no messages
+ if Enum.empty?(messages) do
+ nil
+ else
+ # Single query to get member data with last read info
+ member = Messaging.get_conversation_member(conversation_id, user_id)
+
+ case member do
+ nil ->
+ # User is not a member
+ nil
+
+ %{last_read_message_id: last_id} when not is_nil(last_id) ->
+ # User has a last read message ID
+ find_first_unread_by_message_id(messages, last_id, user_id, member.last_read_at)
+
+ %{last_read_at: last_read_at} when not is_nil(last_read_at) ->
+ # User has a last read timestamp but no message ID
+ find_first_unread_by_timestamp(messages, last_read_at, user_id)
+
+ _ ->
+ # User has never read any messages, find first message from other users
+ find_first_message_from_others(messages, user_id)
+ end
+ end
+ end
+
+ # Find first unread message after a known last-read message ID
+ defp find_first_unread_by_message_id(messages, last_id, user_id, last_read_at) do
+ last_read_index = Enum.find_index(messages, fn msg -> msg.id == last_id end)
+
+ case last_read_index do
+ nil ->
+ # Last read message not in current list, fall back to timestamp
+ if last_read_at do
+ find_first_unread_by_timestamp(messages, last_read_at, user_id)
+ else
+ find_first_message_from_others(messages, user_id)
+ end
+
+ index ->
+ # Find the next message after the last read one from other users
+ messages
+ |> Enum.drop(index + 1)
+ |> Enum.find(fn message -> message.sender_id != user_id end)
+ |> case do
+ nil -> nil
+ message -> message.id
+ end
+ end
+ end
+
+ # Find first message after a timestamp from other users
+ defp find_first_unread_by_timestamp(messages, last_read_at, user_id) do
+ cutoff = to_naive_datetime(last_read_at)
+
+ messages
+ |> Enum.find(fn message ->
+ message.sender_id != user_id &&
+ compare_message_time(message.inserted_at, cutoff) == :gt
+ end)
+ |> case do
+ nil -> nil
+ message -> message.id
+ end
+ end
+
+ # Find the first message from any other user
+ defp find_first_message_from_others(messages, user_id) do
+ messages
+ |> Enum.find(fn message -> message.sender_id != user_id end)
+ |> case do
+ nil -> nil
+ message -> message.id
+ end
+ end
+
+ defp reaction_actor_label(reaction) do
+ cond do
+ is_map(reaction.user) and not match?(%Ecto.Association.NotLoaded{}, reaction.user) ->
+ reaction.user.handle || reaction.user.username || "user"
+
+ is_map(reaction.remote_actor) and
+ not match?(%Ecto.Association.NotLoaded{}, reaction.remote_actor) ->
+ username = reaction.remote_actor.username || "remote"
+ domain = reaction.remote_actor.domain
+
+ if is_binary(domain) and domain != "" do
+ "#{username}@#{domain}"
+ else
+ username
+ end
+
+ true ->
+ "user"
+ end
+ end
+
+ defp message_display_content(%Message{} = message), do: Message.display_content(message)
+ defp message_display_content(%ChatMessage{} = message), do: ChatMessage.display_content(message)
+
+ defp message_display_content(message) when is_map(message) do
+ content =
+ message
+ |> map_message_value(:content)
+ |> fallback_message_text(message)
+ |> normalize_message_text()
+
+ if content != "", do: content, else: fallback_message_label(message)
+ end
+
+ defp message_display_content(_), do: ""
+
+ defp fallback_message_text(nil, message), do: map_message_value(message, :body)
+ defp fallback_message_text("", message), do: map_message_value(message, :body)
+ defp fallback_message_text(content, _message), do: content
+
+ defp fallback_message_label(message) do
+ client_encrypted_payload = map_message_value(message, :client_encrypted_payload)
+ message_type = map_message_value(message, :message_type)
+ media_urls = map_message_value(message, :media_urls) || []
+
+ cond do
+ is_map(client_encrypted_payload) ->
+ "Encrypted message"
+
+ message_type == "voice" ->
+ "Voice message"
+
+ message_type == "image" ->
+ "Photo"
+
+ message_type == "file" ->
+ "File"
+
+ message_type == "system" ->
+ "[System message]"
+
+ is_list(media_urls) and media_urls != [] ->
+ "[Attachment]"
+
+ true ->
+ ""
+ end
+ end
+
+ defp normalize_message_text(nil), do: ""
+
+ defp normalize_message_text(text) when is_binary(text) do
+ text
+ |> String.trim()
+ end
+
+ defp normalize_message_text(text) when is_atom(text), do: Atom.to_string(text)
+ defp normalize_message_text(text) when is_integer(text), do: Integer.to_string(text)
+ defp normalize_message_text(text) when is_float(text), do: Float.to_string(text)
+ defp normalize_message_text(_), do: ""
+
+ defp map_message_value(map, key) when is_map(map) do
+ Map.get(map, key) || Map.get(map, Atom.to_string(key))
+ end
+
+ defp compare_message_time(_message_time, nil), do: :lt
+
+ defp compare_message_time(%NaiveDateTime{} = message_time, %NaiveDateTime{} = cutoff) do
+ NaiveDateTime.compare(message_time, cutoff)
+ end
+
+ defp compare_message_time(%DateTime{} = message_time, %NaiveDateTime{} = cutoff) do
+ message_time
+ |> DateTime.to_naive()
+ |> NaiveDateTime.compare(cutoff)
+ end
+
+ defp compare_message_time(_message_time, _cutoff), do: :lt
+
+ defp to_naive_datetime(%NaiveDateTime{} = datetime), do: datetime
+ defp to_naive_datetime(%DateTime{} = datetime), do: DateTime.to_naive(datetime)
+ defp to_naive_datetime(_), do: nil
+
+ @doc """
+ Calculate unread counts for each conversation using a single batch query.
+ Returns a map of conversation_id => unread_count.
+ """
+ def calculate_unread_counts(conversations, user_id) do
+ conversation_ids = Enum.map(conversations, & &1.id)
+ Messaging.get_conversation_unread_counts(conversation_ids, user_id)
+ end
+
+ @doc """
+ Sort conversations: pinned first, then unread, then by last activity.
+ """
+ def sort_conversations_by_unread(conversations, unread_counts, user_id) do
+ Enum.sort(conversations, fn conv1, conv2 ->
+ # Check if conversation is pinned for this user
+ pinned1 =
+ Enum.find(conv1.members, fn m -> m.user_id == user_id end)
+ |> then(fn m -> m && m.pinned end) || false
+
+ pinned2 =
+ Enum.find(conv2.members, fn m -> m.user_id == user_id end)
+ |> then(fn m -> m && m.pinned end) || false
+
+ unread1 = Map.get(unread_counts, conv1.id, 0) > 0
+ unread2 = Map.get(unread_counts, conv2.id, 0) > 0
+
+ cond do
+ # Pinned conversations always come first
+ pinned1 != pinned2 ->
+ pinned1
+
+ # Among pinned or unpinned, unread comes first
+ unread1 != unread2 ->
+ unread1
+
+ # Same pinned and unread status - sort by last message time
+ true ->
+ NaiveDateTime.compare(
+ conv1.last_message_at || conv1.updated_at,
+ conv2.last_message_at || conv2.updated_at
+ ) == :gt
+ end
+ end)
+ end
+
+ @doc """
+ Calculate read status for the last message in each conversation using a single batch query.
+ Returns a map of conversation_id => %{is_read: boolean, reader_count: integer}.
+ """
+ def calculate_last_message_read_status(conversations, user_id) do
+ # Collect all messages where current user is the sender
+ message_info_list =
+ conversations
+ |> Enum.flat_map(fn conversation ->
+ case conversation.messages do
+ [last_message | _]
+ when not is_nil(last_message.sender_id) and last_message.sender_id == user_id ->
+ [{conversation.id, last_message.id, last_message.inserted_at}]
+
+ _ ->
+ []
+ end
+ end)
+
+ # Batch query for all read statuses
+ Messaging.get_batch_last_message_read_status(message_info_list)
+ end
+
+ @doc """
+ Filter conversations to remove DMs with a specific blocked user.
+ """
+ def filter_blocked_conversations(conversations, _current_user_id, blocked_user_id) do
+ Enum.filter(conversations, fn conversation ->
+ case conversation.type do
+ "dm" ->
+ # Check if this DM is with the blocked user
+ not Enum.any?(conversation.members, fn member ->
+ member.user_id == blocked_user_id and is_nil(member.left_at)
+ end)
+
+ _ ->
+ # Keep all non-DM conversations
+ true
+ end
+ end)
+ end
+
+ @doc """
+ Get user's communities for discussion creation.
+ """
+ def get_user_communities(user_id) do
+ Messaging.list_chat_conversations(user_id)
+ |> Enum.filter(&(&1.type == "community"))
+ end
+
+ @doc """
+ Convert URLs in text to clickable links.
+ """
+ def linkify_urls(text) when is_binary(text) do
+ url_pattern = ~r/(https?:\/\/[^\s&<>]+)/
+
+ text
+ |> Phoenix.HTML.html_escape()
+ |> Phoenix.HTML.safe_to_string()
+ |> then(fn escaped_text ->
+ Regex.replace(url_pattern, escaped_text, fn _, url ->
+ # Remove trailing punctuation that might not be part of the URL
+ clean_url = String.replace(url, ~r/[.!?,;:]$/, "")
+ # URL is already escaped, just use it directly
+ # Use underline and opacity for better contrast in chat bubbles
+ "#{clean_url} "
+ end)
+ end)
+ end
+
+ def linkify_urls(text), do: text
+
+ @doc """
+ Generate Phoenix Token for user authentication in channels.
+ """
+ def generate_user_token(user_id) do
+ user = Elektrine.Accounts.get_user!(user_id)
+
+ Phoenix.Token.sign(ElektrineWeb.Endpoint, "user socket", %{
+ "user_id" => user.id,
+ "password_changed_at" => password_changed_at_unix(user),
+ "auth_valid_after" => auth_valid_after_unix(user)
+ })
+ end
+
+ defp password_changed_at_unix(user) do
+ case user.last_password_change do
+ %DateTime{} = changed_at -> DateTime.to_unix(changed_at, :second)
+ _ -> 0
+ end
+ end
+
+ defp auth_valid_after_unix(user) do
+ case user.auth_valid_after do
+ %DateTime{} = valid_after -> DateTime.to_unix(valid_after, :second)
+ _ -> 0
+ end
+ end
+end
diff --git a/apps/arblarg/lib/arblarg_web/live/chat_live/operations/member_operations.ex b/apps/arblarg/lib/arblarg_web/live/chat_live/operations/member_operations.ex
new file mode 100644
index 0000000..2ec1ef5
--- /dev/null
+++ b/apps/arblarg/lib/arblarg_web/live/chat_live/operations/member_operations.ex
@@ -0,0 +1,322 @@
+defmodule ArblargWeb.ChatLive.Operations.MemberOperations do
+ @moduledoc """
+ Handles member management: add, kick, promote, demote, timeout.
+ Extracted from ChatLive.Home.
+ """
+
+ import Phoenix.Component
+ import ElektrineWeb.Live.NotificationHelpers
+
+ use Phoenix.VerifiedRoutes,
+ endpoint: ElektrineWeb.Endpoint,
+ router: ElektrineWeb.Router
+
+ alias Elektrine.Messaging, as: Messaging
+
+ def handle_event("show_add_members", _params, socket) do
+ pending_remote_join_requests =
+ load_pending_remote_join_requests(
+ socket.assigns.conversation.selected,
+ socket.assigns.current_user
+ )
+
+ {:noreply,
+ socket
+ |> assign(:ui, Map.put(socket.assigns.ui, :show_add_members_modal, true))
+ |> assign(:pending_remote_join_requests, pending_remote_join_requests)}
+ end
+
+ def handle_event("hide_add_members", _params, socket) do
+ {:noreply,
+ socket
+ |> assign(:ui, Map.put(socket.assigns.ui, :show_add_members_modal, false))
+ |> assign(:pending_remote_join_requests, [])}
+ end
+
+ def handle_event("add_member_to_conversation", %{"user_id" => user_id}, socket) do
+ conversation = socket.assigns.conversation.selected
+
+ with {:ok, user_id} <- parse_positive_int(user_id),
+ result <-
+ Messaging.add_member_to_conversation(
+ conversation.id,
+ user_id,
+ "member",
+ socket.assigns.current_user.id
+ ) do
+ case result do
+ {:ok, _} ->
+ {:noreply,
+ socket
+ |> assign(:ui, Map.put(socket.assigns.ui, :show_add_members_modal, false))
+ |> notify_info("Member added")}
+
+ {:error, :blocked} ->
+ {:noreply, notify_error(socket, "You have blocked this user or they have blocked you")}
+
+ {:error, :privacy_restricted} ->
+ {:noreply, notify_error(socket, "This user's privacy settings prevent this action")}
+
+ {:error, :unauthorized} ->
+ {:noreply, notify_error(socket, "You don't have permission to add members")}
+
+ {:error, _} ->
+ {:noreply, notify_error(socket, "Failed to add member")}
+ end
+ else
+ :error -> {:noreply, notify_error(socket, "Failed to add member")}
+ end
+ end
+
+ def handle_event("approve_remote_join_request", %{"remote_actor_id" => remote_actor_id}, socket) do
+ review_remote_join_request(socket, remote_actor_id, :approve)
+ end
+
+ def handle_event("decline_remote_join_request", %{"remote_actor_id" => remote_actor_id}, socket) do
+ review_remote_join_request(socket, remote_actor_id, :decline)
+ end
+
+ def handle_event("kick_member", %{"user_id" => user_id}, socket) do
+ conversation = socket.assigns.conversation.selected
+
+ # Route through the facade /3 so the actor (current user) is passed for the
+ # authorization check and type-routing stays consistent.
+ with {:ok, user_id} <- parse_positive_int(user_id),
+ result <-
+ Messaging.remove_member_from_conversation(
+ conversation.id,
+ user_id,
+ socket.assigns.current_user.id
+ ) do
+ case result do
+ {:ok, _} ->
+ {:noreply, notify_info(socket, "Member removed")}
+
+ {:error, :unauthorized} ->
+ {:noreply, notify_error(socket, "You don't have permission to remove members")}
+
+ {:error, _} ->
+ {:noreply, notify_error(socket, "Failed to remove member")}
+ end
+ else
+ :error -> {:noreply, notify_error(socket, "Failed to remove member")}
+ end
+ end
+
+ def handle_event("promote_member", %{"user_id" => user_id}, socket) do
+ conversation = socket.assigns.conversation.selected
+
+ with {:ok, user_id} <- parse_positive_int(user_id),
+ result <-
+ Messaging.update_member_role(
+ conversation.id,
+ user_id,
+ "admin",
+ socket.assigns.current_user.id
+ ) do
+ case result do
+ {:ok, _} ->
+ {:noreply, notify_info(socket, "Member promoted to admin")}
+
+ {:error, :unauthorized} ->
+ {:noreply, notify_error(socket, "You don't have permission to promote members")}
+
+ {:error, :cannot_modify_creator} ->
+ {:noreply, notify_error(socket, "The conversation owner's role can't be changed")}
+
+ {:error, _} ->
+ {:noreply, notify_error(socket, "Failed to promote member")}
+ end
+ else
+ :error -> {:noreply, notify_error(socket, "Failed to promote member")}
+ end
+ end
+
+ def handle_event("demote_member", %{"user_id" => user_id}, socket) do
+ conversation = socket.assigns.conversation.selected
+
+ with {:ok, user_id} <- parse_positive_int(user_id),
+ result <-
+ Messaging.update_member_role(
+ conversation.id,
+ user_id,
+ "member",
+ socket.assigns.current_user.id
+ ) do
+ case result do
+ {:ok, _} ->
+ {:noreply, notify_info(socket, "Member demoted")}
+
+ {:error, :unauthorized} ->
+ {:noreply, notify_error(socket, "You don't have permission to demote members")}
+
+ {:error, :cannot_modify_creator} ->
+ {:noreply, notify_error(socket, "The conversation owner's role can't be changed")}
+
+ {:error, _} ->
+ {:noreply, notify_error(socket, "Failed to demote member")}
+ end
+ else
+ :error -> {:noreply, notify_error(socket, "Failed to demote member")}
+ end
+ end
+
+ def handle_event("timeout_user", params, socket) do
+ conversation_id = socket.assigns.conversation.selected.id
+
+ with {:ok, user_id} <- parse_positive_int(params["user_id"]),
+ {:ok, duration} <- parse_positive_int(params["duration"]),
+ result <-
+ Messaging.timeout_user(
+ conversation_id,
+ user_id,
+ socket.assigns.current_user.id,
+ duration
+ ) do
+ case result do
+ {:ok, _} ->
+ {:noreply, notify_info(socket, "User timed out")}
+
+ {:error, :unauthorized} ->
+ {:noreply, notify_error(socket, "You don't have permission to timeout users")}
+
+ {:error, _} ->
+ {:noreply, notify_error(socket, "Failed to timeout user")}
+ end
+ else
+ :error -> {:noreply, notify_error(socket, "Failed to timeout user")}
+ end
+ end
+
+ def handle_event("remove_timeout_user", %{"user_id" => user_id}, socket) do
+ conversation_id = socket.assigns.conversation.selected.id
+
+ with {:ok, user_id} <- parse_positive_int(user_id),
+ result <- Messaging.remove_timeout(conversation_id, user_id) do
+ case result do
+ {:ok, _} ->
+ {:noreply, notify_info(socket, "Timeout removed")}
+
+ {:error, _} ->
+ {:noreply, notify_error(socket, "Failed to remove timeout")}
+ end
+ else
+ :error -> {:noreply, notify_error(socket, "Failed to remove timeout")}
+ end
+ end
+
+ def handle_event("kick_user", %{"user_id" => user_id}, socket) do
+ handle_event("kick_member", %{"user_id" => user_id}, socket)
+ end
+
+ def handle_event("show_member_management", _params, socket) do
+ {:noreply, assign(socket, :ui, Map.put(socket.assigns.ui, :show_member_management, true))}
+ end
+
+ def handle_event("hide_member_management", _params, socket) do
+ {:noreply, assign(socket, :ui, Map.put(socket.assigns.ui, :show_member_management, false))}
+ end
+
+ def handle_event("show_moderation_log", _params, socket) do
+ {:noreply, assign(socket, :ui, Map.put(socket.assigns.ui, :show_moderation_log, true))}
+ end
+
+ def handle_event("hide_moderation_log", _params, socket) do
+ {:noreply, assign(socket, :ui, Map.put(socket.assigns.ui, :show_moderation_log, false))}
+ end
+
+ defp review_remote_join_request(socket, remote_actor_id, decision)
+ when decision in [:approve, :decline] do
+ conversation = socket.assigns.conversation.selected
+
+ with true <- can_manage_remote_join_requests?(conversation, socket.assigns.current_user),
+ {parsed_remote_actor_id, ""} <- Integer.parse(remote_actor_id) do
+ result =
+ case decision do
+ :approve ->
+ Messaging.approve_remote_join_request(
+ conversation.id,
+ parsed_remote_actor_id,
+ socket.assigns.current_user.id
+ )
+
+ :decline ->
+ Messaging.decline_remote_join_request(
+ conversation.id,
+ parsed_remote_actor_id,
+ socket.assigns.current_user.id
+ )
+ end
+
+ case result do
+ {:ok, _request} ->
+ updated_requests =
+ load_pending_remote_join_requests(conversation, socket.assigns.current_user)
+
+ message =
+ if decision == :approve do
+ "Remote join approved"
+ else
+ "Remote join declined"
+ end
+
+ {:noreply,
+ socket
+ |> assign(:pending_remote_join_requests, updated_requests)
+ |> notify_info(message)}
+
+ {:error, :not_found} ->
+ {:noreply, notify_error(socket, "Remote join request not found")}
+
+ {:error, _reason} ->
+ {:noreply, notify_error(socket, "Failed to review remote join request")}
+ end
+ else
+ false ->
+ {:noreply, notify_error(socket, "You don't have permission to review remote joins")}
+
+ _ ->
+ {:noreply, notify_error(socket, "Invalid remote join request")}
+ end
+ end
+
+ defp load_pending_remote_join_requests(
+ %{type: "channel", is_federated_mirror: false, id: conversation_id} = conversation,
+ current_user
+ ) do
+ if can_manage_remote_join_requests?(conversation, current_user) do
+ Messaging.list_pending_remote_join_requests(conversation_id)
+ else
+ []
+ end
+ end
+
+ defp load_pending_remote_join_requests(_conversation, _current_user), do: []
+
+ defp can_manage_remote_join_requests?(conversation, current_user) do
+ current_member =
+ case conversation do
+ %{members: members} when is_list(members) ->
+ Enum.find(members, fn member ->
+ member.user_id == Map.get(current_user, :id) and is_nil(member.left_at)
+ end)
+
+ _ ->
+ nil
+ end
+
+ Map.get(current_user, :is_admin, false) or
+ (!!current_member && current_member.role in ["owner", "admin", "moderator"])
+ end
+
+ defp parse_positive_int(value) when is_integer(value) and value > 0, do: {:ok, value}
+
+ defp parse_positive_int(value) when is_binary(value) do
+ case Integer.parse(value) do
+ {parsed, ""} when parsed > 0 -> {:ok, parsed}
+ _ -> :error
+ end
+ end
+
+ defp parse_positive_int(_), do: :error
+end
diff --git a/apps/arblarg/lib/arblarg_web/live/chat_live/operations/message_info_operations.ex b/apps/arblarg/lib/arblarg_web/live/chat_live/operations/message_info_operations.ex
new file mode 100644
index 0000000..058de21
--- /dev/null
+++ b/apps/arblarg/lib/arblarg_web/live/chat_live/operations/message_info_operations.ex
@@ -0,0 +1,413 @@
+defmodule ArblargWeb.ChatLive.Operations.MessageInfoOperations do
+ @moduledoc false
+
+ @doc false
+ def route_info(info, socket) do
+ case info do
+ {:chat_message_updated, message} ->
+ {:handled, handle_chat_message_updated(socket, message)}
+
+ {:chat_message_deleted, message_id} ->
+ {:handled, handle_chat_message_deleted(socket, message_id)}
+
+ {:chat_reaction_added, message_id, reaction} ->
+ {:handled, handle_chat_reaction_added(socket, message_id, reaction)}
+
+ {:chat_reaction_removed, message_id, user_id, emoji} ->
+ {:handled, handle_chat_reaction_removed(socket, message_id, user_id, emoji)}
+
+ {:chat_reaction_removed, message_id, _user_id, emoji, remote_actor_id} ->
+ {:handled, handle_chat_reaction_removed(socket, message_id, nil, emoji, remote_actor_id)}
+
+ {:chat_remote_read_receipt, receipt} when is_map(receipt) ->
+ {:handled, handle_chat_remote_read_receipt(socket, receipt)}
+
+ {:chat_remote_read_cursor, cursor} when is_map(cursor) ->
+ {:handled, handle_chat_remote_read_cursor(socket, cursor)}
+
+ {:federation_presence_update, payload} when is_map(payload) ->
+ {:handled, handle_federation_presence_update(socket, payload)}
+
+ {:message_pinned, message} ->
+ {:handled, handle_message_pinned(socket, message)}
+
+ {:message_unpinned, message} ->
+ {:handled, handle_message_unpinned(socket, message)}
+
+ {:reaction_added, reaction} ->
+ {:handled, handle_reaction_added(socket, reaction)}
+
+ {:reaction_removed, reaction} ->
+ {:handled, handle_reaction_removed(socket, reaction)}
+
+ {:message_link_preview_updated, updated_message} ->
+ {:handled, handle_message_link_preview_updated(socket, updated_message)}
+
+ {:notification_count_updated, new_count} ->
+ {:handled, handle_notification_count_updated(socket, new_count)}
+
+ _ ->
+ :unhandled
+ end
+ end
+
+ def handle_chat_message_updated(socket, message) do
+ if selected_conversation_matches?(socket, message.conversation_id) do
+ messages =
+ Enum.map(socket.assigns.messages, fn msg ->
+ if msg.id == message.id, do: message, else: msg
+ end)
+
+ {:noreply, Phoenix.Component.assign(socket, :messages, messages)}
+ else
+ {:noreply, socket}
+ end
+ end
+
+ def handle_chat_message_deleted(socket, message_id) do
+ messages = Enum.reject(socket.assigns.messages, fn msg -> msg.id == message_id end)
+ {:noreply, Phoenix.Component.assign(socket, :messages, messages)}
+ end
+
+ def handle_chat_reaction_added(socket, message_id, reaction) do
+ messages =
+ Enum.map(socket.assigns.messages, fn message ->
+ if message.id == message_id do
+ existing_reactions = Map.get(message, :reactions, []) || []
+
+ already_exists =
+ Enum.any?(existing_reactions, fn existing ->
+ existing.emoji == reaction.emoji and
+ existing.user_id == reaction.user_id and
+ existing.remote_actor_id == reaction.remote_actor_id
+ end)
+
+ updated_reactions =
+ if already_exists, do: existing_reactions, else: existing_reactions ++ [reaction]
+
+ %{message | reactions: updated_reactions}
+ else
+ message
+ end
+ end)
+
+ {:noreply, Phoenix.Component.assign(socket, :messages, messages)}
+ end
+
+ def handle_chat_reaction_removed(socket, message_id, user_id, emoji) do
+ messages =
+ Enum.map(socket.assigns.messages, fn message ->
+ if message.id == message_id do
+ reactions =
+ (Map.get(message, :reactions, []) || [])
+ |> Enum.reject(fn reaction ->
+ reaction.emoji == emoji and reaction.user_id == user_id
+ end)
+
+ %{message | reactions: reactions}
+ else
+ message
+ end
+ end)
+
+ {:noreply, Phoenix.Component.assign(socket, :messages, messages)}
+ end
+
+ def handle_chat_reaction_removed(socket, message_id, _user_id, emoji, remote_actor_id) do
+ messages =
+ Enum.map(socket.assigns.messages, fn message ->
+ if message.id == message_id do
+ reactions =
+ (Map.get(message, :reactions, []) || [])
+ |> Enum.reject(fn reaction ->
+ reaction.emoji == emoji and reaction.remote_actor_id == remote_actor_id
+ end)
+
+ %{message | reactions: reactions}
+ else
+ message
+ end
+ end)
+
+ {:noreply, Phoenix.Component.assign(socket, :messages, messages)}
+ end
+
+ def handle_chat_remote_read_receipt(socket, receipt) when is_map(receipt) do
+ message_id = receipt[:message_id] || receipt["message_id"]
+
+ if is_integer(message_id) and Enum.any?(socket.assigns.messages, &(&1.id == message_id)) do
+ current_read_status = socket.assigns.message.read_status || %{}
+ current_readers = Map.get(current_read_status, message_id, [])
+
+ remote_reader = %{
+ user_id: nil,
+ remote_actor_id: receipt[:remote_actor_id] || receipt["remote_actor_id"],
+ username: receipt[:username] || receipt["username"] || "@remote",
+ avatar: receipt[:avatar] || receipt["avatar"]
+ }
+
+ updated_readers =
+ current_readers
+ |> Enum.reject(fn reader ->
+ is_integer(reader[:remote_actor_id]) and
+ reader[:remote_actor_id] == remote_reader.remote_actor_id
+ end)
+ |> Kernel.++([remote_reader])
+
+ updated_read_status = Map.put(current_read_status, message_id, updated_readers)
+
+ {:noreply,
+ Phoenix.Component.assign(socket, :message, %{
+ socket.assigns.message
+ | read_status: updated_read_status
+ })}
+ else
+ {:noreply, socket}
+ end
+ end
+
+ def handle_chat_remote_read_cursor(socket, cursor) when is_map(cursor) do
+ read_through_message_id =
+ cursor[:read_through_message_id] || cursor["read_through_message_id"] ||
+ cursor[:message_id] || cursor["message_id"]
+
+ remote_actor_id = cursor[:remote_actor_id] || cursor["remote_actor_id"]
+
+ if is_integer(read_through_message_id) and is_integer(remote_actor_id) do
+ current_read_status = socket.assigns.message.read_status || %{}
+
+ remote_reader = %{
+ user_id: nil,
+ remote_actor_id: remote_actor_id,
+ username: cursor[:username] || cursor["username"] || "@remote",
+ avatar: cursor[:avatar] || cursor["avatar"]
+ }
+
+ updated_read_status =
+ Enum.reduce(socket.assigns.messages, current_read_status, fn message, acc ->
+ if is_integer(message.id) and message.id <= read_through_message_id do
+ readers =
+ acc
+ |> Map.get(message.id, [])
+ |> Enum.reject(fn reader ->
+ is_integer(reader[:remote_actor_id]) and
+ reader[:remote_actor_id] == remote_reader.remote_actor_id
+ end)
+ |> Kernel.++([remote_reader])
+
+ Map.put(acc, message.id, readers)
+ else
+ acc
+ end
+ end)
+
+ {:noreply,
+ Phoenix.Component.assign(socket, :message, %{
+ socket.assigns.message
+ | read_status: updated_read_status
+ })}
+ else
+ {:noreply, socket}
+ end
+ end
+
+ def handle_federation_presence_update(socket, payload) when is_map(payload) do
+ conversation_id = payload[:conversation_id] || payload["conversation_id"]
+ selected_conversation_id = get_in(socket.assigns, [:conversation, :selected, :id])
+
+ if is_integer(conversation_id) and selected_conversation_id == conversation_id do
+ remote_actor_id = payload[:remote_actor_id] || payload["remote_actor_id"]
+
+ if is_integer(remote_actor_id) do
+ existing = socket.assigns[:federation_presence] || %{}
+
+ presence_entry = %{
+ conversation_id: conversation_id,
+ remote_actor_id: remote_actor_id,
+ handle: payload[:handle] || payload["handle"] || "@remote",
+ label: payload[:label] || payload["label"] || "@remote",
+ avatar_url: payload[:avatar_url] || payload["avatar_url"],
+ status: payload[:status] || payload["status"] || "offline",
+ activities: payload[:activities] || payload["activities"] || [],
+ updated_at: payload[:updated_at] || payload["updated_at"] || DateTime.utc_now()
+ }
+
+ {:noreply,
+ Phoenix.Component.assign(
+ socket,
+ :federation_presence,
+ Map.put(existing, remote_actor_id, presence_entry)
+ )}
+ else
+ {:noreply, socket}
+ end
+ else
+ {:noreply, socket}
+ end
+ end
+
+ def handle_message_pinned(socket, message) do
+ if selected_conversation_matches?(socket, message.conversation_id) do
+ messages =
+ Enum.map(socket.assigns.messages, fn msg ->
+ if msg.id == message.id do
+ %{
+ msg
+ | is_pinned: true,
+ pinned_at: message.pinned_at,
+ pinned_by_id: message.pinned_by_id
+ }
+ else
+ msg
+ end
+ end)
+
+ pinned_messages =
+ [message | Enum.reject(socket.assigns[:pinned_messages] || [], &(&1.id == message.id))]
+
+ {:noreply,
+ socket
+ |> Phoenix.Component.assign(:messages, messages)
+ |> Phoenix.Component.assign(:pinned_messages, pinned_messages)}
+ else
+ {:noreply, socket}
+ end
+ end
+
+ def handle_message_unpinned(socket, message) do
+ if selected_conversation_matches?(socket, message.conversation_id) do
+ messages =
+ Enum.map(socket.assigns.messages, fn msg ->
+ if msg.id == message.id do
+ %{msg | is_pinned: false, pinned_at: nil, pinned_by_id: nil}
+ else
+ msg
+ end
+ end)
+
+ pinned_messages =
+ Enum.reject(socket.assigns[:pinned_messages] || [], &(&1.id == message.id))
+
+ {:noreply,
+ socket
+ |> Phoenix.Component.assign(:messages, messages)
+ |> Phoenix.Component.assign(:pinned_messages, pinned_messages)}
+ else
+ {:noreply, socket}
+ end
+ end
+
+ def handle_reaction_added(socket, reaction) do
+ if selected_conversation_matches?(socket, reaction.message.conversation_id) do
+ messages =
+ Enum.map(socket.assigns.messages, fn msg ->
+ if msg.id == reaction.message.id do
+ existing_reaction =
+ Enum.find(msg.reactions, fn r ->
+ r.user_id == reaction.user_id and r.emoji == reaction.emoji
+ end)
+
+ if existing_reaction do
+ msg
+ else
+ %{msg | reactions: msg.reactions ++ [reaction]}
+ end
+ else
+ msg
+ end
+ end)
+
+ {:noreply, Phoenix.Component.assign(socket, :messages, messages)}
+ else
+ {:noreply, socket}
+ end
+ end
+
+ def handle_reaction_removed(socket, reaction) do
+ if selected_conversation_matches?(socket, reaction.message.conversation_id) do
+ messages =
+ Enum.map(socket.assigns.messages, fn msg ->
+ if msg.id == reaction.message.id do
+ reactions = Enum.reject(msg.reactions, &(&1.id == reaction.id))
+ %{msg | reactions: reactions}
+ else
+ msg
+ end
+ end)
+
+ {:noreply, Phoenix.Component.assign(socket, :messages, messages)}
+ else
+ {:noreply, socket}
+ end
+ end
+
+ def handle_message_link_preview_updated(socket, updated_message) do
+ if selected_conversation_matches?(socket, updated_message.conversation_id) do
+ messages =
+ Enum.map(socket.assigns.messages, fn msg ->
+ if msg.id == updated_message.id do
+ %{msg | link_preview: updated_message.link_preview}
+ else
+ msg
+ end
+ end)
+
+ {:noreply,
+ socket
+ |> Phoenix.Component.assign(:messages, messages)
+ |> update_conversation_latest_preview(updated_message)}
+ else
+ {:noreply, socket}
+ end
+ end
+
+ def handle_notification_count_updated(socket, new_count) do
+ {:noreply, Phoenix.Component.assign(socket, :notification_count, new_count)}
+ end
+
+ defp selected_conversation_matches?(socket, conversation_id) when is_integer(conversation_id) do
+ socket.assigns.conversation.selected &&
+ socket.assigns.conversation.selected.id == conversation_id
+ end
+
+ defp selected_conversation_matches?(_socket, _conversation_id), do: false
+
+ defp update_conversation_latest_preview(socket, updated_message) do
+ conversation_state = socket.assigns.conversation
+
+ updated_list =
+ Enum.map(
+ conversation_state.list,
+ &maybe_update_conversation_preview_message(&1, updated_message)
+ )
+
+ updated_filtered =
+ Enum.map(
+ conversation_state.filtered,
+ &maybe_update_conversation_preview_message(&1, updated_message)
+ )
+
+ Phoenix.Component.assign(socket, :conversation, %{
+ conversation_state
+ | list: updated_list,
+ filtered: updated_filtered
+ })
+ end
+
+ defp maybe_update_conversation_preview_message(conversation, updated_message) do
+ if conversation.id == updated_message.conversation_id do
+ case conversation.messages do
+ [%{id: message_id} = latest_message | rest] when message_id == updated_message.id ->
+ %{
+ conversation
+ | messages: [%{latest_message | link_preview: updated_message.link_preview} | rest]
+ }
+
+ _ ->
+ conversation
+ end
+ else
+ conversation
+ end
+ end
+end
diff --git a/apps/arblarg/lib/arblarg_web/live/chat_live/operations/message_operations.ex b/apps/arblarg/lib/arblarg_web/live/chat_live/operations/message_operations.ex
new file mode 100644
index 0000000..175c93a
--- /dev/null
+++ b/apps/arblarg/lib/arblarg_web/live/chat_live/operations/message_operations.ex
@@ -0,0 +1,1099 @@
+defmodule ArblargWeb.ChatLive.Operations.MessageOperations do
+ @moduledoc "Handles all message-related operations: sending, editing, deleting, reactions, pagination.\nExtracted from ChatLive.Home.\n"
+ import Phoenix.LiveView
+ import Phoenix.Component
+ import ElektrineWeb.Live.NotificationHelpers
+ alias ArblargWeb.ChatLive.Operations.Helpers
+ alias Elektrine.Accounts.Storage
+ alias Elektrine.Messaging, as: Messaging
+ alias Elektrine.Messaging.RoomACL
+ alias Elektrine.Messaging.SlashCommands
+ alias Elektrine.Uploads
+ @doc "Load older messages in the conversation (pagination upward).\n"
+ def handle_event("load_older_messages", _, socket) do
+ if socket.assigns.loading_older_messages || !socket.assigns.has_more_older_messages do
+ {:noreply, socket}
+ else
+ conversation = socket.assigns.conversation.selected
+ conversation_id = conversation.id
+ user_id = socket.assigns.current_user.id
+ socket = assign(socket, :loading_older_messages, true)
+
+ data =
+ Messaging.get_conversation_messages(
+ conversation_id,
+ user_id,
+ limit: 50,
+ before_id: socket.assigns.oldest_message_id
+ )
+
+ older_messages =
+ Enum.reverse(data.messages)
+
+ message_data = data
+ new_messages = Helpers.dedupe_messages(older_messages ++ socket.assigns.messages)
+ new_message_ids = Enum.map(older_messages, & &1.id)
+
+ new_read_status =
+ if new_message_ids != [] do
+ Messaging.get_read_status_for_messages(new_message_ids, conversation_id)
+ else
+ %{}
+ end
+
+ {:noreply,
+ socket
+ |> assign(:messages, new_messages)
+ |> assign(:message, %{
+ socket.assigns.message
+ | read_status: Map.merge(socket.assigns.message.read_status, new_read_status)
+ })
+ |> assign(:has_more_older_messages, message_data.has_more_older)
+ |> assign(:oldest_message_id, message_data.oldest_id || socket.assigns.oldest_message_id)
+ |> assign(:loading_older_messages, false)
+ |> push_event("maintain_scroll_position", %{})}
+ end
+ end
+
+ def handle_event("load_newer_messages", _, socket) do
+ if socket.assigns.loading_newer_messages || !socket.assigns.has_more_newer_messages do
+ {:noreply, socket}
+ else
+ conversation = socket.assigns.conversation.selected
+ conversation_id = conversation.id
+ user_id = socket.assigns.current_user.id
+ socket = assign(socket, :loading_newer_messages, true)
+
+ data =
+ Messaging.get_conversation_messages(
+ conversation_id,
+ user_id,
+ limit: 50,
+ after_id: socket.assigns.newest_message_id
+ )
+
+ newer_messages =
+ Enum.reverse(data.messages)
+
+ message_data = data
+ new_messages = Helpers.dedupe_messages(socket.assigns.messages ++ newer_messages)
+ new_message_ids = Enum.map(newer_messages, & &1.id)
+
+ new_read_status =
+ if new_message_ids != [] do
+ Messaging.get_read_status_for_messages(new_message_ids, conversation_id)
+ else
+ %{}
+ end
+
+ {:noreply,
+ socket
+ |> assign(:messages, new_messages)
+ |> assign(:message, %{
+ socket.assigns.message
+ | read_status: Map.merge(socket.assigns.message.read_status, new_read_status)
+ })
+ |> assign(:has_more_newer_messages, message_data.has_more_newer)
+ |> assign(:newest_message_id, message_data.newest_id || socket.assigns.newest_message_id)
+ |> assign(:loading_newer_messages, false)}
+ end
+ end
+
+ def handle_event("scroll_to_newest", _, socket) do
+ {:noreply, push_event(socket, "scroll_to_bottom", %{})}
+ end
+
+ def handle_event("register_chat_encryption_device", params, socket) do
+ case Messaging.register_chat_encryption_device(socket.assigns.current_user.id, params) do
+ {:ok, _device} ->
+ broadcast_chat_e2ee_devices_changed(socket)
+ socket = refresh_chat_e2ee_devices(socket)
+ {:reply, %{ok: true}, socket}
+
+ {:error, _changeset} ->
+ {:reply, %{ok: false, error: "invalid_device"}, socket}
+ end
+ end
+
+ def handle_event("chat_e2ee_key", params, socket) do
+ conversation_id = parse_int(Map.get(params, "conversation_id"))
+ device_id = Map.get(params, "device_id")
+ key_uid = Map.get(params, "key_uid")
+
+ with %{id: selected_id} <- socket.assigns.conversation.selected,
+ true <- conversation_id == selected_id,
+ {:ok, wrapped_key} <-
+ Messaging.get_wrapped_chat_key(
+ conversation_id,
+ socket.assigns.current_user.id,
+ device_id,
+ key_uid
+ ) do
+ {:reply, %{ok: true, wrapped_key: wrapped_key}, socket}
+ else
+ _ -> {:reply, %{ok: false, error: "key_not_found"}, socket}
+ end
+ end
+
+ def handle_event("chat_typing", _params, socket) do
+ {:noreply, broadcast_typing(socket)}
+ end
+
+ def handle_event("send_client_encrypted_message", params, socket) do
+ case socket.assigns.conversation.selected do
+ nil ->
+ {:reply, %{ok: false, error: "no_conversation"}, socket}
+
+ conversation ->
+ reply_to_id = socket.assigns.message.reply_to && socket.assigns.message.reply_to.id
+
+ attrs = %{
+ "encrypted_payload" => Map.get(params, "encrypted_payload"),
+ "key_packages" => Map.get(params, "key_packages", []),
+ "search_index" => Map.get(params, "search_index", [])
+ }
+
+ case Messaging.create_client_encrypted_chat_text_message(
+ conversation.id,
+ socket.assigns.current_user.id,
+ attrs,
+ reply_to_id: reply_to_id
+ ) do
+ {:ok, message} ->
+ socket = put_sent_message(socket, conversation, message)
+ {:reply, %{ok: true}, socket}
+
+ {:error, reason} ->
+ {:reply, %{ok: false, error: to_string(reason)}, socket}
+ end
+ end
+ end
+
+ def handle_event("send_message", %{"message" => message_content}, socket) do
+ trimmed_content = String.trim(message_content)
+
+ case ensure_can_send_current_conversation(socket) do
+ :ok ->
+ upload_results =
+ consume_uploaded_entries(socket, :chat_attachments, fn %{path: path}, entry ->
+ user_id = socket.assigns.current_user.id
+
+ upload_struct = %Plug.Upload{
+ path: path,
+ content_type: entry.client_type,
+ filename: entry.client_name
+ }
+
+ case Uploads.upload_chat_attachment(upload_struct, user_id) do
+ {:ok, metadata} ->
+ {:ok,
+ %{
+ url: metadata.key,
+ name: metadata.filename,
+ type: metadata.content_type,
+ size: metadata.size
+ }}
+
+ {:error, _reason} ->
+ {:ok, %{upload_error: true, name: entry.client_name}}
+ end
+ end)
+
+ {failed_uploads, uploaded_files} =
+ Enum.split_with(upload_results, fn upload ->
+ Map.get(upload, :upload_error, false)
+ end)
+
+ socket =
+ if failed_uploads != [] do
+ notify_error(
+ socket,
+ "One or more attachments failed to upload. Try a supported image format (JPG, PNG, WEBP, HEIC, AVIF) or a smaller file."
+ )
+ else
+ socket
+ end
+
+ case maybe_apply_slash_command(trimmed_content, uploaded_files, socket) do
+ {:halt, socket} ->
+ {:noreply, socket}
+
+ {:send, resolved_content, socket} ->
+ has_content = resolved_content != "" || !Enum.empty?(uploaded_files)
+
+ if has_content do
+ case socket.assigns.conversation.selected do
+ nil ->
+ {:noreply, socket}
+
+ conversation ->
+ if Map.get(
+ socket.assigns.moderation.user_timeout_status,
+ socket.assigns.current_user.id,
+ false
+ ) do
+ {:noreply,
+ notify_error(socket, "You are currently timed out and cannot send messages")}
+ else
+ reply_to_id =
+ if socket.assigns.message.reply_to do
+ socket.assigns.message.reply_to.id
+ else
+ nil
+ end
+
+ socket =
+ assign(socket, :message, %{socket.assigns.message | loading_messages: true})
+
+ result =
+ if Enum.empty?(uploaded_files) do
+ Messaging.create_text_message(
+ conversation.id,
+ socket.assigns.current_user.id,
+ resolved_content,
+ reply_to_id
+ )
+ else
+ media_urls = Enum.map(uploaded_files, & &1.url)
+
+ content =
+ if resolved_content != "" do
+ resolved_content
+ else
+ nil
+ end
+
+ media_metadata =
+ uploaded_files
+ |> Enum.map(fn file ->
+ {file.url,
+ %{size: file.size, filename: file.name, content_type: file.type}}
+ end)
+ |> Map.new()
+
+ Messaging.create_media_message(
+ conversation.id,
+ socket.assigns.current_user.id,
+ media_urls,
+ content,
+ media_metadata
+ )
+ end
+
+ case result do
+ {:ok, message} ->
+ if !Enum.empty?(uploaded_files) do
+ Storage.update_user_storage(socket.assigns.current_user.id)
+ end
+
+ if socket.assigns[:typing_timer] do
+ Process.cancel_timer(socket.assigns.typing_timer)
+ end
+
+ Phoenix.PubSub.broadcast_from(
+ Elektrine.PubSub,
+ self(),
+ "conversation:#{conversation.id}",
+ {:user_stopped_typing, socket.assigns.current_user.id}
+ )
+
+ Elektrine.Messaging.Federation.publish_typing_stopped(
+ conversation.id,
+ socket.assigns.current_user.id
+ )
+
+ message_with_sender = Elektrine.Repo.preload(message, sender: [:profile])
+ conversations = socket.assigns.conversation.list
+
+ updated_conversations =
+ Enum.map(conversations, fn conv ->
+ if conv.id == conversation.id do
+ last_message_at =
+ DateTime.from_naive!(message.inserted_at, "Etc/UTC")
+
+ %{
+ conv
+ | messages: [message_with_sender],
+ last_message_at: last_message_at
+ }
+ else
+ conv
+ end
+ end)
+
+ last_message_read_status =
+ Helpers.calculate_last_message_read_status(
+ updated_conversations,
+ socket.assigns.current_user.id
+ )
+
+ unread_counts =
+ Helpers.calculate_unread_counts(
+ updated_conversations,
+ socket.assigns.current_user.id
+ )
+
+ sorted_conversations =
+ Helpers.sort_conversations_by_unread(
+ updated_conversations,
+ unread_counts,
+ socket.assigns.current_user.id
+ )
+
+ scoped_conversations =
+ Helpers.scope_conversations_to_server(
+ sorted_conversations,
+ socket.assigns[:active_server_id]
+ )
+
+ filtered_conversations =
+ if socket.assigns.search.conversation_query != "" do
+ Helpers.filter_conversations(
+ scoped_conversations,
+ socket.assigns.search.conversation_query,
+ socket.assigns.current_user.id
+ )
+ else
+ scoped_conversations
+ end
+
+ updated_messages =
+ Helpers.dedupe_messages(socket.assigns.messages ++ [message])
+
+ updated_read_status =
+ Map.put(socket.assigns.message.read_status || %{}, message.id, [])
+
+ updated_socket =
+ socket
+ |> assign(:messages, updated_messages)
+ |> assign(:newest_message_id, message.id)
+ |> assign(:has_more_newer_messages, false)
+ |> assign(:message, %{
+ socket.assigns.message
+ | new_message: "",
+ reply_to: nil,
+ loading_messages: false,
+ read_status: updated_read_status
+ })
+ |> assign(:typing_timer, nil)
+ |> assign(:conversation, %{
+ socket.assigns.conversation
+ | list: sorted_conversations,
+ filtered: filtered_conversations,
+ last_message_read_status: last_message_read_status
+ })
+
+ updated_socket =
+ if message.media_urls && message.media_urls != [] do
+ Process.send_after(
+ self(),
+ {:ensure_scroll_after_media, message.id},
+ 50
+ )
+
+ Process.send_after(
+ self(),
+ {:ensure_scroll_after_media, message.id},
+ 200
+ )
+
+ Process.send_after(
+ self(),
+ {:ensure_scroll_after_media, message.id},
+ 500
+ )
+
+ Process.send_after(
+ self(),
+ {:ensure_scroll_after_media, message.id},
+ 1000
+ )
+
+ Process.send_after(
+ self(),
+ {:ensure_scroll_after_media, message.id},
+ 1500
+ )
+
+ updated_socket
+ else
+ push_event(updated_socket, "scroll_to_bottom", %{})
+ end
+
+ {:noreply, updated_socket}
+
+ {:error, :rate_limited} ->
+ {:noreply,
+ socket
+ |> assign(:message, %{socket.assigns.message | loading_messages: false})
+ |> notify_error("Sending too fast! Please slow down.")}
+
+ {:error, reason} ->
+ error_message = message_send_error_message(reason)
+
+ {:noreply,
+ socket
+ |> assign(:message, %{socket.assigns.message | loading_messages: false})
+ |> notify_error(error_message)}
+ end
+ end
+ end
+ else
+ {:noreply, socket}
+ end
+ end
+
+ {:error, reason} ->
+ {:noreply, notify_error(socket, message_send_error_message(reason))}
+ end
+ end
+
+ def handle_event("update_message", %{"message" => message_content}, socket) do
+ socket =
+ if Elektrine.Strings.present?(message_content), do: broadcast_typing(socket), else: socket
+
+ {:noreply, assign(socket, :message, %{socket.assigns.message | new_message: message_content})}
+ end
+
+ def handle_event("handle_keydown", %{"key" => "Enter"}, socket) do
+ trimmed_content = String.trim(socket.assigns.message.new_message)
+
+ if trimmed_content != "" do
+ handle_event("send_message", %{"message" => trimmed_content}, socket)
+ else
+ {:noreply, socket}
+ end
+ end
+
+ def handle_event("handle_keydown", _params, socket) do
+ {:noreply, socket}
+ end
+
+ def handle_event("stop_typing", _params, socket) do
+ case socket.assigns.conversation.selected do
+ nil ->
+ {:noreply, socket}
+
+ conversation ->
+ if socket.assigns[:typing_timer] do
+ Process.cancel_timer(socket.assigns.typing_timer)
+ end
+
+ Phoenix.PubSub.broadcast_from(
+ Elektrine.PubSub,
+ self(),
+ "conversation:#{conversation.id}",
+ {:user_stopped_typing, socket.assigns.current_user.id}
+ )
+
+ Elektrine.Messaging.Federation.publish_typing_stopped(
+ conversation.id,
+ socket.assigns.current_user.id
+ )
+
+ {:noreply, assign(socket, :typing_timer, nil)}
+ end
+ end
+
+ def handle_event(
+ "react_to_message",
+ %{"message_id" => message_id_str, "emoji" => emoji},
+ socket
+ ) do
+ with {:ok, message_id} <- parse_positive_int(message_id_str),
+ false <-
+ Map.get(
+ socket.assigns.moderation.user_timeout_status,
+ socket.assigns.current_user.id,
+ false
+ ) do
+ case Messaging.add_chat_reaction(message_id, socket.assigns.current_user.id, emoji) do
+ {:ok, _reaction} -> {:noreply, socket}
+ {:error, _} -> {:noreply, socket}
+ end
+ else
+ true ->
+ {:noreply,
+ notify_error(socket, "You are currently timed out and cannot react to messages")}
+
+ :error ->
+ {:noreply, socket}
+ end
+ end
+
+ def handle_event("delete_message", %{"message_id" => message_id}, socket) do
+ if socket.assigns.current_user do
+ case parse_positive_int(message_id) do
+ {:ok, message_id} ->
+ case Messaging.delete_chat_message(message_id, socket.assigns.current_user.id) do
+ {:ok, _deleted_message} ->
+ {:noreply,
+ socket
+ |> hide_message_context_menu()
+ |> notify_info("Message deleted")}
+
+ {:error, :unauthorized} ->
+ {:noreply,
+ socket
+ |> hide_message_context_menu()
+ |> notify_error("You can only delete your own messages")}
+
+ {:error, :not_found} ->
+ {:noreply,
+ socket
+ |> hide_message_context_menu()
+ |> notify_error("Message not found")}
+
+ {:error, _} ->
+ {:noreply,
+ socket
+ |> hide_message_context_menu()
+ |> notify_error("Failed to delete message")}
+ end
+
+ :error ->
+ {:noreply,
+ socket
+ |> hide_message_context_menu()
+ |> notify_error("Failed to delete message")}
+ end
+ else
+ {:noreply, notify_error(socket, "Unauthorized")}
+ end
+ end
+
+ def handle_event("delete_message_admin", %{"message_id" => message_id}, socket) do
+ if Helpers.conversation_admin_socket?(socket) do
+ case parse_positive_int(message_id) do
+ {:ok, message_id} ->
+ case Messaging.delete_chat_message(message_id, socket.assigns.current_user.id, true) do
+ {:ok, deleted_message} ->
+ conversation_id =
+ socket.assigns.conversation.selected && socket.assigns.conversation.selected.id
+
+ Messaging.log_moderation_action(
+ "delete_message",
+ deleted_message.sender_id,
+ socket.assigns.current_user.id,
+ conversation_id: conversation_id,
+ reason: "Admin message deletion",
+ details: %{message_id: message_id, message_content: deleted_message.content}
+ )
+
+ {:noreply,
+ socket
+ |> hide_message_context_menu()
+ |> notify_info("Message deleted")}
+
+ {:error, :not_found} ->
+ {:noreply,
+ socket
+ |> hide_message_context_menu()
+ |> notify_info("Message not found")}
+
+ {:error, :already_deleted} ->
+ {:noreply,
+ socket
+ |> hide_message_context_menu()
+ |> notify_info("Message already deleted")}
+
+ {:error, _} ->
+ {:noreply,
+ socket
+ |> hide_message_context_menu()
+ |> notify_error("Failed to delete message")}
+ end
+
+ :error ->
+ {:noreply,
+ socket
+ |> hide_message_context_menu()
+ |> notify_error("Failed to delete message")}
+ end
+ else
+ {:noreply,
+ socket
+ |> hide_message_context_menu()
+ |> notify_error("Unauthorized")}
+ end
+ end
+
+ def handle_event("validate_upload", _params, socket) do
+ {:noreply, socket}
+ end
+
+ def handle_event("cancel_upload", %{"ref" => ref, "upload_name" => upload_name}, socket) do
+ upload_atom =
+ case upload_name do
+ "chat_attachments" -> :chat_attachments
+ "server_icon_upload" -> :server_icon_upload
+ "group_avatar_upload" -> :group_avatar_upload
+ "channel_avatar_upload" -> :channel_avatar_upload
+ _ -> :chat_attachments
+ end
+
+ {:noreply, cancel_upload(socket, upload_atom, ref)}
+ end
+
+ def handle_event("cancel_upload", %{"ref" => ref}, socket) do
+ {:noreply, cancel_upload(socket, :chat_attachments, ref)}
+ end
+
+ def handle_event("scroll_to_message", %{"message_id" => message_id}, socket) do
+ {:noreply, socket |> push_event("scroll_to_message", %{message_id: message_id})}
+ end
+
+ def handle_event("search_messages", params, socket) do
+ query = Map.get(params, "query") || Map.get(params, "value") || ""
+ search_tokens = Map.get(params, "search_tokens", [])
+
+ if String.length(query) >= 2 and socket.assigns.conversation.selected do
+ case Messaging.search_messages_in_conversation(
+ socket.assigns.conversation.selected.id,
+ socket.assigns.current_user.id,
+ query,
+ search_tokens: search_tokens
+ ) do
+ {:ok, results} ->
+ {:noreply,
+ assign(socket, :search, %{
+ socket.assigns.search
+ | message_query: query,
+ message_results: results
+ })}
+
+ {:error, _} ->
+ {:noreply,
+ assign(socket, :search, %{
+ socket.assigns.search
+ | message_query: query,
+ message_results: []
+ })}
+ end
+ else
+ {:noreply,
+ assign(socket, :search, %{
+ socket.assigns.search
+ | message_query: query,
+ message_results: []
+ })}
+ end
+ end
+
+ def handle_event("cancel_reply", _params, socket) do
+ {:noreply, assign(socket, :message, %{socket.assigns.message | reply_to: nil})}
+ end
+
+ def handle_event("reply_to_message", %{"message_id" => message_id}, socket) do
+ message =
+ case parse_positive_int(message_id) do
+ {:ok, message_id} -> Enum.find(socket.assigns.messages, &(&1.id == message_id))
+ :error -> nil
+ end
+
+ {:noreply,
+ socket
+ |> hide_message_context_menu()
+ |> assign(:message, %{socket.assigns.message | reply_to: message})}
+ end
+
+ def handle_event("copy_message", %{"message_id" => message_id}, socket) do
+ message =
+ case parse_positive_int(message_id) do
+ {:ok, message_id} -> Enum.find(socket.assigns.messages, &(&1.id == message_id))
+ :error -> nil
+ end
+
+ socket = hide_message_context_menu(socket)
+
+ if message do
+ {:noreply,
+ push_event(socket, "copy_to_clipboard", %{text: message.content, type: "message"})}
+ else
+ {:noreply, socket}
+ end
+ end
+
+ def handle_event("pin_message", %{"message_id" => message_id}, socket) do
+ case parse_positive_int(message_id) do
+ {:ok, message_id} ->
+ case Messaging.pin_chat_message(message_id, socket.assigns.current_user.id) do
+ {:ok, _message} ->
+ {:noreply,
+ socket
+ |> hide_message_context_menu()
+ |> notify_info("Message pinned")}
+
+ {:error, :unauthorized} ->
+ {:noreply,
+ socket
+ |> hide_message_context_menu()
+ |> notify_error("Only moderators can pin messages")}
+
+ {:error, :pin_limit_reached} ->
+ {:noreply,
+ socket
+ |> hide_message_context_menu()
+ |> notify_error("Pin limit reached for this conversation")}
+
+ {:error, :already_pinned} ->
+ {:noreply,
+ socket
+ |> hide_message_context_menu()
+ |> notify_info("Message is already pinned")}
+
+ {:error, _} ->
+ {:noreply,
+ socket
+ |> hide_message_context_menu()
+ |> notify_error("Failed to pin message")}
+ end
+
+ :error ->
+ {:noreply,
+ socket
+ |> hide_message_context_menu()
+ |> notify_error("Failed to pin message")}
+ end
+ end
+
+ def handle_event("unpin_message", %{"message_id" => message_id}, socket) do
+ case parse_positive_int(message_id) do
+ {:ok, message_id} ->
+ case Messaging.unpin_chat_message(message_id, socket.assigns.current_user.id) do
+ {:ok, _message} ->
+ {:noreply,
+ socket
+ |> hide_message_context_menu()
+ |> notify_info("Message unpinned")}
+
+ {:error, :unauthorized} ->
+ {:noreply,
+ socket
+ |> hide_message_context_menu()
+ |> notify_error("Only moderators can unpin messages")}
+
+ {:error, _} ->
+ {:noreply,
+ socket
+ |> hide_message_context_menu()
+ |> notify_error("Failed to unpin message")}
+ end
+
+ :error ->
+ {:noreply,
+ socket
+ |> hide_message_context_menu()
+ |> notify_error("Failed to unpin message")}
+ end
+ end
+
+ def handle_event(
+ "send_voice_message",
+ %{"audio_data" => audio_data, "duration" => duration, "mime_type" => mime_type},
+ socket
+ ) do
+ case socket.assigns.conversation.selected do
+ nil ->
+ {:noreply, socket}
+
+ conversation ->
+ if Map.get(
+ socket.assigns.moderation.user_timeout_status,
+ socket.assigns.current_user.id,
+ false
+ ) do
+ {:noreply, notify_error(socket, "You are currently timed out and cannot send messages")}
+ else
+ case Base.decode64(audio_data) do
+ {:ok, audio_binary} ->
+ extension =
+ case mime_type do
+ "audio/webm" -> "webm"
+ "audio/mp4" -> "m4a"
+ "audio/ogg" -> "ogg"
+ _ -> "webm"
+ end
+
+ filename = "voice_#{System.system_time(:second)}.#{extension}"
+
+ case Uploads.upload_voice_message(
+ audio_binary,
+ filename,
+ mime_type,
+ socket.assigns.current_user.id
+ ) do
+ {:ok, metadata} ->
+ case Messaging.create_voice_message(
+ conversation.id,
+ socket.assigns.current_user.id,
+ metadata.key,
+ duration,
+ mime_type
+ ) do
+ {:ok, _message} ->
+ {:noreply, socket}
+
+ {:error, :rate_limited} ->
+ {:noreply, notify_error(socket, "Sending too fast! Please slow down.")}
+
+ {:error, _reason} ->
+ {:noreply, notify_error(socket, "Failed to send voice message")}
+ end
+
+ {:error, _reason} ->
+ {:noreply, notify_error(socket, "Failed to upload voice message")}
+ end
+
+ :error ->
+ {:noreply, notify_error(socket, "Invalid audio data")}
+ end
+ end
+ end
+ end
+
+ def handle_event("voice_recording_error", %{"error" => error}, socket) do
+ {:noreply, notify_error(socket, error)}
+ end
+
+ defp refresh_chat_e2ee_devices(socket) do
+ case socket.assigns.conversation.selected do
+ %{id: conversation_id} ->
+ assign(
+ socket,
+ :chat_e2ee_devices,
+ Messaging.list_chat_encryption_devices_for_conversation(conversation_id)
+ )
+
+ _ ->
+ socket
+ end
+ end
+
+ defp broadcast_chat_e2ee_devices_changed(socket) do
+ case socket.assigns.conversation.selected do
+ %{id: conversation_id} ->
+ Phoenix.PubSub.broadcast_from(
+ Elektrine.PubSub,
+ self(),
+ "conversation:#{conversation_id}",
+ {:chat_e2ee_devices_changed, conversation_id}
+ )
+
+ _ ->
+ :ok
+ end
+ end
+
+ defp put_sent_message(socket, conversation, message) do
+ message = Elektrine.Repo.preload(message, sender: [:profile])
+ unread_counts = Map.put(socket.assigns.conversation.unread_counts || %{}, conversation.id, 0)
+
+ if socket.assigns[:typing_timer] do
+ Process.cancel_timer(socket.assigns.typing_timer)
+ end
+
+ Phoenix.PubSub.broadcast_from(
+ Elektrine.PubSub,
+ self(),
+ "conversation:#{conversation.id}",
+ {:user_stopped_typing, socket.assigns.current_user.id}
+ )
+
+ Elektrine.Messaging.Federation.publish_typing_stopped(
+ conversation.id,
+ socket.assigns.current_user.id
+ )
+
+ conversations =
+ update_sent_conversation_preview(socket.assigns.conversation.list, conversation, message)
+
+ last_message_read_status =
+ Helpers.calculate_last_message_read_status(conversations, socket.assigns.current_user.id)
+
+ sorted_conversations =
+ Helpers.sort_conversations_by_unread(
+ conversations,
+ unread_counts,
+ socket.assigns.current_user.id
+ )
+
+ scoped_conversations =
+ Helpers.scope_conversations_to_server(
+ sorted_conversations,
+ socket.assigns[:active_server_id]
+ )
+
+ filtered_conversations =
+ if socket.assigns.search.conversation_query != "" do
+ Helpers.filter_conversations(
+ scoped_conversations,
+ socket.assigns.search.conversation_query,
+ socket.assigns.current_user.id
+ )
+ else
+ scoped_conversations
+ end
+
+ socket
+ |> assign(:messages, Helpers.dedupe_messages(socket.assigns.messages ++ [message]))
+ |> assign(:newest_message_id, message.id)
+ |> assign(:has_more_newer_messages, false)
+ |> assign(:message, %{
+ socket.assigns.message
+ | new_message: "",
+ reply_to: nil,
+ loading_messages: false,
+ read_status: Map.put(socket.assigns.message.read_status || %{}, message.id, [])
+ })
+ |> assign(:typing_timer, nil)
+ |> assign(:conversation, %{
+ socket.assigns.conversation
+ | list: sorted_conversations,
+ filtered: filtered_conversations,
+ unread_counts: unread_counts,
+ last_message_read_status: last_message_read_status
+ })
+ |> push_event("clear_message_input", %{})
+ |> push_event("scroll_to_bottom", %{})
+ end
+
+ defp update_sent_conversation_preview(conversations, conversation, message)
+ when is_list(conversations) do
+ Enum.map(conversations, fn current ->
+ if current.id == conversation.id do
+ %{
+ current
+ | messages: [message],
+ last_message_at: message_datetime(message, current.last_message_at)
+ }
+ else
+ current
+ end
+ end)
+ end
+
+ defp update_sent_conversation_preview(_, _conversation, _message), do: []
+
+ defp message_datetime(%{inserted_at: %DateTime{} = datetime}, _fallback), do: datetime
+
+ defp message_datetime(%{inserted_at: %NaiveDateTime{} = naive_datetime}, _fallback),
+ do: DateTime.from_naive!(naive_datetime, "Etc/UTC")
+
+ defp message_datetime(_message, fallback), do: fallback
+
+ defp broadcast_typing(socket) do
+ case socket.assigns.conversation.selected do
+ nil ->
+ socket
+
+ conversation ->
+ should_broadcast =
+ case socket.assigns[:last_typing_broadcast] do
+ nil -> true
+ last_time -> System.system_time(:millisecond) - last_time > 2000
+ end
+
+ socket =
+ if should_broadcast do
+ Phoenix.PubSub.broadcast_from(
+ Elektrine.PubSub,
+ self(),
+ "conversation:#{conversation.id}",
+ {:user_typing, socket.assigns.current_user.id,
+ socket.assigns.current_user.handle || socket.assigns.current_user.username}
+ )
+
+ Elektrine.Messaging.Federation.publish_typing_started(
+ conversation.id,
+ socket.assigns.current_user.id
+ )
+
+ assign(socket, :last_typing_broadcast, System.system_time(:millisecond))
+ else
+ socket
+ end
+
+ if socket.assigns[:typing_timer] do
+ Process.cancel_timer(socket.assigns.typing_timer)
+ end
+
+ timer = Process.send_after(self(), :clear_typing, 3000)
+ assign(socket, :typing_timer, timer)
+ end
+ end
+
+ defp parse_int(value) when is_integer(value), do: value
+
+ defp parse_int(value) when is_binary(value) do
+ case Integer.parse(value) do
+ {integer, ""} -> integer
+ _ -> nil
+ end
+ end
+
+ defp parse_int(_), do: nil
+
+ defp parse_positive_int(value) do
+ case parse_int(value) do
+ integer when is_integer(integer) and integer > 0 -> {:ok, integer}
+ _ -> :error
+ end
+ end
+
+ defp hide_message_context_menu(socket) do
+ assign(socket, :context_menu, %{
+ socket.assigns.context_menu
+ | message: nil,
+ selected_text: nil
+ })
+ end
+
+ defp ensure_can_send_current_conversation(socket) do
+ case socket.assigns.conversation.selected do
+ %{id: conversation_id} ->
+ RoomACL.authorize_local_user_action(
+ conversation_id,
+ socket.assigns.current_user.id,
+ :write
+ )
+
+ _ ->
+ {:error, :no_conversation}
+ end
+ end
+
+ defp message_send_error_message(:unauthorized), do: "You cannot send messages in this chat."
+
+ defp message_send_error_message(:not_authorized_for_room),
+ do: "You cannot send messages in this chat."
+
+ defp message_send_error_message(:no_conversation), do: "Select a chat before sending."
+
+ defp message_send_error_message(reason), do: Elektrine.Privacy.privacy_error_message(reason)
+
+ defp maybe_apply_slash_command("", _uploaded_files, socket) do
+ {:send, "", socket}
+ end
+
+ defp maybe_apply_slash_command(content, uploaded_files, socket) do
+ if uploaded_files != [] or not String.starts_with?(content, "/") do
+ {:send, content, socket}
+ else
+ conversation = socket.assigns.conversation.selected
+ user = socket.assigns.current_user
+
+ case SlashCommands.process(content,
+ conversation: conversation,
+ endpoint_url: ElektrineWeb.Endpoint.url(),
+ user_display: user.display_name,
+ user_handle: user.handle,
+ username: user.username
+ ) do
+ {:send, resolved_content} -> {:send, resolved_content, socket}
+ {:noop, info_message} -> {:halt, notify_info(socket, info_message)}
+ {:error, error_message} -> {:halt, notify_error(socket, error_message)}
+ end
+ end
+ end
+end
diff --git a/apps/arblarg/lib/arblarg_web/live/chat_live/operations/thread_operations.ex b/apps/arblarg/lib/arblarg_web/live/chat_live/operations/thread_operations.ex
new file mode 100644
index 0000000..fa099ec
--- /dev/null
+++ b/apps/arblarg/lib/arblarg_web/live/chat_live/operations/thread_operations.ex
@@ -0,0 +1,319 @@
+defmodule ArblargWeb.ChatLive.Operations.ThreadOperations do
+ @moduledoc """
+ Handles chat thread operations: creating threads from messages, the
+ right-hand thread panel (thread list + single-thread view with composer),
+ archiving, and the PubSub events that keep open panels in sync.
+ """
+
+ import Phoenix.Component
+ import ElektrineWeb.Live.NotificationHelpers
+
+ alias ArblargWeb.ChatLive.State
+ alias Elektrine.Messaging
+
+ ## Events
+
+ def handle_event("toggle_thread_panel", _params, socket) do
+ threads = socket.assigns.threads
+
+ if threads.show_panel do
+ {:noreply, assign(socket, :threads, %{threads | show_panel: false})}
+ else
+ {:noreply,
+ assign(socket, :threads, refresh_thread_lists(%{threads | show_panel: true}, socket))}
+ end
+ end
+
+ def handle_event("close_thread_panel", _params, socket) do
+ threads = socket.assigns.threads
+
+ {:noreply,
+ assign(socket, :threads, %{threads | show_panel: false, active: nil, messages: []})}
+ end
+
+ def handle_event("back_to_thread_list", _params, socket) do
+ threads = socket.assigns.threads
+
+ {:noreply, assign(socket, :threads, %{threads | active: nil, messages: [], composer: ""})}
+ end
+
+ def handle_event("toggle_archived_threads", _params, socket) do
+ threads = socket.assigns.threads
+ show_archived = !threads.show_archived
+
+ archived =
+ if show_archived and selected_conversation_id(socket) do
+ Messaging.list_chat_threads(selected_conversation_id(socket), :archived)
+ else
+ threads.archived
+ end
+
+ {:noreply,
+ assign(socket, :threads, %{threads | show_archived: show_archived, archived: archived})}
+ end
+
+ def handle_event("create_thread", %{"message_id" => message_id}, socket) do
+ user_id = socket.assigns.current_user.id
+
+ case Messaging.create_chat_thread_from_message(message_id, user_id) do
+ {:ok, thread} ->
+ {:noreply,
+ socket
+ |> hide_message_context_menu()
+ |> open_thread(thread)
+ |> notify_info("Thread created")}
+
+ {:error, :thread_exists} ->
+ {:noreply,
+ socket
+ |> hide_message_context_menu()
+ |> notify_info("This message already has a thread")}
+
+ {:error, :unauthorized} ->
+ {:noreply,
+ socket
+ |> hide_message_context_menu()
+ |> notify_error("You don't have permission to create threads here")}
+
+ {:error, :unsupported_conversation_type} ->
+ {:noreply,
+ socket
+ |> hide_message_context_menu()
+ |> notify_error("Threads are only available in channels")}
+
+ {:error, _} ->
+ {:noreply,
+ socket
+ |> hide_message_context_menu()
+ |> notify_error("Failed to create thread")}
+ end
+ end
+
+ def handle_event("open_thread", %{"thread_id" => thread_id}, socket) do
+ with {:ok, thread_id} <- parse_positive_int(thread_id),
+ %{} = thread <- Messaging.get_chat_thread(thread_id),
+ true <- thread.conversation_id == selected_conversation_id(socket) do
+ {:noreply, open_thread(socket, thread)}
+ else
+ _ -> {:noreply, notify_error(socket, "Thread not found")}
+ end
+ end
+
+ def handle_event("archive_thread", %{"thread_id" => thread_id}, socket) do
+ with {:ok, thread_id} <- parse_positive_int(thread_id),
+ {:ok, _thread} <-
+ Messaging.archive_chat_thread(thread_id, socket.assigns.current_user.id) do
+ {:noreply, notify_info(socket, "Thread archived")}
+ else
+ {:error, :unauthorized} ->
+ {:noreply, notify_error(socket, "You don't have permission to archive this thread")}
+
+ _ ->
+ {:noreply, notify_error(socket, "Failed to archive thread")}
+ end
+ end
+
+ def handle_event("unarchive_thread", %{"thread_id" => thread_id}, socket) do
+ with {:ok, thread_id} <- parse_positive_int(thread_id),
+ {:ok, _thread} <-
+ Messaging.unarchive_chat_thread(thread_id, socket.assigns.current_user.id) do
+ {:noreply, notify_info(socket, "Thread reopened")}
+ else
+ {:error, :unauthorized} ->
+ {:noreply, notify_error(socket, "You don't have permission to reopen this thread")}
+
+ _ ->
+ {:noreply, notify_error(socket, "Failed to reopen thread")}
+ end
+ end
+
+ def handle_event("update_thread_composer", %{"message" => value}, socket) do
+ threads = socket.assigns.threads
+ {:noreply, assign(socket, :threads, %{threads | composer: value})}
+ end
+
+ def handle_event("send_thread_message", %{"message" => content}, socket) do
+ threads = socket.assigns.threads
+ content = String.trim(content || "")
+
+ cond do
+ is_nil(threads.active) ->
+ {:noreply, socket}
+
+ content == "" ->
+ {:noreply, socket}
+
+ true ->
+ case Messaging.create_chat_thread_message(
+ threads.active.id,
+ socket.assigns.current_user.id,
+ content
+ ) do
+ {:ok, _message} ->
+ {:noreply, assign(socket, :threads, %{socket.assigns.threads | composer: ""})}
+
+ {:error, :thread_archived} ->
+ {:noreply, notify_error(socket, "This thread is archived")}
+
+ {:error, :unauthorized} ->
+ {:noreply, notify_error(socket, "You can't send messages here")}
+
+ {:error, _} ->
+ {:noreply, notify_error(socket, "Failed to send message")}
+ end
+ end
+ end
+
+ ## PubSub info routing (chained before the generic message info router)
+
+ def route_info(info, socket) do
+ case info do
+ {:thread_created, thread} ->
+ {:handled, handle_thread_upserted(socket, thread)}
+
+ {:thread_updated, thread} ->
+ {:handled, handle_thread_upserted(socket, thread)}
+
+ {:thread_archived, thread} ->
+ {:handled, handle_thread_upserted(socket, thread)}
+
+ {:new_thread_message, message} ->
+ {:handled, handle_new_thread_message(socket, message)}
+
+ _ ->
+ :unhandled
+ end
+ end
+
+ ## Helpers used by the template
+
+ @doc """
+ Returns the thread rooted at `message_id`, if any (active or archived).
+ """
+ def thread_for_message(%State.Threads{} = threads, message_id) do
+ Enum.find(threads.list ++ threads.archived, &(&1.root_message_id == message_id))
+ end
+
+ def thread_for_message(_threads, _message_id), do: nil
+
+ ## Internal
+
+ defp handle_thread_upserted(socket, thread) do
+ if thread.conversation_id == selected_conversation_id(socket) do
+ threads = socket.assigns.threads
+
+ active =
+ if threads.active && threads.active.id == thread.id do
+ thread
+ else
+ threads.active
+ end
+
+ list = upsert_sorted(threads.list, thread, is_nil(thread.archived_at))
+
+ archived =
+ if threads.show_archived do
+ upsert_sorted(threads.archived, thread, not is_nil(thread.archived_at))
+ else
+ threads.archived
+ end
+
+ {:noreply,
+ assign(socket, :threads, %{threads | list: list, archived: archived, active: active})}
+ else
+ {:noreply, socket}
+ end
+ end
+
+ defp handle_new_thread_message(socket, message) do
+ threads = socket.assigns.threads
+
+ if threads.active && threads.active.id == message.thread_id do
+ messages =
+ if Enum.any?(threads.messages, &(&1.id == message.id)) do
+ threads.messages
+ else
+ threads.messages ++ [message]
+ end
+
+ {:noreply, assign(socket, :threads, %{threads | messages: messages})}
+ else
+ {:noreply, socket}
+ end
+ end
+
+ defp open_thread(socket, thread) do
+ threads = socket.assigns.threads
+ messages = Messaging.list_chat_thread_messages(thread.id)
+
+ assign(socket, :threads, %{
+ threads
+ | show_panel: true,
+ active: thread,
+ messages: messages,
+ composer: "",
+ list: refresh_active_list(socket)
+ })
+ end
+
+ defp refresh_thread_lists(threads, socket) do
+ case selected_conversation_id(socket) do
+ nil ->
+ threads
+
+ conversation_id ->
+ archived =
+ if threads.show_archived do
+ Messaging.list_chat_threads(conversation_id, :archived)
+ else
+ threads.archived
+ end
+
+ %{threads | list: Messaging.list_chat_threads(conversation_id), archived: archived}
+ end
+ end
+
+ defp refresh_active_list(socket) do
+ case selected_conversation_id(socket) do
+ nil -> socket.assigns.threads.list
+ conversation_id -> Messaging.list_chat_threads(conversation_id)
+ end
+ end
+
+ defp upsert_sorted(threads, thread, belongs?) do
+ without = Enum.reject(threads, &(&1.id == thread.id))
+
+ if belongs? do
+ [thread | without]
+ |> Enum.sort_by(&(&1.last_activity_at || &1.inserted_at), {:desc, DateTime})
+ else
+ without
+ end
+ end
+
+ defp selected_conversation_id(socket) do
+ case socket.assigns.conversation.selected do
+ %{id: id} -> id
+ _ -> nil
+ end
+ end
+
+ defp parse_positive_int(value) when is_integer(value) and value > 0, do: {:ok, value}
+
+ defp parse_positive_int(value) when is_binary(value) do
+ case Integer.parse(value) do
+ {int, ""} when int > 0 -> {:ok, int}
+ _ -> :error
+ end
+ end
+
+ defp parse_positive_int(_value), do: :error
+
+ defp hide_message_context_menu(socket) do
+ assign(socket, :context_menu, %{
+ socket.assigns.context_menu
+ | message: nil,
+ selected_text: nil
+ })
+ end
+end
diff --git a/apps/arblarg/lib/arblarg_web/live/chat_live/operations/ui_operations.ex b/apps/arblarg/lib/arblarg_web/live/chat_live/operations/ui_operations.ex
new file mode 100644
index 0000000..b3e0d09
--- /dev/null
+++ b/apps/arblarg/lib/arblarg_web/live/chat_live/operations/ui_operations.ex
@@ -0,0 +1,260 @@
+defmodule ArblargWeb.ChatLive.Operations.UIOperations do
+ @moduledoc """
+ Handles UI state: modals, dropdowns, search, emoji picker, etc.
+ Extracted from ChatLive.Home.
+ """
+
+ import Phoenix.LiveView
+ import Phoenix.Component
+
+ @overlay_ui_keys [
+ :show_new_chat,
+ :show_create_group,
+ :show_create_channel,
+ :show_group_modal,
+ :show_channel_modal,
+ :show_category_modal,
+ :show_server_modal,
+ :show_browse_channels,
+ :show_settings_modal,
+ :show_edit_modal,
+ :show_add_members_modal,
+ :show_message_search_modal,
+ :show_emoji_picker,
+ :show_profile_modal,
+ :show_member_management,
+ :show_moderation_log,
+ :show_browse_modal
+ ]
+
+ def handle_event("close_dropdown", _params, socket) do
+ # Just acknowledge the event - dropdown will close automatically
+ {:noreply, socket}
+ end
+
+ def handle_event("toggle_mobile_search", _params, socket) do
+ {:noreply, assign(socket, :show_mobile_search, !socket.assigns.show_mobile_search)}
+ end
+
+ def handle_event("close_chat_overlay", _params, socket) do
+ ui = Enum.reduce(@overlay_ui_keys, socket.assigns.ui, &Map.put(&2, &1, false))
+
+ context_menu = %{
+ socket.assigns.context_menu
+ | conversation: nil,
+ message: nil,
+ selected_text: nil
+ }
+
+ {:noreply,
+ socket
+ |> assign(:ui, ui)
+ |> assign(:context_menu, context_menu)
+ |> assign(:show_mobile_search, false)
+ |> assign(:show_report_modal, false)
+ |> assign(:report_type, nil)
+ |> assign(:report_id, nil)
+ |> assign(:report_metadata, %{})
+ |> assign(:show_image_modal, false)
+ |> assign(:modal_image_url, nil)
+ |> assign(:modal_images, [])
+ |> assign(:modal_image_index, 0)
+ |> assign(:modal_post, nil)}
+ end
+
+ def handle_event("stop_event", _params, socket) do
+ {:noreply, socket}
+ end
+
+ def handle_event("navigate_to_origin", %{"url" => url}, socket) do
+ ElektrineWeb.SafeLiveNavigation.noreply(socket, url)
+ end
+
+ def handle_event("navigate_to_embedded_post", %{"url" => url}, socket) do
+ ElektrineWeb.SafeLiveNavigation.noreply(socket, url)
+ end
+
+ def handle_event("show_message_search", _params, socket) do
+ {:noreply, assign(socket, :ui, Map.put(socket.assigns.ui, :show_message_search_modal, true))}
+ end
+
+ def handle_event("hide_message_search", _params, socket) do
+ {:noreply, assign(socket, :ui, Map.put(socket.assigns.ui, :show_message_search_modal, false))}
+ end
+
+ def handle_event("toggle_pinned_panel", _params, socket) do
+ show_pinned_panel = !socket.assigns.ui.show_pinned_panel
+
+ {:noreply,
+ assign(socket, :ui, Map.put(socket.assigns.ui, :show_pinned_panel, show_pinned_panel))}
+ end
+
+ def handle_event("ignore", _params, socket) do
+ {:noreply, socket}
+ end
+
+ def handle_event("view_original_context", %{"message_id" => message_id}, socket) do
+ {:noreply, push_event(socket, "scroll_to_message", %{message_id: message_id})}
+ end
+
+ def handle_event("show_report_modal", %{"type" => type, "id" => id}, socket) do
+ case parse_positive_int(id) do
+ {:ok, id} ->
+ {:noreply,
+ socket
+ |> assign(:show_report_modal, true)
+ |> assign(:report_type, type)
+ |> assign(:report_id, id)}
+
+ :error ->
+ {:noreply, put_flash(socket, :error, "Invalid report target")}
+ end
+ end
+
+ def handle_event("close_report_modal", _params, socket) do
+ {:noreply,
+ socket
+ |> assign(:show_report_modal, false)
+ |> assign(:report_type, nil)
+ |> assign(:report_id, nil)
+ |> assign(:report_metadata, %{})}
+ end
+
+ # Image Modal Operations
+ def handle_event(
+ "open_image_modal",
+ %{"images" => images_json, "index" => index} = params,
+ socket
+ ) do
+ with {:ok, images} when is_list(images) <- Jason.decode(images_json),
+ {:ok, index_int} <- parse_non_negative_int(index) do
+ url = params["url"] || Enum.at(images, index_int, List.first(images))
+
+ {:noreply,
+ socket
+ |> assign(:show_image_modal, true)
+ |> assign(:modal_image_url, url)
+ |> assign(:modal_images, images)
+ |> assign(:modal_image_index, index_int)
+ |> assign(:modal_post, modal_message(params["message_id"], socket))}
+ else
+ _ -> {:noreply, put_flash(socket, :error, "Unable to open image")}
+ end
+ end
+
+ def handle_event("close_image_modal", _params, socket) do
+ {:noreply,
+ socket
+ |> assign(:show_image_modal, false)
+ |> assign(:modal_image_url, nil)
+ |> assign(:modal_images, [])
+ |> assign(:modal_image_index, 0)
+ |> assign(:modal_post, nil)}
+ end
+
+ def handle_event("next_image", _params, socket) do
+ total = length(socket.assigns.modal_images)
+
+ if total > 0 do
+ new_index = rem(socket.assigns.modal_image_index + 1, total)
+ new_url = Enum.at(socket.assigns.modal_images, new_index)
+
+ {:noreply,
+ socket
+ |> assign(:modal_image_index, new_index)
+ |> assign(:modal_image_url, new_url)}
+ else
+ {:noreply, socket}
+ end
+ end
+
+ def handle_event("prev_image", _params, socket) do
+ total = length(socket.assigns.modal_images)
+
+ if total > 0 do
+ new_index = rem(socket.assigns.modal_image_index - 1 + total, total)
+ new_url = Enum.at(socket.assigns.modal_images, new_index)
+
+ {:noreply,
+ socket
+ |> assign(:modal_image_index, new_index)
+ |> assign(:modal_image_url, new_url)}
+ else
+ {:noreply, socket}
+ end
+ end
+
+ def handle_event("next_media_post", _params, socket) do
+ navigate_to_media_message(socket, :next)
+ end
+
+ def handle_event("prev_media_post", _params, socket) do
+ navigate_to_media_message(socket, :prev)
+ end
+
+ defp navigate_to_media_message(socket, direction) do
+ modal_post = socket.assigns[:modal_post]
+ messages = socket.assigns[:messages] || []
+
+ if is_nil(modal_post) or Enum.empty?(messages) do
+ {:noreply, socket}
+ else
+ # Find messages with media
+ media_messages =
+ Enum.filter(messages, fn msg ->
+ media_urls = msg.media_urls || []
+ media_urls != []
+ end)
+
+ # Find current message index in media_messages
+ current_index = Enum.find_index(media_messages, fn msg -> msg.id == modal_post.id end)
+
+ if is_nil(current_index) do
+ {:noreply, socket}
+ else
+ # Calculate new index
+ total = length(media_messages)
+
+ new_index =
+ case direction do
+ :next -> rem(current_index + 1, total)
+ :prev -> rem(current_index - 1 + total, total)
+ end
+
+ new_message = Enum.at(media_messages, new_index)
+ new_images = new_message.media_urls || []
+ new_url = List.first(new_images)
+
+ {:noreply,
+ socket
+ |> assign(:modal_post, new_message)
+ |> assign(:modal_images, new_images)
+ |> assign(:modal_image_index, 0)
+ |> assign(:modal_image_url, new_url)}
+ end
+ end
+ end
+
+ defp modal_message(nil, _socket), do: nil
+
+ defp modal_message(message_id, socket) do
+ case parse_positive_int(message_id) do
+ {:ok, message_id} -> Enum.find(socket.assigns.messages, fn msg -> msg.id == message_id end)
+ :error -> nil
+ end
+ end
+
+ defp parse_positive_int(value) do
+ case Integer.parse(to_string(value)) do
+ {int, ""} when int > 0 -> {:ok, int}
+ _ -> :error
+ end
+ end
+
+ defp parse_non_negative_int(value) do
+ case Integer.parse(to_string(value)) do
+ {int, ""} when int >= 0 -> {:ok, int}
+ _ -> :error
+ end
+ end
+end
diff --git a/apps/arblarg/lib/arblarg_web/live/chat_live/operations/voice_channel_operations.ex b/apps/arblarg/lib/arblarg_web/live/chat_live/operations/voice_channel_operations.ex
new file mode 100644
index 0000000..bcb8b05
--- /dev/null
+++ b/apps/arblarg/lib/arblarg_web/live/chat_live/operations/voice_channel_operations.ex
@@ -0,0 +1,242 @@
+defmodule ArblargWeb.ChatLive.Operations.VoiceChannelOperations do
+ @moduledoc """
+ Voice channel operations for the chat LiveView: joining/leaving voice
+ channels, mute toggling, and sidebar occupancy driven by Phoenix Presence
+ on the `voice:` topics.
+
+ The LiveView only orchestrates: the WebRTC mesh itself lives in the
+ `VoiceChannel` JS hook, which talks to `ElektrineWeb.VoiceChannel` over the
+ user socket (same transport style as 1:1 calls).
+ """
+
+ import Phoenix.LiveView
+ import Phoenix.Component
+ import ElektrineWeb.Live.NotificationHelpers
+
+ alias ArblargWeb.ChatLive.Operations.Helpers
+ alias Elektrine.Calls.Transport, as: CallTransport
+ alias Elektrine.Messaging.VoiceChannels
+ alias ElektrineWeb.Presence
+
+ def handle_event("join_voice_channel", %{"conversation_id" => conversation_id}, socket) do
+ user_id = socket.assigns.current_user.id
+
+ with {:ok, conversation_id} <- parse_positive_int(conversation_id),
+ :ok <-
+ VoiceChannels.authorize_join(conversation_id, user_id, occupant_ids(conversation_id)) do
+ conversation = find_conversation(socket, conversation_id)
+ transport = CallTransport.descriptor_for_user(user_id, conversation_id)
+
+ {:noreply,
+ socket
+ |> assign(:voice, %{
+ socket.assigns.voice
+ | joined_id: conversation_id,
+ joined_name: (conversation && conversation.name) || "Voice channel",
+ muted: false
+ })
+ |> push_event("voice_join", %{
+ conversation_id: conversation_id,
+ user_id: user_id,
+ user_token: user_token(socket),
+ ice_servers: transport["ice_servers"],
+ transport: transport
+ })}
+ else
+ {:error, :channel_full} ->
+ {:noreply, notify_error(socket, "This voice channel is full")}
+
+ {:error, :already_joined} ->
+ {:noreply, notify_error(socket, "You're already connected to this voice channel")}
+
+ {:error, :remote_mirror} ->
+ {:noreply,
+ notify_error(socket, "Voice channels on remote servers can't be joined from here")}
+
+ {:error, :not_found} ->
+ {:noreply, notify_error(socket, "Voice channel not found")}
+
+ _ ->
+ {:noreply, notify_error(socket, "You don't have access to this voice channel")}
+ end
+ end
+
+ def handle_event("leave_voice_channel", _params, socket) do
+ {:noreply,
+ socket
+ |> assign(:voice, reset_connection(socket.assigns.voice))
+ |> push_event("voice_leave", %{})}
+ end
+
+ def handle_event("toggle_voice_mute", _params, socket) do
+ {:noreply, push_event(socket, "voice_toggle_mute", %{})}
+ end
+
+ # Confirmations and errors reported back by the VoiceChannel JS hook.
+
+ def handle_event("voice_joined", _params, socket) do
+ {:noreply, socket}
+ end
+
+ # Sent by the hook after a LiveView reconnect while a media session is
+ # still running, so the connected bar reflects reality again.
+ def handle_event("voice_rejoined", %{"conversation_id" => conversation_id} = params, socket) do
+ case parse_positive_int(conversation_id) do
+ {:ok, conversation_id} ->
+ conversation = find_conversation(socket, conversation_id)
+
+ {:noreply,
+ assign(socket, :voice, %{
+ socket.assigns.voice
+ | joined_id: conversation_id,
+ joined_name: (conversation && conversation.name) || "Voice channel",
+ muted: params["muted"] == true
+ })}
+
+ _ ->
+ {:noreply, socket}
+ end
+ end
+
+ def handle_event("voice_left", _params, socket) do
+ {:noreply, assign(socket, :voice, reset_connection(socket.assigns.voice))}
+ end
+
+ def handle_event("voice_mute_changed", %{"muted" => muted}, socket) do
+ {:noreply, assign(socket, :voice, %{socket.assigns.voice | muted: muted == true})}
+ end
+
+ def handle_event("voice_error", params, socket) do
+ {:noreply,
+ socket
+ |> assign(:voice, reset_connection(socket.assigns.voice))
+ |> notify_error(voice_error_message(params["reason"]))}
+ end
+
+ @doc """
+ Handles presence diffs broadcast on `voice:` topics.
+
+ Returns `{:handled, result}` or `:unhandled` (same contract as the other
+ `route_info` operation modules).
+ """
+ def route_info(
+ %Phoenix.Socket.Broadcast{topic: "voice:" <> conversation_id, event: "presence_diff"},
+ socket
+ ) do
+ case parse_positive_int(conversation_id) do
+ {:ok, conversation_id} ->
+ occupants =
+ Map.put(
+ socket.assigns.voice.occupants,
+ conversation_id,
+ occupants_for(conversation_id)
+ )
+
+ {:handled,
+ {:noreply, assign(socket, :voice, %{socket.assigns.voice | occupants: occupants})}}
+
+ _ ->
+ {:handled, {:noreply, socket}}
+ end
+ end
+
+ def route_info(%Phoenix.Socket.Broadcast{topic: "voice:" <> _rest}, socket) do
+ {:handled, {:noreply, socket}}
+ end
+
+ def route_info(_info, _socket), do: :unhandled
+
+ @doc """
+ Subscribes the LiveView to the presence topics of every voice channel in
+ the conversation list and refreshes occupant snapshots. Call whenever the
+ conversation list is (re)loaded.
+ """
+ def sync_voice_channels(socket, conversations) when is_list(conversations) do
+ voice_ids =
+ conversations
+ |> Enum.filter(&(&1.type == "voice_channel"))
+ |> MapSet.new(& &1.id)
+
+ subscribed = socket.assigns.voice.subscribed_ids
+
+ if connected?(socket) do
+ Enum.each(MapSet.difference(voice_ids, subscribed), fn id ->
+ Phoenix.PubSub.subscribe(Elektrine.PubSub, "voice:#{id}")
+ end)
+
+ Enum.each(MapSet.difference(subscribed, voice_ids), fn id ->
+ Phoenix.PubSub.unsubscribe(Elektrine.PubSub, "voice:#{id}")
+ end)
+ end
+
+ occupants = Map.new(voice_ids, fn id -> {id, occupants_for(id)} end)
+
+ assign(socket, :voice, %{
+ socket.assigns.voice
+ | subscribed_ids: voice_ids,
+ occupants: occupants
+ })
+ end
+
+ def sync_voice_channels(socket, _conversations), do: socket
+
+ @doc """
+ Occupant maps (user_id, username, display_name, avatar, muted) for one
+ voice channel, ordered by join time.
+ """
+ def occupants_for(conversation_id) do
+ "voice:#{conversation_id}"
+ |> Presence.list()
+ |> Enum.flat_map(fn {_key, %{metas: metas}} -> Enum.take(metas, 1) end)
+ |> Enum.sort_by(&{Map.get(&1, :joined_at, 0), Map.get(&1, :user_id, 0)})
+ |> Enum.map(
+ &%{
+ user_id: Map.get(&1, :user_id),
+ username: Map.get(&1, :username),
+ display_name: Map.get(&1, :display_name) || Map.get(&1, :username),
+ avatar: Map.get(&1, :avatar),
+ muted: Map.get(&1, :muted, false)
+ }
+ )
+ end
+
+ defp occupant_ids(conversation_id) do
+ conversation_id
+ |> occupants_for()
+ |> Enum.map(& &1.user_id)
+ end
+
+ defp find_conversation(socket, conversation_id) do
+ Enum.find(socket.assigns.conversation.list, &(&1.id == conversation_id))
+ end
+
+ defp reset_connection(voice_state) do
+ %{voice_state | joined_id: nil, joined_name: nil, muted: false}
+ end
+
+ defp voice_error_message("channel_full"), do: "This voice channel is full"
+ defp voice_error_message("already_joined"), do: "You're already connected in another tab"
+
+ defp voice_error_message("unauthorized"),
+ do: "You don't have access to this voice channel"
+
+ defp voice_error_message(reason) when is_binary(reason) and reason != "",
+ do: "Voice channel error: #{reason}"
+
+ defp voice_error_message(_reason), do: "Voice channel connection failed"
+
+ defp parse_positive_int(value) when is_integer(value) and value > 0, do: {:ok, value}
+
+ defp parse_positive_int(value) when is_binary(value) do
+ case Integer.parse(value) do
+ {parsed, ""} when parsed > 0 -> {:ok, parsed}
+ _ -> {:error, :invalid_id}
+ end
+ end
+
+ defp parse_positive_int(_value), do: {:error, :invalid_id}
+
+ defp user_token(socket) do
+ socket.assigns[:user_token] || Helpers.generate_user_token(socket.assigns.current_user.id)
+ end
+end
diff --git a/apps/arblarg/lib/arblarg_web/live/chat_live/router.ex b/apps/arblarg/lib/arblarg_web/live/chat_live/router.ex
new file mode 100644
index 0000000..5869f35
--- /dev/null
+++ b/apps/arblarg/lib/arblarg_web/live/chat_live/router.ex
@@ -0,0 +1,195 @@
+defmodule ArblargWeb.ChatLive.Router do
+ @moduledoc """
+ Routes handle_event calls to the appropriate operation module.
+ This module acts as a dispatcher to keep the main LiveView clean.
+ """
+
+ require Logger
+
+ alias ArblargWeb.ChatLive.Operations.{
+ CallOperations,
+ ContextMenuOperations,
+ ConversationOperations,
+ DirectMessageOperations,
+ EmojiGifOperations,
+ GroupChannelOperations,
+ MemberOperations,
+ MessageOperations,
+ ThreadOperations,
+ UIOperations,
+ VoiceChannelOperations
+ }
+
+ @event_handlers %{
+ "send_message" => MessageOperations,
+ "send_client_encrypted_message" => MessageOperations,
+ "register_chat_encryption_device" => MessageOperations,
+ "chat_e2ee_key" => MessageOperations,
+ "chat_typing" => MessageOperations,
+ "update_message" => MessageOperations,
+ "stop_typing" => MessageOperations,
+ "handle_keydown" => MessageOperations,
+ "validate_upload" => MessageOperations,
+ "cancel_upload" => MessageOperations,
+ "load_older_messages" => MessageOperations,
+ "load_newer_messages" => MessageOperations,
+ "scroll_to_newest" => MessageOperations,
+ "scroll_to_message" => MessageOperations,
+ "react_to_message" => MessageOperations,
+ "cancel_reply" => MessageOperations,
+ "reply_to_message" => MessageOperations,
+ "copy_message" => MessageOperations,
+ "delete_message" => MessageOperations,
+ "delete_message_admin" => MessageOperations,
+ "search_messages" => MessageOperations,
+ "pin_message" => MessageOperations,
+ "unpin_message" => MessageOperations,
+ "send_voice_message" => MessageOperations,
+ "voice_recording_error" => MessageOperations,
+ "toggle_thread_panel" => ThreadOperations,
+ "close_thread_panel" => ThreadOperations,
+ "back_to_thread_list" => ThreadOperations,
+ "toggle_archived_threads" => ThreadOperations,
+ "create_thread" => ThreadOperations,
+ "open_thread" => ThreadOperations,
+ "archive_thread" => ThreadOperations,
+ "unarchive_thread" => ThreadOperations,
+ "update_thread_composer" => ThreadOperations,
+ "send_thread_message" => ThreadOperations,
+ "select_conversation" => ConversationOperations,
+ "search_conversations" => ConversationOperations,
+ "clear_selection" => ConversationOperations,
+ "pin_conversation" => ConversationOperations,
+ "unpin_conversation" => ConversationOperations,
+ "mark_as_read" => ConversationOperations,
+ "clear_history" => ConversationOperations,
+ "show_settings" => ConversationOperations,
+ "hide_settings" => ConversationOperations,
+ "show_edit_conversation" => ConversationOperations,
+ "hide_edit_conversation" => ConversationOperations,
+ "update_conversation" => ConversationOperations,
+ "delete_conversation" => ConversationOperations,
+ "leave_conversation" => ConversationOperations,
+ "view_profile" => ConversationOperations,
+ "share_conversation" => ConversationOperations,
+ "show_conversation_info" => ConversationOperations,
+ "toggle_new_chat" => GroupChannelOperations,
+ "show_create_group" => GroupChannelOperations,
+ "show_create_server" => GroupChannelOperations,
+ "hide_create_server" => GroupChannelOperations,
+ "show_create_channel" => GroupChannelOperations,
+ "show_create_category" => GroupChannelOperations,
+ "hide_create_category" => GroupChannelOperations,
+ "create_channel_category" => GroupChannelOperations,
+ "toggle_category_collapse" => GroupChannelOperations,
+ "create_group" => GroupChannelOperations,
+ "create_server" => GroupChannelOperations,
+ "create_channel" => GroupChannelOperations,
+ "cancel_create" => GroupChannelOperations,
+ "toggle_user_selection" => GroupChannelOperations,
+ "show_browse_modal" => GroupChannelOperations,
+ "hide_browse_modal" => GroupChannelOperations,
+ "browse_tab" => GroupChannelOperations,
+ "browse_search" => GroupChannelOperations,
+ "join_conversation" => GroupChannelOperations,
+ "join_group" => GroupChannelOperations,
+ "join_channel" => GroupChannelOperations,
+ "join_server" => GroupChannelOperations,
+ "filter_server" => GroupChannelOperations,
+ "select_server" => GroupChannelOperations,
+ "clear_server_scope" => GroupChannelOperations,
+ "show_add_members" => MemberOperations,
+ "hide_add_members" => MemberOperations,
+ "add_member_to_conversation" => MemberOperations,
+ "approve_remote_join_request" => MemberOperations,
+ "decline_remote_join_request" => MemberOperations,
+ "kick_member" => MemberOperations,
+ "promote_member" => MemberOperations,
+ "demote_member" => MemberOperations,
+ "timeout_user" => MemberOperations,
+ "remove_timeout_user" => MemberOperations,
+ "kick_user" => MemberOperations,
+ "show_member_management" => MemberOperations,
+ "hide_member_management" => MemberOperations,
+ "show_moderation_log" => MemberOperations,
+ "hide_moderation_log" => MemberOperations,
+ "start_dm" => DirectMessageOperations,
+ "search_users" => DirectMessageOperations,
+ "block_user" => DirectMessageOperations,
+ "unblock_user" => DirectMessageOperations,
+ "show_user_profile" => DirectMessageOperations,
+ "hide_profile_modal" => DirectMessageOperations,
+ "initiate_call" => CallOperations,
+ "answer_call" => CallOperations,
+ "reject_call" => CallOperations,
+ "end_call" => CallOperations,
+ "toggle_audio" => CallOperations,
+ "toggle_video" => CallOperations,
+ "audio_toggled" => CallOperations,
+ "video_toggled" => CallOperations,
+ "call_error" => CallOperations,
+ "call_started" => CallOperations,
+ "remote_stream_ready" => CallOperations,
+ "call_answered" => CallOperations,
+ "call_connected" => CallOperations,
+ "call_ended" => CallOperations,
+ "call_ended_by_user" => CallOperations,
+ "call_rejected" => CallOperations,
+ "join_voice_channel" => VoiceChannelOperations,
+ "leave_voice_channel" => VoiceChannelOperations,
+ "toggle_voice_mute" => VoiceChannelOperations,
+ "voice_joined" => VoiceChannelOperations,
+ "voice_rejoined" => VoiceChannelOperations,
+ "voice_left" => VoiceChannelOperations,
+ "voice_mute_changed" => VoiceChannelOperations,
+ "voice_error" => VoiceChannelOperations,
+ "close_dropdown" => UIOperations,
+ "toggle_mobile_search" => UIOperations,
+ "close_chat_overlay" => UIOperations,
+ "stop_event" => UIOperations,
+ "navigate_to_origin" => UIOperations,
+ "navigate_to_embedded_post" => UIOperations,
+ "show_message_search" => UIOperations,
+ "hide_message_search" => UIOperations,
+ "toggle_pinned_panel" => UIOperations,
+ "ignore" => UIOperations,
+ "view_original_context" => UIOperations,
+ "show_report_modal" => UIOperations,
+ "close_report_modal" => UIOperations,
+ "open_image_modal" => UIOperations,
+ "close_image_modal" => UIOperations,
+ "next_image" => UIOperations,
+ "prev_image" => UIOperations,
+ "next_media_post" => UIOperations,
+ "prev_media_post" => UIOperations,
+ "show_context_menu" => ContextMenuOperations,
+ "hide_context_menu" => ContextMenuOperations,
+ "show_message_context_menu" => ContextMenuOperations,
+ "hide_message_context_menu" => ContextMenuOperations,
+ "toggle_emoji_picker" => EmojiGifOperations,
+ "insert_emoji" => EmojiGifOperations,
+ "emoji_search" => EmojiGifOperations,
+ "emoji_tab" => EmojiGifOperations
+ }
+
+ @doc false
+ def handler_for(event_name), do: Map.get(@event_handlers, event_name)
+
+ @doc false
+ def event_handlers, do: @event_handlers
+
+ @doc """
+ Routes an event to the appropriate operation module based on the event name.
+ Returns {:noreply, socket} tuple.
+ """
+ def route_event(event_name, params, socket) do
+ case handler_for(event_name) do
+ nil ->
+ Logger.warning("Unknown event in ChatLive.Index: #{event_name}")
+ {:noreply, socket}
+
+ handler_module ->
+ handler_module.handle_event(event_name, params, socket)
+ end
+ end
+end
diff --git a/apps/arblarg/lib/arblarg_web/live/chat_live/state.ex b/apps/arblarg/lib/arblarg_web/live/chat_live/state.ex
new file mode 100644
index 0000000..a6a287a
--- /dev/null
+++ b/apps/arblarg/lib/arblarg_web/live/chat_live/state.ex
@@ -0,0 +1,159 @@
+defmodule ArblargWeb.ChatLive.State do
+ @moduledoc """
+ State management structs for the Chat LiveView.
+
+ This module defines structs to group related assigns and reduce assign bloat.
+ """
+
+ defmodule UI do
+ @moduledoc """
+ UI state including modal visibility and picker states.
+ """
+ defstruct show_new_chat: false,
+ show_create_group: false,
+ show_create_channel: false,
+ show_group_modal: false,
+ show_channel_modal: false,
+ show_category_modal: false,
+ show_pinned_panel: false,
+ show_server_modal: false,
+ show_browse_channels: false,
+ show_settings_modal: false,
+ show_edit_modal: false,
+ show_add_members_modal: false,
+ show_message_search_modal: false,
+ show_emoji_picker: false,
+ show_profile_modal: false,
+ show_member_management: false,
+ show_moderation_log: false,
+ show_browse_modal: false,
+ show_incoming_call: false
+ end
+
+ defmodule Search do
+ @moduledoc """
+ Search-related state for users, messages, and emojis.
+ """
+ defstruct query: "",
+ results: [],
+ conversation_query: "",
+ message_query: "",
+ message_results: [],
+ browse_query: "",
+ user_results: [],
+ emoji_query: "",
+ emoji_tab: "Smileys"
+ end
+
+ defmodule Call do
+ @moduledoc """
+ Voice/video call state.
+ """
+ defstruct active_call: nil,
+ incoming_call: nil,
+ audio_enabled: true,
+ video_enabled: true,
+ status: "connecting"
+ end
+
+ defmodule Form do
+ @moduledoc """
+ Form input state for group/channel creation and editing.
+ """
+ defstruct group_name: "",
+ group_description: "",
+ group_is_public: false,
+ edit_name: "",
+ edit_description: "",
+ selected_users: []
+ end
+
+ defmodule ContextMenu do
+ @moduledoc """
+ Context menu state for conversations and messages.
+ """
+ defstruct conversation: nil,
+ message: nil,
+ selected_text: nil,
+ position: %{x: 0, y: 0}
+ end
+
+ defmodule Message do
+ @moduledoc """
+ Message composition and display state.
+ """
+ defstruct new_message: "",
+ reply_to: nil,
+ uploaded_files: [],
+ loading_messages: false,
+ typing_users: [],
+ read_status: %{}
+ end
+
+ defmodule Conversation do
+ @moduledoc """
+ Conversation list and selection state.
+ """
+ defstruct list: [],
+ selected: nil,
+ filtered: [],
+ last_message_read_status: %{},
+ unread_count: 0,
+ unread_counts: %{}
+ end
+
+ defmodule Threads do
+ @moduledoc """
+ Thread state for the selected conversation: the thread list, the thread
+ open in the right-hand panel (with its messages), and panel visibility.
+ """
+ defstruct list: [],
+ archived: [],
+ show_panel: false,
+ show_archived: false,
+ active: nil,
+ messages: [],
+ composer: ""
+ end
+
+ defmodule Voice do
+ @moduledoc """
+ Voice channel state: the channel the user is connected to, local mute
+ state, live occupant lists per voice channel (from presence), and the
+ set of voice presence topics this LiveView is subscribed to.
+ """
+ defstruct joined_id: nil,
+ joined_name: nil,
+ muted: false,
+ occupants: %{},
+ subscribed_ids: MapSet.new()
+ end
+
+ defmodule Moderation do
+ @moduledoc """
+ Content moderation state.
+ """
+ defstruct log: [],
+ user_timeout_status: %{}
+ end
+
+ defmodule Browse do
+ @moduledoc """
+ Browse servers/channels/groups state.
+ """
+ defstruct tab: "servers",
+ public_servers: [],
+ public_channels: [],
+ public_groups: [],
+ filtered_servers: [],
+ filtered_channels: [],
+ filtered_groups: []
+ end
+
+ defmodule Profile do
+ @moduledoc """
+ User profile modal state.
+ """
+ defstruct user: nil
+ end
+end
diff --git a/apps/arblarg/lib/arblarg_web/optional_module.ex b/apps/arblarg/lib/arblarg_web/optional_module.ex
new file mode 100644
index 0000000..306e885
--- /dev/null
+++ b/apps/arblarg/lib/arblarg_web/optional_module.ex
@@ -0,0 +1,14 @@
+defmodule ArblargWeb.OptionalModule do
+ @moduledoc false
+
+ alias Elektrine.Platform.Modules
+
+ def call(module_id, module, function, args, fallback) do
+ if Modules.compiled?(module_id) and Modules.enabled?(module_id) and
+ Code.ensure_loaded?(module) and function_exported?(module, function, length(args)) do
+ apply(module, function, args)
+ else
+ fallback
+ end
+ end
+end
diff --git a/apps/arblarg/mix.exs b/apps/arblarg/mix.exs
new file mode 100644
index 0000000..56d9505
--- /dev/null
+++ b/apps/arblarg/mix.exs
@@ -0,0 +1,52 @@
+defmodule Arblarg.MixProject do
+ use Mix.Project
+
+ def project do
+ [
+ app: :arblarg,
+ version: "0.1.0",
+ build_path: "../../_build",
+ config_path: "../../config/config.exs",
+ deps_path: "../../deps",
+ lockfile: "../../mix.lock",
+ elixir: "~> 1.19",
+ elixirc_paths: elixirc_paths(Mix.env()),
+ elixirc_options: elixirc_options(),
+ start_permanent: Mix.env() == :prod,
+ deps: deps()
+ ]
+ end
+
+ def application do
+ [
+ extra_applications: [:logger],
+ mod: {Arblarg.Application, []}
+ ]
+ end
+
+ defp elixirc_paths(:test), do: ["lib", "test/support"]
+ defp elixirc_paths(_), do: ["lib"]
+
+ defp elixirc_options do
+ if Mix.env() == :test, do: [ignore_module_conflict: true], else: []
+ end
+
+ defp deps do
+ [
+ internal_dep(:elektrine),
+ internal_dep(:elektrine_web),
+ {:phoenix, "== 1.8.9"},
+ {:phoenix_html, "== 4.3.0"},
+ {:phoenix_live_view, "== 1.1.30"},
+ {:jason, "== 1.4.5"}
+ ]
+ end
+
+ defp internal_dep(app) do
+ if Mix.Project.umbrella?() do
+ {app, in_umbrella: true}
+ else
+ {app, path: "../#{app}"}
+ end
+ end
+end
diff --git a/apps/arblarg/test/arblarg_test.exs b/apps/arblarg/test/arblarg_test.exs
new file mode 100644
index 0000000..21f7385
--- /dev/null
+++ b/apps/arblarg/test/arblarg_test.exs
@@ -0,0 +1,10 @@
+defmodule ArblargTest do
+ use ExUnit.Case
+
+ test "exposes chat facade functions" do
+ functions = Arblarg.__info__(:functions)
+
+ assert {:create_dm_conversation, 2} in functions
+ assert {:create_chat_text_message, 3} in functions
+ end
+end
diff --git a/apps/arblarg/test/arblarg_web/live/chat_live/operations/emoji_gif_operations_test.exs b/apps/arblarg/test/arblarg_web/live/chat_live/operations/emoji_gif_operations_test.exs
new file mode 100644
index 0000000..707b710
--- /dev/null
+++ b/apps/arblarg/test/arblarg_web/live/chat_live/operations/emoji_gif_operations_test.exs
@@ -0,0 +1,21 @@
+defmodule ArblargWeb.ChatLive.Operations.EmojiGifOperationsTest do
+ use ExUnit.Case, async: true
+
+ alias ArblargWeb.ChatLive.Operations.EmojiGifOperations
+ alias Phoenix.LiveView.Socket
+
+ test "emoji_search accepts keyup payloads with value" do
+ socket = %Socket{
+ assigns: %{__changed__: %{}, search: %{emoji_query: "", emoji_tab: "Smileys"}}
+ }
+
+ assert {:noreply, updated_socket} =
+ EmojiGifOperations.handle_event(
+ "emoji_search",
+ %{"key" => "y", "value" => "cry"},
+ socket
+ )
+
+ assert updated_socket.assigns.search.emoji_query == "cry"
+ end
+end
diff --git a/apps/arblarg/test/test_helper.exs b/apps/arblarg/test/test_helper.exs
new file mode 100644
index 0000000..869559e
--- /dev/null
+++ b/apps/arblarg/test/test_helper.exs
@@ -0,0 +1 @@
+ExUnit.start()
diff --git a/apps/atomine/lib/atomine.ex b/apps/atomine/lib/atomine.ex
new file mode 100644
index 0000000..0c3041b
--- /dev/null
+++ b/apps/atomine/lib/atomine.ex
@@ -0,0 +1,5 @@
+defmodule Atomine do
+ @moduledoc """
+ Boundary module for the Atomine personhood/anti-bot proof application.
+ """
+end
diff --git a/apps/atomine/lib/atomine/attestation.ex b/apps/atomine/lib/atomine/attestation.ex
new file mode 100644
index 0000000..ec6ec49
--- /dev/null
+++ b/apps/atomine/lib/atomine/attestation.ex
@@ -0,0 +1,80 @@
+defmodule Atomine.Attestation do
+ @moduledoc """
+ Portable anti-bot attestation issued by Atomine.
+
+ Attestations cover proof-of-effort receipts, passkey-bound continuity receipts,
+ and anonymous effort token MVPs. Domain/web identity claims remain represented by
+ `Atomine.Proof` because they are durable public discovery claims.
+ """
+
+ use Ecto.Schema
+ import Ecto.Changeset
+
+ @kinds ~w(pow_receipt anonymous_effort_token passkey_receipt)
+ @statuses ~w(issued redeemed expired revoked)
+
+ schema "atomine_attestations" do
+ field :public_id, :string
+ belongs_to :user, Elektrine.Accounts.User
+ belongs_to :passkey_credential, Elektrine.Accounts.PasskeyCredential
+ field :kind, :string
+ field :status, :string, default: "issued"
+ field :issuer, :string
+ field :subject, :string
+ field :subject_hash, :string
+ field :artifact_hash, :string
+ field :artifact, :string
+ field :difficulty, :integer
+ field :metadata, :map, default: %{}
+ field :issued_at, :utc_datetime
+ field :expires_at, :utc_datetime
+ field :redeemed_at, :utc_datetime
+
+ timestamps(type: :utc_datetime)
+ end
+
+ def kinds, do: @kinds
+ def statuses, do: @statuses
+
+ def changeset(attestation, attrs) do
+ attestation
+ |> cast(attrs, [
+ :public_id,
+ :user_id,
+ :passkey_credential_id,
+ :kind,
+ :status,
+ :issuer,
+ :subject,
+ :subject_hash,
+ :artifact_hash,
+ :artifact,
+ :difficulty,
+ :metadata,
+ :issued_at,
+ :expires_at,
+ :redeemed_at
+ ])
+ |> validate_required([
+ :public_id,
+ :kind,
+ :status,
+ :issuer,
+ :artifact_hash,
+ :artifact,
+ :issued_at,
+ :expires_at
+ ])
+ |> validate_inclusion(:kind, @kinds)
+ |> validate_inclusion(:status, @statuses)
+ |> validate_length(:public_id, max: 120)
+ |> validate_length(:issuer, max: 255)
+ |> validate_length(:subject_hash, max: 128)
+ |> validate_length(:artifact_hash, max: 128)
+ |> validate_number(:difficulty, greater_than_or_equal_to: 0, less_than_or_equal_to: 64)
+ |> unique_constraint(:public_id)
+ |> unique_constraint(:artifact_hash)
+ |> foreign_key_constraint(:user_id)
+ |> foreign_key_constraint(:passkey_credential_id)
+ end
+end
diff --git a/apps/atomine/lib/atomine/attestations.ex b/apps/atomine/lib/atomine/attestations.ex
new file mode 100644
index 0000000..73a305e
--- /dev/null
+++ b/apps/atomine/lib/atomine/attestations.ex
@@ -0,0 +1,656 @@
+defmodule Atomine.Attestations do
+ @moduledoc """
+ Portable anti-bot attestations.
+
+ This module intentionally separates three low-friction signals from durable
+ DNS/web identity claims:
+
+ * proof-of-work receipts: signed proof that local compute was spent
+ * passkey receipts: signed proof that an authenticated account has passkey continuity
+ * anonymous effort tokens: MVP bearer tokens that can later be replaced by blind tokens
+ """
+
+ import Ecto.Query, warn: false
+
+ alias Atomine.Attestation
+ alias Elektrine.Accounts.PasskeyCredential
+ alias Elektrine.Accounts.User
+ alias Elektrine.Repo
+
+ @pow_challenge_prefix "Atomine pow challenge v1"
+ @receipt_prefix "Atomine receipt v1"
+ @anonymous_token_prefix "Atomine anonymous effort token v1"
+ @default_pow_difficulty 18
+ @default_pow_ttl_seconds 300
+ @default_receipt_ttl_seconds 7 * 24 * 60 * 60
+ @default_token_ttl_seconds 24 * 60 * 60
+ @gate_version "atomine-gate-v1"
+ @gate_layers ~w(pow browser_instrumentation)
+ @browser_instrumentation_checks ~w(
+ layout.getComputedStyle
+ canvas.toDataURL
+ event.isTrusted
+ navigator.webdriver
+ dom.querySelector
+ )
+
+ @doc "Returns public issuer metadata for external verifiers."
+ def issuer_metadata(endpoint_base \\ nil) do
+ endpoint_base = normalize_endpoint_base(endpoint_base)
+
+ %{
+ issuer: issuer(),
+ protocol: "atomine-attestations",
+ version: "v1",
+ signing_alg: "hmac-sha256-dev",
+ artifacts: ["pow_receipt", "gate_proof", "anonymous_effort_token", "passkey_receipt"],
+ privacy: %{
+ gate_proof: "challenge-bound layer results stored as summarized metadata",
+ anonymous_effort_token: "bearer-token-mvp",
+ blind_tokens: "planned"
+ },
+ endpoints: %{
+ pow_challenge: endpoint(endpoint_base, "/api/atomine/pow/challenge"),
+ pow_receipts: endpoint(endpoint_base, "/api/atomine/pow/receipts"),
+ anonymous_tokens: endpoint(endpoint_base, "/api/atomine/anonymous-tokens"),
+ spend_anonymous_token: endpoint(endpoint_base, "/api/atomine/anonymous-tokens/spend"),
+ verify_artifact: endpoint(endpoint_base, "/api/atomine/artifacts/verify")
+ }
+ }
+ end
+
+ @doc "Issues a stateless Atomine proof challenge."
+ def issue_pow_challenge(opts \\ []) do
+ now = now()
+ difficulty = public_pow_difficulty(Keyword.get(opts, :difficulty))
+ ttl_seconds = Keyword.get(opts, :ttl_seconds, @default_pow_ttl_seconds)
+
+ payload = %{
+ "kind" => "pow_challenge",
+ "issuer" => issuer(),
+ "difficulty" => difficulty,
+ "nonce" => random_token(18),
+ "issued_at" => encode_time(now),
+ "expires_at" => encode_time(DateTime.add(now, ttl_seconds, :second))
+ }
+
+ {:ok,
+ %{
+ "challenge" => sign_statement(@pow_challenge_prefix, payload),
+ "difficulty" => difficulty,
+ "expires_at" => payload["expires_at"],
+ "gate" => %{
+ "version" => @gate_version,
+ "layers" => @gate_layers,
+ "browser_instrumentation" => %{
+ "required_checks" => @browser_instrumentation_checks
+ }
+ }
+ }}
+ end
+
+ @doc "Verifies a PoW solution and issues a portable signed receipt."
+ def issue_pow_receipt(attrs) when is_map(attrs) do
+ with {:ok, challenge_payload} <-
+ parse_statement(Map.get(attrs, "challenge"), @pow_challenge_prefix),
+ :ok <- ensure_not_expired(challenge_payload),
+ :ok <- ensure_minimum_difficulty(challenge_payload["difficulty"]),
+ solution when is_binary(solution) and solution != "" <- Map.get(attrs, "solution"),
+ true <-
+ valid_pow_solution?(
+ Map.get(attrs, "challenge"),
+ solution,
+ challenge_payload["difficulty"]
+ ) do
+ challenge_hash = artifact_hash(Map.get(attrs, "challenge"))
+
+ use_challenge_once(challenge_hash, fn ->
+ subject = Map.get(attrs, "subject")
+
+ issue_receipt(
+ "pow_receipt",
+ subject,
+ challenge_payload["difficulty"],
+ %{
+ "challenge_hash" => challenge_hash,
+ "solution_hash" => artifact_hash(solution)
+ },
+ user_id: Map.get(attrs, :user_id)
+ )
+ end)
+ else
+ false -> {:error, :invalid_pow_solution}
+ nil -> {:error, :missing_pow_solution}
+ {:error, reason} -> {:error, reason}
+ _ -> {:error, :invalid_pow_solution}
+ end
+ end
+
+ @doc "Issues a bearer anonymous effort token after a valid PoW solution."
+ def issue_anonymous_effort_token(attrs) when is_map(attrs) do
+ with {:ok, challenge_payload} <-
+ parse_statement(Map.get(attrs, "challenge"), @pow_challenge_prefix),
+ :ok <- ensure_not_expired(challenge_payload),
+ :ok <- ensure_minimum_difficulty(challenge_payload["difficulty"]),
+ solution when is_binary(solution) and solution != "" <- Map.get(attrs, "solution"),
+ true <-
+ valid_pow_solution?(
+ Map.get(attrs, "challenge"),
+ solution,
+ challenge_payload["difficulty"]
+ ),
+ :ok <- validate_gate_proof(Map.get(attrs, "gate_proof"), Map.get(attrs, "challenge")) do
+ challenge_hash = artifact_hash(Map.get(attrs, "challenge"))
+
+ use_challenge_once(challenge_hash, fn ->
+ now = now()
+ public_id = public_id("aet")
+
+ payload = %{
+ "id" => public_id,
+ "kind" => "anonymous_effort_token",
+ "issuer" => issuer(),
+ "difficulty" => challenge_payload["difficulty"],
+ "issued_at" => encode_time(now),
+ "expires_at" => encode_time(DateTime.add(now, @default_token_ttl_seconds, :second)),
+ "nonce" => random_token(24)
+ }
+
+ token = sign_statement(@anonymous_token_prefix, payload)
+
+ insert_attestation(%{
+ public_id: public_id,
+ kind: "anonymous_effort_token",
+ status: "issued",
+ issuer: issuer(),
+ artifact: token,
+ artifact_hash: artifact_hash(token),
+ difficulty: challenge_payload["difficulty"],
+ issued_at: now,
+ expires_at: decode_time!(payload["expires_at"]),
+ metadata:
+ %{"challenge_hash" => challenge_hash}
+ |> maybe_put_gate_proof_metadata(Map.get(attrs, "gate_proof"))
+ })
+ end)
+ else
+ false -> {:error, :invalid_pow_solution}
+ nil -> {:error, :missing_pow_solution}
+ {:error, reason} -> {:error, reason}
+ _ -> {:error, :invalid_pow_solution}
+ end
+ end
+
+ @doc "Redeems an anonymous effort token once."
+ def redeem_anonymous_effort_token(token, attrs \\ %{})
+
+ def redeem_anonymous_effort_token(token, attrs) when is_binary(token) and is_map(attrs) do
+ with {:ok, payload} <- parse_statement(token, @anonymous_token_prefix),
+ :ok <- ensure_not_expired(payload),
+ :ok <- validate_token_spend_attrs(attrs),
+ %Attestation{} <- Repo.get_by(Attestation, artifact_hash: artifact_hash(token)) do
+ Repo.transaction(fn ->
+ attestation =
+ Attestation
+ |> where([a], a.artifact_hash == ^artifact_hash(token))
+ |> lock("FOR UPDATE")
+ |> Repo.one!()
+
+ cond do
+ attestation.status == "redeemed" ->
+ Repo.rollback(:already_redeemed)
+
+ DateTime.compare(attestation.expires_at, now()) == :lt ->
+ update_status(attestation, "expired")
+ Repo.rollback(:expired)
+
+ true ->
+ case attestation
+ |> Attestation.changeset(%{
+ status: "redeemed",
+ redeemed_at: now(),
+ metadata: spend_metadata(attestation.metadata, attrs, payload)
+ })
+ |> Repo.update() do
+ {:ok, updated} -> updated
+ {:error, changeset} -> Repo.rollback(changeset)
+ end
+ end
+ end)
+ |> case do
+ {:ok, result} -> {:ok, result}
+ {:error, reason} -> {:error, reason}
+ end
+ else
+ nil -> {:error, :unknown_token}
+ {:error, reason} -> {:error, reason}
+ end
+ end
+
+ def redeem_anonymous_effort_token(_, _), do: {:error, :invalid_token}
+
+ @doc "Issues a passkey-bound continuity receipt for an authenticated user."
+ def issue_passkey_receipt(%User{id: user_id}, passkey_credential_id) do
+ with {id, ""} <- Integer.parse(to_string(passkey_credential_id)),
+ %PasskeyCredential{user_id: ^user_id} = credential <- Repo.get(PasskeyCredential, id) do
+ subject = "passkey:#{credential_fingerprint(credential)}"
+
+ issue_receipt(
+ "passkey_receipt",
+ subject,
+ nil,
+ %{
+ "passkey_credential_id" => credential.id,
+ "passkey_name" => credential.name,
+ "passkey_fingerprint" => credential_fingerprint(credential)
+ },
+ user_id: user_id,
+ passkey_credential_id: credential.id
+ )
+ else
+ nil -> {:error, :passkey_not_found}
+ :error -> {:error, :invalid_passkey_id}
+ _ -> {:error, :passkey_not_found}
+ end
+ end
+
+ def issue_passkey_receipt(_, _), do: {:error, :invalid_user}
+
+ @doc "Verifies any Atomine receipt/token statement signature and expiry."
+ def verify_artifact(artifact) when is_binary(artifact) do
+ cond do
+ String.starts_with?(artifact, @receipt_prefix) ->
+ verify_signed_artifact(artifact, @receipt_prefix)
+
+ String.starts_with?(artifact, @anonymous_token_prefix) ->
+ verify_signed_artifact(artifact, @anonymous_token_prefix)
+
+ true ->
+ {:error, :unsupported_artifact}
+ end
+ end
+
+ def verify_artifact(_), do: {:error, :invalid_artifact}
+
+ defp verify_signed_artifact(artifact, prefix) do
+ with {:ok, payload} <- parse_statement(artifact, prefix),
+ :ok <- ensure_not_expired(payload) do
+ persisted = Repo.get_by(Attestation, artifact_hash: artifact_hash(artifact))
+
+ {:ok,
+ %{
+ payload: payload,
+ persisted: !is_nil(persisted),
+ status: persisted && persisted.status
+ }}
+ end
+ end
+
+ defp issue_receipt(kind, subject, difficulty, metadata, opts) do
+ now = now()
+ public_id = public_id("rcpt")
+ expires_at = DateTime.add(now, @default_receipt_ttl_seconds, :second)
+
+ payload = %{
+ "id" => public_id,
+ "kind" => kind,
+ "issuer" => issuer(),
+ "subject_hash" => subject_hash(subject),
+ "difficulty" => difficulty,
+ "issued_at" => encode_time(now),
+ "expires_at" => encode_time(expires_at),
+ "nonce" => random_token(24)
+ }
+
+ receipt = sign_statement(@receipt_prefix, payload)
+
+ insert_attestation(%{
+ public_id: public_id,
+ user_id: Keyword.get(opts, :user_id),
+ passkey_credential_id: Keyword.get(opts, :passkey_credential_id),
+ kind: kind,
+ status: "issued",
+ issuer: issuer(),
+ subject: subject,
+ subject_hash: subject_hash(subject),
+ artifact: receipt,
+ artifact_hash: artifact_hash(receipt),
+ difficulty: difficulty,
+ issued_at: now,
+ expires_at: expires_at,
+ metadata: metadata
+ })
+ end
+
+ defp insert_attestation(attrs) do
+ %Attestation{}
+ |> Attestation.changeset(attrs)
+ |> Repo.insert()
+ end
+
+ defp use_challenge_once(challenge_hash, fun)
+ when is_binary(challenge_hash) and is_function(fun, 0) do
+ Repo.transaction(fn ->
+ Repo.query!("SELECT pg_advisory_xact_lock(hashtext($1))", [challenge_hash])
+
+ if challenge_used?(challenge_hash) do
+ Repo.rollback(:challenge_already_used)
+ else
+ case fun.() do
+ {:ok, attestation} -> attestation
+ {:error, reason} -> Repo.rollback(reason)
+ end
+ end
+ end)
+ |> case do
+ {:ok, attestation} -> {:ok, attestation}
+ {:error, :challenge_already_used} -> {:error, :challenge_already_used}
+ {:error, reason} -> {:error, reason}
+ end
+ end
+
+ defp challenge_used?(challenge_hash) do
+ Attestation
+ |> where([a], a.kind in ["pow_receipt", "anonymous_effort_token"])
+ |> where([a], fragment("?->>?", a.metadata, "challenge_hash") == ^challenge_hash)
+ |> Repo.exists?()
+ end
+
+ defp update_status(attestation, status) do
+ attestation
+ |> Attestation.changeset(%{status: status})
+ |> Repo.update()
+ end
+
+ defp validate_token_spend_attrs(attrs) do
+ audience = Map.get(attrs, "audience")
+ nonce = Map.get(attrs, "nonce")
+
+ cond do
+ not is_nil(audience) and not valid_spend_field?(audience, 500) ->
+ {:error, :invalid_audience}
+
+ not is_nil(nonce) and not valid_spend_field?(nonce, 200) ->
+ {:error, :invalid_nonce}
+
+ true ->
+ :ok
+ end
+ end
+
+ defp valid_spend_field?(value, max_length) when is_binary(value) do
+ value = String.trim(value)
+ value != "" and String.length(value) <= max_length
+ end
+
+ defp valid_spend_field?(_value, _max_length), do: false
+
+ defp spend_metadata(metadata, attrs, payload) do
+ spend =
+ %{
+ "token_id" => payload["id"],
+ "spent_at" => encode_time(now())
+ }
+ |> maybe_put_trimmed("audience", Map.get(attrs, "audience"))
+ |> maybe_put_trimmed("nonce", Map.get(attrs, "nonce"))
+
+ metadata
+ |> ensure_map()
+ |> Map.put("spend", spend)
+ end
+
+ defp maybe_put_trimmed(map, key, value) when is_binary(value) do
+ case String.trim(value) do
+ "" -> map
+ trimmed -> Map.put(map, key, trimmed)
+ end
+ end
+
+ defp maybe_put_trimmed(map, _key, _value), do: map
+
+ defp ensure_map(value) when is_map(value), do: value
+ defp ensure_map(_value), do: %{}
+
+ defp validate_gate_proof(nil, _challenge), do: {:error, :missing_gate_proof}
+
+ defp validate_gate_proof(proof, challenge) when is_map(proof) and is_binary(challenge) do
+ browser_layer = Map.get(proof, "browser_instrumentation")
+ checks = if is_map(browser_layer), do: Map.get(browser_layer, "checks"), else: nil
+ check_names = gate_check_names(checks)
+
+ cond do
+ Map.get(proof, "version") != @gate_version ->
+ {:error, :invalid_gate_proof}
+
+ not Enum.all?(@gate_layers, &(&1 in Map.get(proof, "layers", []))) ->
+ {:error, :invalid_gate_proof}
+
+ not is_map(browser_layer) ->
+ {:error, :invalid_gate_proof}
+
+ Map.get(browser_layer, "challenge_hash") != artifact_hash(challenge) ->
+ {:error, :invalid_gate_proof}
+
+ not is_list(checks) ->
+ {:error, :invalid_gate_proof}
+
+ not Enum.all?(@browser_instrumentation_checks, &(&1 in check_names)) ->
+ {:error, :invalid_gate_proof}
+
+ not Enum.all?(checks, &gate_check_ok?/1) ->
+ {:error, :invalid_gate_proof}
+
+ true ->
+ :ok
+ end
+ end
+
+ defp validate_gate_proof(_proof, _challenge), do: {:error, :invalid_gate_proof}
+
+ defp gate_check_names(checks) when is_list(checks) do
+ checks
+ |> Enum.map(fn
+ check when is_map(check) -> Map.get(check, "name")
+ _ -> nil
+ end)
+ |> Enum.reject(&is_nil/1)
+ end
+
+ defp gate_check_names(_checks), do: []
+
+ defp gate_check_ok?(%{"ok" => true, "duration_ms" => duration})
+ when is_integer(duration) and duration >= 0,
+ do: true
+
+ defp gate_check_ok?(_check), do: false
+
+ defp maybe_put_gate_proof_metadata(metadata, proof) when is_map(proof) do
+ browser_layer = Map.get(proof, "browser_instrumentation", %{})
+ checks = Map.get(browser_layer, "checks", [])
+
+ Map.put(metadata, "gate_proof", %{
+ "version" => Map.get(proof, "version"),
+ "layers" => Map.get(proof, "layers", []),
+ "browser_instrumentation" => %{
+ "challenge_hash" => Map.get(browser_layer, "challenge_hash"),
+ "checks" => Enum.map(checks, &gate_check_summary/1),
+ "signals" => Map.get(browser_layer, "signals", %{})
+ }
+ })
+ end
+
+ defp maybe_put_gate_proof_metadata(metadata, _proof), do: metadata
+
+ defp gate_check_summary(check) when is_map(check) do
+ %{
+ "name" => Map.get(check, "name"),
+ "ok" => Map.get(check, "ok"),
+ "duration_ms" => Map.get(check, "duration_ms")
+ }
+ end
+
+ defp gate_check_summary(_check), do: %{}
+
+ defp valid_pow_solution?(challenge, solution, difficulty) do
+ bits = normalize_difficulty(difficulty)
+
+ digest = :crypto.hash(:sha256, challenge <> ":" <> solution)
+ leading_zero_bits(digest) >= bits
+ end
+
+ defp leading_zero_bits(binary), do: leading_zero_bits(binary, 0)
+
+ defp leading_zero_bits(<<0, rest::binary>>, acc), do: leading_zero_bits(rest, acc + 8)
+
+ defp leading_zero_bits(<>, acc) do
+ acc + leading_zero_bits_in_byte(byte)
+ end
+
+ defp leading_zero_bits(<<>>, acc), do: acc
+
+ defp leading_zero_bits_in_byte(byte) do
+ Enum.find_value(7..0//-1, 8, fn shift ->
+ if Bitwise.band(byte, Bitwise.bsl(1, shift)) != 0, do: 7 - shift
+ end)
+ end
+
+ defp sign_statement(prefix, payload) do
+ encoded_payload = payload |> Jason.encode!() |> Base.url_encode64(padding: false)
+ signature = sign(encoded_payload)
+ prefix <> " payload=" <> encoded_payload <> " sig=" <> signature
+ end
+
+ defp parse_statement(statement, prefix) when is_binary(statement) do
+ with true <- String.starts_with?(statement, prefix <> " "),
+ %{"payload" => encoded_payload, "sig" => signature} <- parse_fields(statement, prefix),
+ true <- secure_compare(sign(encoded_payload), signature),
+ {:ok, json} <- Base.url_decode64(encoded_payload, padding: false),
+ {:ok, payload} <- Jason.decode(json) do
+ {:ok, payload}
+ else
+ false -> {:error, :invalid_signature}
+ :error -> {:error, :invalid_payload}
+ {:error, reason} -> {:error, reason}
+ _ -> {:error, :invalid_statement}
+ end
+ end
+
+ defp parse_statement(_, _), do: {:error, :invalid_statement}
+
+ defp parse_fields(statement, prefix) do
+ statement
+ |> String.replace_prefix(prefix <> " ", "")
+ |> String.split(" ", trim: true)
+ |> Map.new(fn field ->
+ case String.split(field, "=", parts: 2) do
+ [key, value] -> {key, value}
+ [key] -> {key, ""}
+ end
+ end)
+ end
+
+ defp ensure_not_expired(%{"expires_at" => expires_at}) do
+ case DateTime.from_iso8601(expires_at) do
+ {:ok, expires_at, _} ->
+ if DateTime.compare(expires_at, now()) == :lt, do: {:error, :expired}, else: :ok
+
+ _ ->
+ {:error, :invalid_expiry}
+ end
+ end
+
+ defp ensure_not_expired(_), do: {:error, :missing_expiry}
+
+ defp decode_time!(value) do
+ {:ok, time, _} = DateTime.from_iso8601(value)
+ time
+ end
+
+ defp encode_time(%DateTime{} = time), do: DateTime.to_iso8601(time)
+
+ defp ensure_minimum_difficulty(value) do
+ if normalize_difficulty(value) >= configured_pow_difficulty() do
+ :ok
+ else
+ {:error, :pow_difficulty_too_low}
+ end
+ end
+
+ defp public_pow_difficulty(requested) do
+ requested
+ |> normalize_difficulty()
+ |> max(configured_pow_difficulty())
+ end
+
+ defp configured_pow_difficulty do
+ :elektrine
+ |> Application.get_env(:atomine_pow, [])
+ |> Keyword.get(:difficulty, @default_pow_difficulty)
+ |> normalize_difficulty()
+ end
+
+ defp normalize_difficulty(value) when is_integer(value), do: value |> max(0) |> min(64)
+
+ defp normalize_difficulty(value) when is_binary(value) do
+ case Integer.parse(value) do
+ {parsed, _} -> normalize_difficulty(parsed)
+ :error -> @default_pow_difficulty
+ end
+ end
+
+ defp normalize_difficulty(_), do: @default_pow_difficulty
+
+ defp normalize_endpoint_base(nil), do: nil
+
+ defp normalize_endpoint_base(""), do: nil
+ defp normalize_endpoint_base(value) when is_binary(value), do: String.trim_trailing(value, "/")
+ defp normalize_endpoint_base(_), do: nil
+
+ defp endpoint(nil, path), do: path
+ defp endpoint(base, path), do: base <> path
+
+ defp public_id(prefix), do: prefix <> "_" <> random_token(18)
+
+ defp random_token(bytes),
+ do: bytes |> :crypto.strong_rand_bytes() |> Base.url_encode64(padding: false)
+
+ defp subject_hash(nil), do: nil
+ defp subject_hash(""), do: nil
+ defp subject_hash(subject), do: artifact_hash("subject:" <> to_string(subject))
+
+ defp artifact_hash(value) do
+ :crypto.hash(:sha256, to_string(value))
+ |> Base.url_encode64(padding: false)
+ end
+
+ defp credential_fingerprint(%PasskeyCredential{credential_id: credential_id}) do
+ credential_id
+ |> Base.url_encode64(padding: false)
+ |> artifact_hash()
+ end
+
+ defp sign(payload),
+ do:
+ :crypto.mac(:hmac, :sha256, signing_secret(), payload) |> Base.url_encode64(padding: false)
+
+ defp signing_secret do
+ System.get_env("ATOMINE_ATTESTATION_SECRET") || Elektrine.RuntimeSecrets.secret_key_base() ||
+ if Elektrine.RuntimeEnv.dev_or_test?() do
+ "atomine-dev-attestation-secret"
+ else
+ raise "attestation signing secret is not configured"
+ end
+ end
+
+ defp issuer do
+ System.get_env("ATOMINE_ISSUER") || Elektrine.ActivityPub.instance_url() || "atomine.local"
+ end
+
+ defp secure_compare(left, right) when is_binary(left) and is_binary(right) do
+ byte_size(left) == byte_size(right) and Plug.Crypto.secure_compare(left, right)
+ end
+
+ defp secure_compare(_, _), do: false
+
+ defp now, do: DateTime.utc_now() |> DateTime.truncate(:second)
+end
diff --git a/apps/atomine/lib/atomine/credit_account.ex b/apps/atomine/lib/atomine/credit_account.ex
new file mode 100644
index 0000000..8ef637a
--- /dev/null
+++ b/apps/atomine/lib/atomine/credit_account.ex
@@ -0,0 +1,40 @@
+defmodule Atomine.CreditAccount do
+ @moduledoc "Per-user Atomine Credit balance."
+
+ use Ecto.Schema
+ import Ecto.Changeset
+
+ @credit_types ~w(atomine_credit)
+
+ schema "atomine_credit_accounts" do
+ belongs_to :user, Elektrine.Accounts.User
+ field :credit_type, :string
+ field :balance, :integer, default: 0
+ field :lifetime_earned, :integer, default: 0
+ field :lifetime_spent, :integer, default: 0
+ field :metadata, :map, default: %{}
+
+ timestamps(type: :utc_datetime)
+ end
+
+ def credit_types, do: @credit_types
+
+ def changeset(account, attrs) do
+ account
+ |> cast(attrs, [
+ :user_id,
+ :credit_type,
+ :balance,
+ :lifetime_earned,
+ :lifetime_spent,
+ :metadata
+ ])
+ |> validate_required([:user_id, :credit_type, :balance, :lifetime_earned, :lifetime_spent])
+ |> validate_inclusion(:credit_type, @credit_types)
+ |> validate_number(:balance, greater_than_or_equal_to: 0)
+ |> validate_number(:lifetime_earned, greater_than_or_equal_to: 0)
+ |> validate_number(:lifetime_spent, greater_than_or_equal_to: 0)
+ |> unique_constraint([:user_id, :credit_type])
+ |> foreign_key_constraint(:user_id)
+ end
+end
diff --git a/apps/atomine/lib/atomine/credit_earning_policy.ex b/apps/atomine/lib/atomine/credit_earning_policy.ex
new file mode 100644
index 0000000..d0aa97c
--- /dev/null
+++ b/apps/atomine/lib/atomine/credit_earning_policy.ex
@@ -0,0 +1,344 @@
+defmodule Atomine.CreditEarningPolicy do
+ @moduledoc "Policy for converting anti-abuse proof systems into Atomine Credit grants."
+
+ import Ecto.Query, warn: false
+
+ alias Atomine.{CreditLedgerEntry, Credits, Proof}
+ alias Elektrine.Accounts.User
+ alias Elektrine.Repo
+
+ @pow_daily_grant 1
+ @pow_daily_claim_limit 20
+ @credit_earning_actions ~w(verified_proof proof_of_work)
+
+ @daily_earning_caps %{
+ 0 => 20,
+ 1 => 50,
+ 2 => 100,
+ 3 => 250
+ }
+
+ @balance_caps %{
+ 0 => 25,
+ 1 => 100,
+ 2 => 250,
+ 3 => 1_000
+ }
+
+ @proof_grants %{
+ "dns" => 10,
+ "web" => 8,
+ "social" => 5,
+ "passkey" => 5,
+ "payment" => 15,
+ "vouch" => 10,
+ "manual" => 10
+ }
+
+ @doc "Returns the proof-of-X earning systems for product/UI discovery."
+ def earning_paths do
+ [
+ %{
+ key: "proof_of_personhood",
+ label: "Proof of personhood/control",
+ status: :active,
+ summary: "Verify DNS, web, social, GitHub, passkey, payment, or reviewed proof signals.",
+ reward: "5-15 Identity Credits per verified proof, once per proof."
+ },
+ %{
+ key: "proof_of_stake",
+ label: "Proof of stake",
+ status: :planned,
+ summary: "Lock stake for higher action capacity; abusive use can be slashed.",
+ reward: "Planned."
+ },
+ %{
+ key: "proof_of_work",
+ label: "Proof of work",
+ status: :active,
+ summary: "Spend computational or delivery-cost work to earn small temporary capacity.",
+ reward:
+ "#{@pow_daily_grant} Identity Credit per run, up to #{@pow_daily_claim_limit} per day."
+ },
+ %{
+ key: "proof_of_reputation",
+ label: "Proof of reputation",
+ status: :planned,
+ summary: "Clean account age, low reports, accepted messages, and good delivery history.",
+ reward: "Planned."
+ },
+ %{
+ key: "proof_of_service",
+ label: "Proof of service",
+ status: :planned,
+ summary: "Contribute useful network, moderation, or support work.",
+ reward: "Planned."
+ }
+ ]
+ end
+
+ @doc "Returns the one-time Atomine Credit grant for a verified proof kind."
+ def verified_proof_grant_amount(kind), do: Map.get(@proof_grants, to_string(kind), 0)
+
+ @doc "Returns the daily Atomine Credit grant for a proof-of-work claim."
+ def proof_of_work_grant_amount, do: @pow_daily_grant
+
+ @doc "Returns the maximum proof-of-work credit claims per day."
+ def proof_of_work_daily_claim_limit, do: @pow_daily_claim_limit
+
+ @doc "Grants Atomine Credits once when an eligible proof is verified."
+ def grant_for_verified_proof(%Proof{} = proof) do
+ amount = verified_proof_grant_amount(proof.kind)
+
+ cond do
+ proof.claim_type != "positive" ->
+ {:ok, :not_eligible}
+
+ proof.status != "verified" ->
+ {:ok, :not_verified}
+
+ amount <= 0 ->
+ {:ok, :not_eligible}
+
+ true ->
+ canonical_key = canonical_proof_key(proof)
+ reference_id = safe_reference_id(canonical_key)
+
+ grant_once_per_claim(
+ proof.user_id,
+ amount,
+ "verified_proof:#{proof.kind}",
+ action: "verified_proof",
+ reference_type: "atomine_proof_claim",
+ reference_id: reference_id,
+ metadata: %{
+ "proof_kind" => proof.kind,
+ "proof_subject" => proof.subject,
+ "proof_method" => proof.verification_method,
+ "proof_id" => proof.id,
+ "canonical_key" => canonical_key
+ }
+ )
+ end
+ end
+
+ @doc "Grants one proof-of-work Atomine Credit, capped per day."
+ def grant_for_proof_of_work(user_id, opts \\ []) when is_integer(user_id) do
+ date = Keyword.get(opts, :date, Date.utc_today())
+ claims_today = proof_of_work_claims_today(user_id, date)
+
+ if claims_today >= @pow_daily_claim_limit do
+ {:ok, :daily_claim_limit_reached}
+ else
+ claim_number = claims_today + 1
+
+ grant_once_per_claim(
+ user_id,
+ @pow_daily_grant,
+ "proof_of_work",
+ action: "proof_of_work",
+ date: date,
+ reference_type: "atomine_pow_claim",
+ reference_id: "#{user_id}:#{Date.to_iso8601(date)}:#{claim_number}",
+ metadata: %{
+ "date" => Date.to_iso8601(date),
+ "difficulty" => Keyword.get(opts, :difficulty),
+ "claim_number" => claim_number,
+ "daily_claim_limit" => @pow_daily_claim_limit
+ }
+ )
+ end
+ end
+
+ defp grant_once_per_claim(user_id, amount, reason, opts) do
+ reference_type = Keyword.fetch!(opts, :reference_type)
+ reference_id = Keyword.fetch!(opts, :reference_id)
+
+ cond do
+ existing_user_grant?(user_id, reason, reference_type, reference_id) ->
+ {:ok, :already_granted}
+
+ existing_global_proof_claim?(reference_type, reference_id) ->
+ {:ok, :already_rewarded}
+
+ true ->
+ case check_earning_caps(user_id, amount, Keyword.get(opts, :date, Date.utc_today())) do
+ :ok ->
+ Credits.grant_once(
+ user_id,
+ :atomine_credit,
+ amount,
+ reason,
+ opts
+ )
+
+ {:error, reason} ->
+ {:ok, reason}
+ end
+ end
+ end
+
+ defp existing_user_grant?(user_id, reason, reference_type, reference_id) do
+ CreditLedgerEntry
+ |> where([e], e.user_id == ^user_id)
+ |> where([e], e.credit_type == "atomine_credit")
+ |> where([e], e.reason == ^reason)
+ |> where([e], e.reference_type == ^reference_type)
+ |> where([e], e.reference_id == ^to_string(reference_id))
+ |> where([e], e.amount > 0)
+ |> Repo.exists?()
+ end
+
+ defp existing_global_proof_claim?("atomine_proof_claim", reference_id) do
+ CreditLedgerEntry
+ |> where([e], e.credit_type == "atomine_credit")
+ |> where([e], e.reference_type == "atomine_proof_claim")
+ |> where([e], e.reference_id == ^to_string(reference_id))
+ |> where([e], e.amount > 0)
+ |> Repo.exists?()
+ end
+
+ defp existing_global_proof_claim?(_reference_type, _reference_id), do: false
+
+ defp check_earning_caps(user_id, amount, date) do
+ %{daily: daily_cap, balance: balance_cap} = credit_caps(user_id)
+
+ cond do
+ cap_exceeded?(daily_cap, earned_today(user_id, date), amount) ->
+ {:error, :daily_earning_cap_reached}
+
+ cap_exceeded?(balance_cap, Credits.balance(user_id, :atomine_credit), amount) ->
+ {:error, :balance_cap_reached}
+
+ true ->
+ :ok
+ end
+ end
+
+ defp cap_exceeded?(nil, _current, _amount), do: false
+ defp cap_exceeded?(cap, current, amount), do: current + amount > cap
+
+ defp earned_today(user_id, date) do
+ {start_at, end_at} = day_bounds(date)
+
+ CreditLedgerEntry
+ |> where([e], e.user_id == ^user_id)
+ |> where([e], e.credit_type == "atomine_credit")
+ |> where([e], e.amount > 0)
+ |> where([e], e.action in ^@credit_earning_actions)
+ |> where([e], e.inserted_at >= ^start_at and e.inserted_at < ^end_at)
+ |> select([e], coalesce(sum(e.amount), 0))
+ |> Repo.one()
+ end
+
+ defp proof_of_work_claims_today(user_id, date) do
+ {start_at, end_at} = day_bounds(date)
+
+ CreditLedgerEntry
+ |> where([e], e.user_id == ^user_id)
+ |> where([e], e.credit_type == "atomine_credit")
+ |> where([e], e.reason == "proof_of_work")
+ |> where([e], e.reference_type == "atomine_pow_claim")
+ |> where([e], e.amount > 0)
+ |> where([e], e.inserted_at >= ^start_at and e.inserted_at < ^end_at)
+ |> select([e], count(e.id))
+ |> Repo.one()
+ end
+
+ defp day_bounds(date) do
+ start_at = DateTime.new!(date, ~T[00:00:00], "Etc/UTC") |> DateTime.truncate(:second)
+ {start_at, DateTime.add(start_at, 1, :day)}
+ end
+
+ defp credit_caps(user_id) do
+ case Repo.get(User, user_id) do
+ %User{is_admin: true} ->
+ %{daily: nil, balance: nil}
+
+ %User{trust_level: trust_level} ->
+ tier = trust_level |> Kernel.||(0) |> min(3) |> max(0)
+ %{daily: Map.fetch!(@daily_earning_caps, tier), balance: Map.fetch!(@balance_caps, tier)}
+
+ _ ->
+ %{daily: Map.fetch!(@daily_earning_caps, 0), balance: Map.fetch!(@balance_caps, 0)}
+ end
+ end
+
+ defp canonical_proof_key(%Proof{kind: "dns", subject: subject}) do
+ "dns:#{canonical_dns_subject(subject)}"
+ end
+
+ defp canonical_proof_key(%Proof{kind: "web"} = proof) do
+ "web:#{canonical_url(proof.evidence_url || proof.subject)}"
+ end
+
+ defp canonical_proof_key(%Proof{verification_method: "oauth", metadata: metadata}) do
+ provider = metadata_value(metadata, "provider")
+ provider_account_id = metadata_value(metadata, "provider_account_id")
+
+ "social:oauth:#{String.downcase(provider)}:#{provider_account_id}"
+ end
+
+ defp canonical_proof_key(%Proof{kind: "social", subject: subject}) do
+ "social:#{canonical_url(subject)}"
+ end
+
+ defp canonical_proof_key(%Proof{kind: kind, subject: subject}) do
+ "#{kind}:#{subject |> to_string() |> String.trim() |> String.downcase()}"
+ end
+
+ defp canonical_dns_subject(subject) do
+ subject
+ |> to_string()
+ |> String.trim()
+ |> String.trim_trailing(".")
+ |> String.downcase()
+ end
+
+ defp canonical_url(value) do
+ value = String.trim(to_string(value))
+
+ case URI.parse(value) do
+ %URI{host: host} = uri when is_binary(host) ->
+ scheme = uri.scheme |> to_string() |> String.downcase()
+ host = String.downcase(host)
+ port = canonical_port(uri.scheme, uri.port)
+ path = canonical_path(uri.path)
+
+ [scheme, "://", host, port, path]
+ |> IO.iodata_to_binary()
+ |> String.trim_trailing("/")
+
+ _ ->
+ value
+ end
+ end
+
+ defp canonical_port("http", 80), do: ""
+ defp canonical_port("https", 443), do: ""
+ defp canonical_port(_scheme, nil), do: ""
+ defp canonical_port(_scheme, port), do: ":#{port}"
+
+ defp canonical_path(nil), do: "/"
+ defp canonical_path(""), do: "/"
+ defp canonical_path(path), do: URI.decode(path)
+
+ defp metadata_value(metadata, key) when is_map(metadata) do
+ metadata
+ |> Map.get(key, "")
+ |> to_string()
+ end
+
+ defp metadata_value(_metadata, _key), do: ""
+
+ defp safe_reference_id(value) do
+ value = to_string(value)
+
+ if String.length(value) <= 200 do
+ value
+ else
+ hash = :crypto.hash(:sha256, value) |> Base.url_encode64(padding: false)
+ "sha256:#{hash}"
+ end
+ end
+end
diff --git a/apps/atomine/lib/atomine/credit_ledger_entry.ex b/apps/atomine/lib/atomine/credit_ledger_entry.ex
new file mode 100644
index 0000000..da322c1
--- /dev/null
+++ b/apps/atomine/lib/atomine/credit_ledger_entry.ex
@@ -0,0 +1,45 @@
+defmodule Atomine.CreditLedgerEntry do
+ @moduledoc "Immutable Atomine Credit accounting event."
+
+ use Ecto.Schema
+ import Ecto.Changeset
+
+ alias Atomine.CreditAccount
+
+ schema "atomine_credit_ledger_entries" do
+ belongs_to :user, Elektrine.Accounts.User
+ field :credit_type, :string
+ field :amount, :integer
+ field :reason, :string
+ field :action, :string
+ field :reference_type, :string
+ field :reference_id, :string
+ field :metadata, :map, default: %{}
+
+ timestamps(type: :utc_datetime)
+ end
+
+ def changeset(entry, attrs) do
+ entry
+ |> cast(attrs, [
+ :user_id,
+ :credit_type,
+ :amount,
+ :reason,
+ :action,
+ :reference_type,
+ :reference_id,
+ :metadata
+ ])
+ |> validate_required([:user_id, :credit_type, :amount, :reason])
+ |> validate_inclusion(:credit_type, CreditAccount.credit_types())
+ |> validate_length(:reason, min: 1, max: 120)
+ |> validate_length(:action, max: 120)
+ |> validate_length(:reference_type, max: 120)
+ |> validate_length(:reference_id, max: 255)
+ |> validate_change(:amount, fn :amount, amount ->
+ if amount == 0, do: [amount: "must not be zero"], else: []
+ end)
+ |> foreign_key_constraint(:user_id)
+ end
+end
diff --git a/apps/atomine/lib/atomine/credit_policy.ex b/apps/atomine/lib/atomine/credit_policy.ex
new file mode 100644
index 0000000..a9a93b8
--- /dev/null
+++ b/apps/atomine/lib/atomine/credit_policy.ex
@@ -0,0 +1,77 @@
+defmodule Atomine.CreditPolicy do
+ @moduledoc "Policy helpers that translate account trust into Atomine Credit costs."
+
+ alias Atomine.Credits
+ alias Elektrine.Accounts.Capabilities
+
+ @first_dm_action "first_dm"
+ @send_email_action "send_email"
+ @first_dm_cost 1
+ @send_email_cost 1
+
+ @doc "Returns whether the first-DM credit gate is enabled."
+ def dm_gate_enabled?, do: Capabilities.credit_gate_enabled?(:dm)
+
+ @doc "Returns whether the outbound email credit gate is enabled."
+ def email_gate_enabled?, do: Capabilities.credit_gate_enabled?(:email)
+
+ @doc "Returns current built-in action pricing for UI/API discovery."
+ def action_prices, do: Capabilities.action_prices()
+
+ @doc "Spends Atomine Credits for a first DM against an arbitrary audience."
+ def spend_first_dm_credit(sender_id, recipient, opts \\ [])
+
+ def spend_first_dm_credit(sender_id, recipient_id, opts) when is_integer(recipient_id) do
+ opts = Keyword.put(opts, :local_recipient_id, recipient_id)
+ spend_first_dm_credit(sender_id, "user:#{recipient_id}", opts)
+ end
+
+ def spend_first_dm_credit(sender_id, audience, opts) when is_binary(audience) do
+ case Capabilities.first_dm_credit_requirement(sender_id, opts) do
+ :free ->
+ :ok
+
+ :required ->
+ case Credits.spend(
+ sender_id,
+ :atomine_credit,
+ @first_dm_cost,
+ @first_dm_action,
+ audience,
+ idempotency_key: "#{@first_dm_action}:#{sender_id}:#{audience}"
+ ) do
+ {:ok, _spend} -> :ok
+ {:error, :insufficient_credits} -> {:error, :insufficient_dm_credits}
+ {:error, reason} -> {:error, reason}
+ end
+
+ {:error, reason} ->
+ {:error, reason}
+ end
+ end
+
+ @doc "Spends Atomine Credits when an external outbound send needs them."
+ def spend_email_credit(sender_id, audience, opts \\ []) when is_binary(audience) do
+ case Capabilities.email_credit_requirement(sender_id, opts) do
+ :free ->
+ :ok
+
+ :required ->
+ case Credits.spend(
+ sender_id,
+ :atomine_credit,
+ @send_email_cost,
+ @send_email_action,
+ audience,
+ idempotency_key: Keyword.get(opts, :idempotency_key)
+ ) do
+ {:ok, _spend} -> :ok
+ {:error, :insufficient_credits} -> {:error, :insufficient_email_credits}
+ {:error, reason} -> {:error, reason}
+ end
+
+ {:error, reason} ->
+ {:error, reason}
+ end
+ end
+end
diff --git a/apps/atomine/lib/atomine/credit_spend.ex b/apps/atomine/lib/atomine/credit_spend.ex
new file mode 100644
index 0000000..2fdadbb
--- /dev/null
+++ b/apps/atomine/lib/atomine/credit_spend.ex
@@ -0,0 +1,43 @@
+defmodule Atomine.CreditSpend do
+ @moduledoc "One Atomine Credit spend against an action and audience."
+
+ use Ecto.Schema
+ import Ecto.Changeset
+
+ alias Atomine.CreditAccount
+
+ schema "atomine_credit_spends" do
+ belongs_to :user, Elektrine.Accounts.User
+ field :credit_type, :string
+ field :amount, :integer
+ field :action, :string
+ field :audience, :string
+ field :idempotency_key, :string
+ field :metadata, :map, default: %{}
+
+ timestamps(type: :utc_datetime)
+ end
+
+ def changeset(spend, attrs) do
+ spend
+ |> cast(attrs, [
+ :user_id,
+ :credit_type,
+ :amount,
+ :action,
+ :audience,
+ :idempotency_key,
+ :metadata
+ ])
+ |> validate_required([:user_id, :credit_type, :amount, :action, :audience])
+ |> validate_inclusion(:credit_type, CreditAccount.credit_types())
+ |> validate_number(:amount, greater_than: 0)
+ |> validate_length(:action, min: 1, max: 120)
+ |> validate_length(:audience, min: 1, max: 500)
+ |> validate_length(:idempotency_key, max: 255)
+ |> unique_constraint([:user_id, :credit_type, :action, :idempotency_key],
+ name: :atomine_credit_spends_idempotency_index
+ )
+ |> foreign_key_constraint(:user_id)
+ end
+end
diff --git a/apps/atomine/lib/atomine/credits.ex b/apps/atomine/lib/atomine/credits.ex
new file mode 100644
index 0000000..4f5eddb
--- /dev/null
+++ b/apps/atomine/lib/atomine/credits.ex
@@ -0,0 +1,244 @@
+defmodule Atomine.Credits do
+ @moduledoc """
+ Atomine Credits are scarce balances used for anti-abuse gates.
+
+ Credits are granted by trusted signals and spent by risky actions. This module
+ is intentionally ledger-backed so abuse review can trace where capacity came
+ from and where it was consumed.
+ """
+
+ import Ecto.Query, warn: false
+
+ alias Atomine.{CreditAccount, CreditLedgerEntry, CreditSpend}
+ alias Elektrine.Repo
+
+ @type credit_type :: String.t() | atom()
+
+ @doc "Returns supported credit type identifiers."
+ def credit_types, do: CreditAccount.credit_types()
+
+ @doc "Returns the current balance for a user and credit type."
+ def balance(user_id, credit_type) do
+ case normalize_credit_type(credit_type) do
+ {:ok, credit_type} ->
+ CreditAccount
+ |> where([a], a.user_id == ^user_id and a.credit_type == ^credit_type)
+ |> select([a], a.balance)
+ |> Repo.one()
+ |> Kernel.||(0)
+
+ {:error, _reason} ->
+ 0
+ end
+ end
+
+ @doc "Grants credits and records a positive ledger entry."
+ def grant(user_id, credit_type, amount, reason, opts \\ [])
+
+ def grant(user_id, credit_type, amount, reason, opts)
+ when is_integer(user_id) and is_integer(amount) and amount > 0 and is_binary(reason) do
+ with {:ok, credit_type} <- normalize_credit_type(credit_type) do
+ Repo.transaction(fn ->
+ account = get_or_insert_account!(user_id, credit_type)
+
+ account
+ |> CreditAccount.changeset(%{
+ balance: account.balance + amount,
+ lifetime_earned: account.lifetime_earned + amount
+ })
+ |> Repo.update!()
+
+ %CreditLedgerEntry{}
+ |> CreditLedgerEntry.changeset(%{
+ user_id: user_id,
+ credit_type: credit_type,
+ amount: amount,
+ reason: reason,
+ action: Keyword.get(opts, :action),
+ reference_type: Keyword.get(opts, :reference_type),
+ reference_id: reference_id(opts),
+ metadata: Keyword.get(opts, :metadata, %{})
+ })
+ |> Repo.insert!()
+ end)
+ end
+ end
+
+ def grant(_user_id, credit_type, _amount, _reason, _opts) do
+ with {:ok, _credit_type} <- normalize_credit_type(credit_type), do: {:error, :invalid_grant}
+ end
+
+ @doc "Grants credits once for a stable reference."
+ def grant_once(user_id, credit_type, amount, reason, opts \\ [])
+
+ def grant_once(user_id, credit_type, amount, reason, opts) do
+ with {:ok, credit_type} <- normalize_credit_type(credit_type) do
+ case existing_grant(user_id, credit_type, reason, opts) do
+ %CreditLedgerEntry{} = ledger_entry ->
+ {:ok, ledger_entry}
+
+ nil ->
+ grant(user_id, credit_type, amount, reason, opts)
+ end
+ end
+ end
+
+ @doc "Spends credits once for an action and audience."
+ def spend(user_id, credit_type, amount, action, audience, opts \\ [])
+
+ def spend(user_id, credit_type, amount, action, audience, opts)
+ when is_integer(user_id) and is_integer(amount) and amount > 0 and is_binary(action) and
+ is_binary(audience) do
+ with {:ok, credit_type} <- normalize_credit_type(credit_type) do
+ Repo.transaction(fn ->
+ idempotency_key = Keyword.get(opts, :idempotency_key)
+
+ case existing_spend(user_id, credit_type, action, idempotency_key) do
+ %CreditSpend{} = spend ->
+ spend
+
+ nil ->
+ spend_fresh!(user_id, credit_type, amount, action, audience, opts)
+ end
+ end)
+ end
+ end
+
+ def spend(_user_id, credit_type, _amount, _action, _audience, _opts) do
+ with {:ok, _credit_type} <- normalize_credit_type(credit_type), do: {:error, :invalid_spend}
+ end
+
+ @doc "Returns recent ledger entries for a user."
+ def list_ledger_entries(user_id, opts \\ []) do
+ limit = opts |> Keyword.get(:limit, 50) |> min(200)
+
+ CreditLedgerEntry
+ |> where([e], e.user_id == ^user_id)
+ |> order_by([e], desc: e.inserted_at, desc: e.id)
+ |> limit(^limit)
+ |> Repo.all()
+ end
+
+ @doc "Returns credit accounts for a user ordered by credit type."
+ def list_accounts(user_id) do
+ CreditAccount
+ |> where([a], a.user_id == ^user_id)
+ |> order_by([a], asc: a.credit_type)
+ |> Repo.all()
+ end
+
+ defp spend_fresh!(user_id, credit_type, amount, action, audience, opts) do
+ account = lock_account(user_id, credit_type)
+
+ if is_nil(account) or account.balance < amount do
+ Repo.rollback(:insufficient_credits)
+ end
+
+ account
+ |> CreditAccount.changeset(%{
+ balance: account.balance - amount,
+ lifetime_spent: account.lifetime_spent + amount
+ })
+ |> Repo.update!()
+
+ spend =
+ %CreditSpend{}
+ |> CreditSpend.changeset(%{
+ user_id: user_id,
+ credit_type: credit_type,
+ amount: amount,
+ action: action,
+ audience: audience,
+ idempotency_key: Keyword.get(opts, :idempotency_key),
+ metadata: Keyword.get(opts, :metadata, %{})
+ })
+ |> Repo.insert!()
+
+ %CreditLedgerEntry{}
+ |> CreditLedgerEntry.changeset(%{
+ user_id: user_id,
+ credit_type: credit_type,
+ amount: -amount,
+ reason: "spend:#{action}",
+ action: action,
+ reference_type: "atomine_credit_spend",
+ reference_id: to_string(spend.id),
+ metadata: %{"audience" => audience}
+ })
+ |> Repo.insert!()
+
+ spend
+ end
+
+ defp existing_spend(_user_id, _credit_type, _action, nil), do: nil
+ defp existing_spend(_user_id, _credit_type, _action, ""), do: nil
+
+ defp existing_spend(user_id, credit_type, action, idempotency_key) do
+ Repo.get_by(CreditSpend,
+ user_id: user_id,
+ credit_type: credit_type,
+ action: action,
+ idempotency_key: idempotency_key
+ )
+ end
+
+ defp existing_grant(user_id, credit_type, reason, opts) do
+ reference_type = Keyword.get(opts, :reference_type)
+ reference_id = reference_id(opts)
+
+ if is_nil(reference_type) or is_nil(reference_id) do
+ nil
+ else
+ Repo.get_by(CreditLedgerEntry,
+ user_id: user_id,
+ credit_type: credit_type,
+ reason: reason,
+ reference_type: reference_type,
+ reference_id: reference_id
+ )
+ end
+ end
+
+ defp get_or_insert_account!(user_id, credit_type) do
+ case lock_account(user_id, credit_type) do
+ %CreditAccount{} = account ->
+ account
+
+ nil ->
+ %CreditAccount{}
+ |> CreditAccount.changeset(%{user_id: user_id, credit_type: credit_type})
+ |> Repo.insert!(on_conflict: :nothing, conflict_target: [:user_id, :credit_type])
+
+ lock_account(user_id, credit_type)
+ end
+ end
+
+ defp lock_account(user_id, credit_type) do
+ CreditAccount
+ |> where([a], a.user_id == ^user_id and a.credit_type == ^credit_type)
+ |> lock("FOR UPDATE")
+ |> Repo.one()
+ end
+
+ defp normalize_credit_type(value) when is_atom(value),
+ do: value |> Atom.to_string() |> normalize_credit_type()
+
+ defp normalize_credit_type(value) when is_binary(value) do
+ credit_type = String.trim(value)
+
+ if credit_type in CreditAccount.credit_types() do
+ {:ok, credit_type}
+ else
+ {:error, :invalid_credit_type}
+ end
+ end
+
+ defp normalize_credit_type(_value), do: {:error, :invalid_credit_type}
+
+ defp reference_id(opts) do
+ case Keyword.get(opts, :reference_id) do
+ nil -> nil
+ value -> to_string(value)
+ end
+ end
+end
diff --git a/apps/atomine/lib/atomine/live_proof_recheck_worker.ex b/apps/atomine/lib/atomine/live_proof_recheck_worker.ex
new file mode 100644
index 0000000..a6e8b99
--- /dev/null
+++ b/apps/atomine/lib/atomine/live_proof_recheck_worker.ex
@@ -0,0 +1,16 @@
+defmodule Atomine.LiveProofRecheckWorker do
+ @moduledoc """
+ Periodically rechecks due live proofs.
+ """
+
+ use Oban.Worker, queue: :default, max_attempts: 1
+
+ @default_limit 100
+
+ @impl Oban.Worker
+ def perform(%Oban.Job{args: args}) do
+ limit = Map.get(args || %{}, "limit", @default_limit)
+ _results = Atomine.Personhood.recheck_due_live_proofs(limit)
+ :ok
+ end
+end
diff --git a/apps/atomine/lib/atomine/personhood.ex b/apps/atomine/lib/atomine/personhood.ex
new file mode 100644
index 0000000..0494ee0
--- /dev/null
+++ b/apps/atomine/lib/atomine/personhood.ex
@@ -0,0 +1,927 @@
+defmodule Atomine.Personhood do
+ @moduledoc """
+ Proof-of-personhood and anti-bot scoring context.
+
+ This app owns personhood proof lifecycle and scoring. Other apps can consume
+ `personhood_score/1` or `sufficiently_human?/2` when deciding whether to raise
+ limits, allow sensitive actions, or reduce anti-bot friction.
+ """
+
+ import Ecto.Query, warn: false
+
+ alias Atomine.{CreditEarningPolicy, Proof}
+ alias Atomine.Scoring
+ alias Atomine.TrustSession
+ alias Elektrine.Accounts.ConnectedAccount
+ alias Elektrine.Accounts.User
+ alias Elektrine.HTTP.SafeFetch
+ alias Elektrine.Repo
+
+ @live_check_interval_days 30
+ @live_stale_after_days 45
+ @max_web_proof_bytes 256 * 1024
+ @proof_statement_version "v1"
+
+ @proof_weights %{
+ "web" => 20,
+ "dns" => 25,
+ "social" => 15,
+ "vouch" => 30,
+ "payment" => 25,
+ "passkey" => 20,
+ "manual" => 100
+ }
+
+ @doc "Returns default score weights by proof kind."
+ def proof_weights, do: @proof_weights
+
+ @doc "Lists trust sessions newest first. Pass `:user_id`, `:merchant_id`, or `:status` to filter."
+ def list_trust_sessions(opts \\ []) do
+ TrustSession
+ |> maybe_filter(:user_id, Keyword.get(opts, :user_id))
+ |> maybe_filter(:merchant_id, Keyword.get(opts, :merchant_id))
+ |> maybe_filter(:status, Keyword.get(opts, :status))
+ |> order_by([s], desc: s.inserted_at)
+ |> Repo.all()
+ end
+
+ @doc "Gets a trust session by its public session id."
+ def get_trust_session(public_id) when is_binary(public_id) do
+ Repo.get_by(TrustSession, public_id: public_id)
+ end
+
+ def get_trust_session(_), do: nil
+
+ @doc "Creates a guest trust session for checkout/signup/external flows."
+ def create_trust_session(attrs) when is_map(attrs) do
+ attrs
+ |> stringify_keys()
+ |> put_default_session_fields(nil)
+ |> insert_trust_session()
+ end
+
+ @doc "Creates a trust session attached to a known Elektrine user."
+ def create_trust_session(%User{} = user, attrs) when is_map(attrs) do
+ attrs
+ |> stringify_keys()
+ |> put_default_session_fields(user)
+ |> insert_trust_session()
+ end
+
+ @doc "Updates a trust session with a fresh decision or step-up state."
+ def update_trust_session(%TrustSession{} = session, attrs) when is_map(attrs) do
+ session
+ |> TrustSession.changeset(stringify_keys(attrs))
+ |> Repo.update()
+ end
+
+ @doc "Marks a trust session completed and records the final decision."
+ def complete_trust_session(%TrustSession{} = session, attrs \\ %{}) when is_map(attrs) do
+ attrs =
+ attrs
+ |> stringify_keys()
+ |> Map.put_new("status", "completed")
+ |> Map.put_new("completed_at", now())
+
+ update_trust_session(session, attrs)
+ end
+
+ @doc "Lists a user's proofs newest first."
+ def list_proofs(user_id) when is_integer(user_id) do
+ Proof
+ |> where([p], p.user_id == ^user_id)
+ |> order_by([p], desc: p.inserted_at)
+ |> Repo.all()
+ end
+
+ def list_proofs(_), do: []
+
+ @doc "Lists proofs needing human/admin review."
+ def list_pending_proofs do
+ Proof
+ |> where([p], p.status == "pending")
+ |> order_by([p], asc: p.inserted_at)
+ |> Repo.all()
+ end
+
+ @doc "Lists live proofs whose scheduled recheck is due."
+ def list_due_live_proofs(limit \\ 100) do
+ now = now()
+
+ Proof
+ |> where(
+ [p],
+ p.claim_type == "positive" and p.proof_mode == "live" and p.status == "verified" and
+ not is_nil(p.next_check_at) and p.next_check_at <= ^now
+ )
+ |> order_by([p], asc: p.next_check_at)
+ |> limit(^normalize_limit(limit))
+ |> Repo.all()
+ end
+
+ @doc "Rechecks due live proofs and returns per-proof results."
+ def recheck_due_live_proofs(limit \\ 100) do
+ limit
+ |> list_due_live_proofs()
+ |> Enum.map(fn proof -> {proof, check_proof(proof)} end)
+ end
+
+ def get_proof!(id), do: Repo.get!(Proof, id)
+
+ @doc "Creates a pending proof with a public challenge string."
+ def create_proof(%User{} = user, attrs) when is_map(attrs) do
+ attrs = stringify_keys(attrs)
+ kind = attrs |> Map.get("kind", "") |> normalize_kind()
+
+ proof_mode = attrs |> Map.get("proof_mode", "snapshot") |> normalize_proof_mode()
+ subject = attrs |> Map.get("subject") |> normalize_subject_for_kind(kind)
+ default_method = default_method_for_kind(kind, subject)
+
+ verification_method =
+ attrs |> Map.get("verification_method", default_method) |> normalize_method()
+
+ challenge = Map.get(attrs, "challenge") || generate_challenge(user, kind, subject)
+
+ %Proof{}
+ |> Proof.changeset(%{
+ user_id: user.id,
+ kind: kind,
+ claim_type: "positive",
+ proof_mode: proof_mode,
+ live_status: initial_live_status(proof_mode),
+ verification_method: verification_method,
+ subject: subject,
+ status: "pending",
+ challenge: challenge,
+ evidence_url: Map.get(attrs, "evidence_url"),
+ score_weight: Map.get(attrs, "score_weight") || Map.get(@proof_weights, kind, 0),
+ metadata: proof_metadata(Map.get(attrs, "metadata"), verification_method, challenge)
+ })
+ |> Repo.insert()
+ end
+
+ @doc "Creates a hosted negative assertion, such as `I am not on Twitter`."
+ def create_negative_assertion(%User{} = user, attrs) when is_map(attrs) do
+ attrs = stringify_keys(attrs)
+ kind = attrs |> Map.get("kind", "social") |> normalize_kind()
+
+ challenge =
+ Map.get(attrs, "challenge") || generate_challenge(user, kind, Map.get(attrs, "subject"))
+
+ %Proof{}
+ |> Proof.changeset(%{
+ user_id: user.id,
+ kind: kind,
+ claim_type: "negative",
+ proof_mode: "snapshot",
+ live_status: nil,
+ verification_method: "none",
+ subject: Map.get(attrs, "subject"),
+ status: "asserted",
+ challenge: challenge,
+ evidence_url: Map.get(attrs, "evidence_url"),
+ score_weight: 0,
+ metadata: proof_metadata(Map.get(attrs, "metadata"), "none", challenge)
+ })
+ |> Repo.insert()
+ end
+
+ @doc "Creates or refreshes a verified proof from a connected OAuth/OIDC account."
+ def verify_connected_account_proof(%ConnectedAccount{} = connected_account) do
+ subject = connected_account_subject(connected_account)
+
+ attrs = %{
+ user_id: connected_account.user_id,
+ kind: "social",
+ claim_type: "positive",
+ proof_mode: "live",
+ live_status: "active",
+ verification_method: "oauth",
+ subject: subject,
+ status: "verified",
+ challenge: "OAuth account verified for #{subject}",
+ evidence_url: connected_account.profile_url,
+ score_weight: Map.get(@proof_weights, "social", 15),
+ checked_at: now(),
+ last_seen_at: now(),
+ next_check_at: DateTime.add(now(), @live_check_interval_days, :day),
+ stale_at: DateTime.add(now(), @live_stale_after_days, :day),
+ verified_at: now(),
+ metadata: %{
+ "provider" => connected_account.provider,
+ "provider_account_id" => connected_account.provider_account_id,
+ "username" => connected_account.username,
+ "display_name" => connected_account.display_name,
+ "connected_account_id" => connected_account.id
+ }
+ }
+
+ case Repo.get_by(Proof,
+ user_id: connected_account.user_id,
+ kind: "social",
+ subject: subject,
+ status: "verified"
+ ) do
+ %Proof{} = proof ->
+ proof
+ |> Proof.changeset(attrs)
+ |> Repo.update()
+ |> grant_verified_proof_credits()
+
+ nil ->
+ %Proof{}
+ |> Proof.changeset(attrs)
+ |> Repo.insert()
+ |> grant_verified_proof_credits()
+ end
+ end
+
+ @doc "Marks a proof verified and makes its weight count toward personhood score."
+ def verify_proof(%Proof{} = proof, reviewer \\ nil, notes \\ nil) do
+ now = now()
+
+ proof
+ |> Proof.changeset(%{
+ status: "verified",
+ checked_at: now,
+ last_seen_at: live_timestamp(proof, now),
+ next_check_at: next_check_at(proof, now),
+ stale_at: stale_at(proof, now),
+ live_status: verified_live_status(proof),
+ failed_check_count: 0,
+ verified_at: now,
+ rejected_at: nil,
+ revoked_at: nil,
+ reviewed_by_user_id: reviewer_id(reviewer),
+ review_notes: notes
+ })
+ |> Repo.update()
+ |> grant_verified_proof_credits()
+ end
+
+ @doc "Rejects a pending proof without contributing score."
+ def reject_proof(%Proof{} = proof, reviewer \\ nil, notes \\ nil) do
+ proof
+ |> Proof.changeset(%{
+ status: "rejected",
+ checked_at: now(),
+ live_status: rejected_live_status(proof),
+ rejected_at: now(),
+ verified_at: nil,
+ revoked_at: nil,
+ reviewed_by_user_id: reviewer_id(reviewer),
+ review_notes: notes
+ })
+ |> Repo.update()
+ end
+
+ @doc "Revokes a previously accepted proof."
+ def revoke_proof(%Proof{} = proof, reviewer \\ nil, notes \\ nil) do
+ proof
+ |> Proof.changeset(%{
+ status: "revoked",
+ live_status: revoked_live_status(proof),
+ revoked_at: now(),
+ verified_at: nil,
+ rejected_at: nil,
+ reviewed_by_user_id: reviewer_id(reviewer),
+ review_notes: notes
+ })
+ |> Repo.update()
+ end
+
+ @doc "Deletes a proof owned by a user."
+ def delete_proof(%Proof{} = proof) do
+ Repo.delete(proof)
+ end
+
+ @doc "Marks a live proof stale after an overdue or failed recheck."
+ def mark_live_stale(proof, notes \\ nil)
+
+ def mark_live_stale(%Proof{proof_mode: "live"} = proof, notes) do
+ proof
+ |> Proof.changeset(%{
+ live_status: "stale",
+ failed_check_count: proof.failed_check_count + 1,
+ checked_at: now(),
+ review_notes: notes || proof.review_notes
+ })
+ |> Repo.update()
+ end
+
+ def mark_live_stale(%Proof{} = proof, _notes), do: {:ok, proof}
+
+ @doc "Marks a live proof inactive when its snippet can no longer be found."
+ def mark_live_inactive(proof, notes \\ nil)
+
+ def mark_live_inactive(%Proof{proof_mode: "live"} = proof, notes) do
+ proof
+ |> Proof.changeset(%{
+ live_status: "inactive",
+ failed_check_count: proof.failed_check_count + 1,
+ checked_at: now(),
+ review_notes: notes || proof.review_notes
+ })
+ |> Repo.update()
+ end
+
+ def mark_live_inactive(%Proof{} = proof, _notes), do: {:ok, proof}
+
+ @doc "Checks a DNS or public page proof and verifies it when the challenge is published."
+ def check_proof(%Proof{claim_type: "negative"}), do: {:error, :not_checkable}
+
+ def check_proof(%Proof{status: status}) when status in ["rejected", "revoked"],
+ do: {:error, :closed}
+
+ def check_proof(%Proof{verification_method: "dns"} = proof) do
+ case dns_challenge_present?(proof) do
+ true -> verify_proof(proof, nil, "DNS TXT record matched")
+ false -> mark_check_failed(proof, "DNS TXT record did not contain the challenge")
+ {:error, reason} -> mark_check_failed(proof, "DNS check failed: #{inspect(reason)}")
+ end
+ end
+
+ def check_proof(%Proof{verification_method: "page"} = proof) do
+ case web_challenge_present?(proof) do
+ true -> verify_proof(proof, nil, "Web page contained challenge")
+ false -> mark_check_failed(proof, "Web page did not contain the challenge")
+ {:error, reason} -> mark_check_failed(proof, "Web check failed: #{inspect(reason)}")
+ end
+ end
+
+ def check_proof(%Proof{verification_method: "github_gist"} = proof) do
+ case github_gist_challenge_present?(proof) do
+ true -> verify_proof(proof, nil, "GitHub gist contained challenge")
+ false -> mark_check_failed(proof, "GitHub gist did not contain the challenge")
+ {:error, reason} -> mark_check_failed(proof, "GitHub gist check failed: #{inspect(reason)}")
+ end
+ end
+
+ def check_proof(%Proof{}), do: {:error, :manual_review_required}
+
+ @doc "Returns a detailed composite personhood score breakdown."
+ def personhood_breakdown(user_or_id), do: Scoring.breakdown(user_or_id)
+
+ @doc "Returns the capped composite personhood score for a user."
+ def personhood_score(user_or_id), do: personhood_breakdown(user_or_id).score
+
+ @doc "Returns a coarse label for UI and policy decisions."
+ def personhood_level(user_or_id) do
+ personhood_breakdown(user_or_id).level
+ end
+
+ @doc "Returns whether a user has enough personhood score for a policy gate."
+ def sufficiently_human?(user_or_id, minimum_score \\ 40) when is_integer(minimum_score) do
+ personhood_score(user_or_id) >= minimum_score
+ end
+
+ @doc "Returns the claim snippet users should publish for page-based proofs."
+ def page_snippet(%Proof{} = proof), do: proof.challenge
+
+ @doc "Returns DNS TXT record instructions for DNS-based proofs."
+ def dns_txt_record(%Proof{verification_method: "dns"} = proof) do
+ {"_atomine", proof.challenge}
+ end
+
+ def dns_txt_record(%Proof{}), do: nil
+
+ @doc "Returns the DNS TXT host users should publish for a DNS proof."
+ def dns_txt_host(%Proof{verification_method: "dns", subject: subject})
+ when is_binary(subject) do
+ "_atomine.#{String.trim(subject)}"
+ end
+
+ def dns_txt_host(%Proof{}), do: nil
+
+ defp generate_challenge(%User{} = user, kind, subject) do
+ token = :crypto.strong_rand_bytes(18) |> Base.url_encode64(padding: false)
+ handle = user.handle || user.username || "user-#{user.id}"
+ subject = subject || ""
+ payload = signed_proof_payload(user.id, handle, kind, subject, token)
+ signature = sign_proof_payload(payload)
+
+ Enum.join(
+ [
+ "Atomine identity claim",
+ @proof_statement_version,
+ "user=#{encode_proof_field("@#{handle}")}",
+ "user_id=#{user.id}",
+ "kind=#{encode_proof_field(kind)}",
+ "subject=#{encode_proof_field(subject)}",
+ "nonce=#{encode_proof_field(token)}",
+ "sig=#{encode_proof_field(signature)}"
+ ],
+ " "
+ )
+ end
+
+ defp stringify_keys(attrs) do
+ Map.new(attrs, fn {key, value} -> {to_string(key), value} end)
+ end
+
+ defp put_default_session_fields(attrs, user) do
+ score_breakdown = if user, do: personhood_breakdown(user), else: nil
+ score = Map.get(attrs, "score") || (score_breakdown && score_breakdown.score) || 0
+ decision = Map.get(attrs, "decision") || default_decision(score)
+
+ attrs
+ |> maybe_put_user_id(user)
+ |> Map.put_new("context", "checkout")
+ |> Map.put_new("status", default_session_status(decision))
+ |> Map.put_new("decision", decision)
+ |> Map.put_new("recommended_step_up", default_step_up(decision, score))
+ |> Map.put_new("score", score)
+ |> Map.put_new("level", Map.get(attrs, "level") || score_level(score))
+ |> Map.put_new("signals", %{})
+ |> Map.put_new("metadata", %{})
+ |> Map.put_new("expires_at", DateTime.add(now(), 30, :minute))
+ end
+
+ defp maybe_put_user_id(attrs, %User{id: user_id}), do: Map.put_new(attrs, "user_id", user_id)
+ defp maybe_put_user_id(attrs, nil), do: attrs
+
+ defp insert_trust_session(attrs) do
+ %TrustSession{}
+ |> TrustSession.changeset(attrs)
+ |> Repo.insert()
+ end
+
+ defp maybe_filter(query, _field, nil), do: query
+
+ defp maybe_filter(query, field, value) do
+ where(query, [s], field(s, ^field) == ^value)
+ end
+
+ defp default_decision(score) when score >= 75, do: "allow"
+ defp default_decision(score) when score >= 40, do: "step_up"
+ defp default_decision(score) when score >= 15, do: "step_up"
+ defp default_decision(_), do: "review"
+
+ defp default_session_status("allow"), do: "completed"
+ defp default_session_status("step_up"), do: "step_up"
+ defp default_session_status(_), do: "pending"
+
+ defp default_step_up("step_up", score) when score < 40, do: "passkey"
+ defp default_step_up("step_up", _score), do: "email"
+ defp default_step_up("review", _score), do: "proof"
+ defp default_step_up(_, _score), do: "none"
+
+ defp score_level(score), do: Scoring.level(score) |> Atom.to_string()
+
+ defp connected_account_subject(%ConnectedAccount{} = connected_account) do
+ "oauth:#{connected_account.provider}:#{connected_account.provider_account_id}"
+ end
+
+ defp dns_challenge_present?(proof) do
+ host = dns_txt_host(proof)
+
+ with true <- is_binary(host) and host != "",
+ true <- proof_statement_valid?(proof),
+ {:ok, records} <- lookup_txt_records(host) do
+ Enum.any?(records, &String.contains?(&1, proof.challenge))
+ else
+ false -> {:error, :invalid_dns_subject}
+ {:error, reason} -> {:error, reason}
+ end
+ end
+
+ defp lookup_txt_records(host) do
+ host
+ |> String.trim_trailing(".")
+ |> String.to_charlist()
+ |> :inet_res.lookup(:in, :txt)
+ |> case do
+ [] -> {:ok, []}
+ records -> {:ok, Enum.map(records, &txt_record_to_string/1)}
+ end
+ rescue
+ error -> {:error, error}
+ end
+
+ defp txt_record_to_string(record) when is_list(record) do
+ record
+ |> List.flatten()
+ |> to_string()
+ end
+
+ defp txt_record_to_string(record), do: to_string(record)
+
+ defp web_challenge_present?(proof) do
+ url = proof.evidence_url || proof.subject
+
+ with true <- proof_statement_valid?(proof),
+ {:ok, uri} <- safe_web_proof_uri(url),
+ {:ok, body} <- fetch_web_proof(uri) do
+ String.contains?(body, proof.challenge)
+ end
+ end
+
+ defp github_gist_challenge_present?(proof) do
+ with true <- proof_statement_valid?(proof),
+ {:ok, username} <- github_profile_username(proof.subject),
+ {:ok, body} <- fetch_github_user_gists(username) do
+ String.contains?(body, proof.challenge)
+ end
+ end
+
+ defp github_profile_username(url) when is_binary(url) do
+ uri = URI.parse(String.trim(url))
+ host = if is_binary(uri.host), do: String.downcase(uri.host), else: nil
+ path_parts = uri.path |> to_string() |> String.split("/", trim: true)
+
+ case {uri.scheme, host, path_parts} do
+ {scheme, "github.com", [username | _]} when scheme in ["http", "https"] ->
+ {:ok, username}
+
+ _ ->
+ {:error, :not_github_profile_url}
+ end
+ end
+
+ defp github_profile_username(_), do: {:error, :not_github_profile_url}
+
+ defp fetch_github_user_gists(username) do
+ start_app(:inets)
+ start_app(:ssl)
+
+ url = ~c"https://api.github.com/users/#{username}/gists?per_page=100"
+
+ headers = [
+ {~c"user-agent", ~c"Elektrine-Atomine-Proofs"},
+ {~c"accept", ~c"application/vnd.github+json"}
+ ]
+
+ request = {url, headers}
+ http_options = [timeout: 5_000]
+ options = [body_format: :binary]
+
+ case :httpc.request(:get, request, http_options, options) do
+ {:ok, {{_, status, _}, _headers, body}} when status in 200..299 ->
+ decode_user_gists_body(body)
+
+ {:ok, {{_, status, _}, _headers, _body}} ->
+ {:error, {:github_status, status}}
+
+ {:error, reason} ->
+ {:error, reason}
+ end
+ end
+
+ defp decode_user_gists_body(body) do
+ case Jason.decode(body) do
+ {:ok, decoded} when is_list(decoded) ->
+ contents =
+ decoded
+ |> Enum.flat_map(&gist_file_raw_urls/1)
+ |> Enum.flat_map(&fetch_gist_raw_file/1)
+ |> Enum.join("\n")
+
+ {:ok, contents}
+
+ {:error, reason} ->
+ {:error, reason}
+
+ _ ->
+ {:error, :invalid_gist_response}
+ end
+ end
+
+ defp gist_file_raw_urls(%{"files" => files}) when is_map(files) do
+ files
+ |> Map.values()
+ |> Enum.map(fn file -> Map.get(file, "raw_url") end)
+ |> Enum.filter(&is_binary/1)
+ end
+
+ defp gist_file_raw_urls(_), do: []
+
+ defp fetch_gist_raw_file(raw_url) do
+ with {:ok, uri} <- safe_web_proof_uri(raw_url),
+ "gist.githubusercontent.com" <- String.downcase(uri.host),
+ {:ok, body} <- fetch_web_proof(uri) do
+ [body]
+ else
+ _ -> []
+ end
+ end
+
+ defp safe_web_proof_uri(url) when is_binary(url) do
+ uri = URI.parse(String.trim(url))
+
+ cond do
+ uri.scheme not in ["http", "https"] -> {:error, :unsupported_url_scheme}
+ is_nil(uri.host) or uri.host == "" -> {:error, :missing_host}
+ blocked_proof_host?(uri.host) -> {:error, :blocked_private_host}
+ true -> {:ok, uri}
+ end
+ end
+
+ defp safe_web_proof_uri(_), do: {:error, :invalid_url}
+
+ defp blocked_proof_host?(host) do
+ host = host |> String.trim_trailing(".") |> String.downcase()
+
+ cond do
+ host in ["localhost", "localhost.localdomain"] ->
+ true
+
+ String.ends_with?(host, ".local") ->
+ true
+
+ true ->
+ host
+ |> resolve_host_addresses()
+ |> case do
+ {:ok, addresses} -> Enum.any?(addresses, &blocked_address?/1)
+ {:error, _reason} -> true
+ end
+ end
+ end
+
+ defp resolve_host_addresses(host) do
+ host_chars = String.to_charlist(host)
+
+ addresses =
+ [:inet, :inet6]
+ |> Enum.flat_map(fn family ->
+ case :inet.getaddrs(host_chars, family) do
+ {:ok, values} -> values
+ {:error, _reason} -> []
+ end
+ end)
+ |> Enum.uniq()
+
+ case addresses do
+ [] -> {:error, :host_not_resolved}
+ values -> {:ok, values}
+ end
+ end
+
+ defp blocked_address?({10, _, _, _}), do: true
+ defp blocked_address?({127, _, _, _}), do: true
+ defp blocked_address?({0, _, _, _}), do: true
+ defp blocked_address?({169, 254, _, _}), do: true
+ defp blocked_address?({172, second, _, _}) when second in 16..31, do: true
+ defp blocked_address?({192, 168, _, _}), do: true
+ defp blocked_address?({100, second, _, _}) when second in 64..127, do: true
+ defp blocked_address?({192, 0, 0, _}), do: true
+ defp blocked_address?({192, 0, 2, _}), do: true
+ defp blocked_address?({198, 18, _, _}), do: true
+ defp blocked_address?({198, 19, _, _}), do: true
+ defp blocked_address?({198, 51, 100, _}), do: true
+ defp blocked_address?({203, 0, 113, _}), do: true
+ defp blocked_address?({224, _, _, _}), do: true
+ defp blocked_address?({a, _, _, _}) when a >= 225, do: true
+ defp blocked_address?({0, 0, 0, 0, 0, 0, 0, 1}), do: true
+ defp blocked_address?({0, 0, 0, 0, 0, 0, 0, 0}), do: true
+ defp blocked_address?({0xFE80, _, _, _, _, _, _, _}), do: true
+ defp blocked_address?({first, _, _, _, _, _, _, _}) when first in 0xFC00..0xFDFF, do: true
+ defp blocked_address?({0x2001, 0, _, _, _, _, _, _}), do: true
+ defp blocked_address?({0x2001, 0x0DB8, _, _, _, _, _, _}), do: true
+ defp blocked_address?(_address), do: false
+
+ defp fetch_web_proof(uri) do
+ request = Finch.build(:get, URI.to_string(uri), [{"user-agent", "Elektrine-Atomine-Proofs"}])
+
+ case SafeFetch.request(request, Elektrine.Finch,
+ receive_timeout: 5_000,
+ pool_timeout: 5_000,
+ max_body_bytes: @max_web_proof_bytes
+ ) do
+ {:ok, %Finch.Response{status: status, body: body}} when status in 200..299 -> {:ok, body}
+ {:ok, %Finch.Response{status: status}} -> {:error, {:http_status, status}}
+ {:error, reason} -> {:error, reason}
+ end
+ end
+
+ defp start_app(app) do
+ case Application.ensure_all_started(app) do
+ {:ok, _started} -> :ok
+ {:error, {:already_started, ^app}} -> :ok
+ {:error, _reason} -> :ok
+ end
+ end
+
+ defp proof_statement_valid?(%Proof{challenge: challenge} = proof) when is_binary(challenge) do
+ case parse_signed_proof_statement(challenge) do
+ {:ok, fields} ->
+ with :ok <- signed_fields_match_proof(fields, proof) do
+ payload =
+ signed_proof_payload(
+ fields["user_id"],
+ fields["user"] |> String.trim_leading("@"),
+ fields["kind"],
+ fields["subject"],
+ fields["nonce"]
+ )
+
+ if secure_compare(sign_proof_payload(payload), fields["sig"]) do
+ true
+ else
+ {:error, :invalid_proof_signature}
+ end
+ end
+
+ :older ->
+ true
+
+ {:error, reason} ->
+ {:error, reason}
+ end
+ end
+
+ defp proof_statement_valid?(%Proof{}), do: {:error, :invalid_proof_statement}
+
+ defp parse_signed_proof_statement("Atomine identity claim " <> rest) do
+ parse_signed_proof_statement_fields(rest)
+ end
+
+ defp parse_signed_proof_statement("Atomine personhood proof " <> rest) do
+ parse_signed_proof_statement_fields(rest)
+ end
+
+ defp parse_signed_proof_statement(_challenge), do: :older
+
+ defp parse_signed_proof_statement_fields(rest) do
+ case String.split(rest, " ", trim: true) do
+ [@proof_statement_version | encoded_fields] ->
+ fields =
+ Map.new(encoded_fields, fn field ->
+ case String.split(field, "=", parts: 2) do
+ [key, value] -> {key, decode_proof_field(value)}
+ [key] -> {key, ""}
+ end
+ end)
+
+ if Enum.all?(~w(user user_id kind subject nonce sig), &present_field?(fields, &1)) do
+ {:ok, fields}
+ else
+ {:error, :invalid_proof_statement}
+ end
+
+ _ ->
+ {:error, :unsupported_proof_statement}
+ end
+ end
+
+ defp signed_fields_match_proof(fields, proof) do
+ cond do
+ fields["user_id"] != to_string(proof.user_id) -> {:error, :proof_user_mismatch}
+ fields["kind"] != proof.kind -> {:error, :proof_kind_mismatch}
+ fields["subject"] != proof.subject -> {:error, :proof_subject_mismatch}
+ true -> :ok
+ end
+ end
+
+ defp present_field?(fields, key) do
+ case Map.get(fields, key) do
+ value when is_binary(value) -> String.trim(value) != ""
+ _ -> false
+ end
+ end
+
+ defp signed_proof_payload(user_id, handle, kind, subject, nonce) do
+ Enum.join(
+ [
+ "atomine-proof:#{@proof_statement_version}",
+ "user_id=#{user_id}",
+ "user=#{handle}",
+ "kind=#{kind}",
+ "subject=#{subject}",
+ "nonce=#{nonce}"
+ ],
+ "\n"
+ )
+ end
+
+ defp sign_proof_payload(payload) do
+ :crypto.mac(:hmac, :sha256, proof_signing_secret(), payload)
+ |> Base.url_encode64(padding: false)
+ end
+
+ defp proof_signing_secret do
+ Elektrine.RuntimeSecrets.secret_key_base() ||
+ if Elektrine.RuntimeEnv.dev_or_test?() do
+ "atomine-dev-proof-signing-secret"
+ else
+ raise "proof signing secret is not configured"
+ end
+ end
+
+ defp encode_proof_field(value), do: value |> to_string() |> URI.encode_www_form()
+
+ defp decode_proof_field(value) do
+ URI.decode_www_form(value)
+ rescue
+ ArgumentError -> value
+ end
+
+ defp secure_compare(left, right) when is_binary(left) and is_binary(right) do
+ byte_size(left) == byte_size(right) and Plug.Crypto.secure_compare(left, right)
+ end
+
+ defp secure_compare(_, _), do: false
+
+ defp mark_check_failed(%Proof{proof_mode: "live"} = proof, notes) do
+ proof
+ |> mark_live_stale(notes)
+ |> case do
+ {:ok, updated} -> {:error, {:not_found, updated}}
+ {:error, changeset} -> {:error, changeset}
+ end
+ end
+
+ defp mark_check_failed(%Proof{} = proof, notes) do
+ proof
+ |> Proof.changeset(%{checked_at: now(), review_notes: notes})
+ |> Repo.update()
+ |> case do
+ {:ok, updated} -> {:error, {:not_found, updated}}
+ {:error, changeset} -> {:error, changeset}
+ end
+ end
+
+ defp normalize_kind(kind) when is_binary(kind), do: kind |> String.trim() |> String.downcase()
+ defp normalize_kind(_), do: ""
+
+ defp normalize_subject_for_kind(subject, "dns") when is_binary(subject) do
+ subject |> String.trim() |> String.trim_trailing(".") |> String.downcase()
+ end
+
+ defp normalize_subject_for_kind(subject, _kind) when is_binary(subject),
+ do: String.trim(subject)
+
+ defp normalize_subject_for_kind(subject, _kind), do: subject
+
+ defp normalize_method(method) when is_binary(method),
+ do: method |> String.trim() |> String.downcase()
+
+ defp normalize_method(_), do: "manual"
+
+ defp normalize_proof_mode("live"), do: "live"
+ defp normalize_proof_mode(_), do: "snapshot"
+
+ defp normalize_limit(limit) when is_integer(limit), do: limit |> max(1) |> min(500)
+ defp normalize_limit(_), do: 100
+
+ defp default_method_for_kind("dns", _subject), do: "dns"
+ defp default_method_for_kind("web", _subject), do: "page"
+ defp default_method_for_kind("social", subject), do: social_method_for_subject(subject)
+ defp default_method_for_kind(_kind, _subject), do: "manual"
+
+ defp social_method_for_subject(subject) do
+ case github_profile_username(subject) do
+ {:ok, _username} -> "github_gist"
+ {:error, _reason} -> "page"
+ end
+ end
+
+ defp proof_metadata(metadata, verification_method, challenge) when is_map(metadata) do
+ Map.merge(
+ %{"verification_snippet" => challenge, "verification_method" => verification_method},
+ metadata
+ )
+ end
+
+ defp proof_metadata(_metadata, verification_method, challenge) do
+ %{"verification_snippet" => challenge, "verification_method" => verification_method}
+ end
+
+ defp initial_live_status("live"), do: "stale"
+ defp initial_live_status(_), do: nil
+
+ defp verified_live_status(%Proof{proof_mode: "live"}), do: "active"
+ defp verified_live_status(%Proof{} = proof), do: proof.live_status
+
+ defp grant_verified_proof_credits({:ok, %Proof{} = proof}) do
+ _ = CreditEarningPolicy.grant_for_verified_proof(proof)
+ {:ok, proof}
+ end
+
+ defp grant_verified_proof_credits(result), do: result
+
+ defp rejected_live_status(%Proof{proof_mode: "live"}), do: "inactive"
+ defp rejected_live_status(%Proof{} = proof), do: proof.live_status
+
+ defp revoked_live_status(%Proof{proof_mode: "live"}), do: "inactive"
+ defp revoked_live_status(%Proof{} = proof), do: proof.live_status
+
+ defp live_timestamp(%Proof{proof_mode: "live"}, now), do: now
+ defp live_timestamp(%Proof{} = proof, _now), do: proof.last_seen_at
+
+ defp next_check_at(%Proof{proof_mode: "live"}, now),
+ do: DateTime.add(now, @live_check_interval_days, :day)
+
+ defp next_check_at(%Proof{} = proof, _now), do: proof.next_check_at
+
+ defp stale_at(%Proof{proof_mode: "live"}, now),
+ do: DateTime.add(now, @live_stale_after_days, :day)
+
+ defp stale_at(%Proof{} = proof, _now), do: proof.stale_at
+
+ defp reviewer_id(%User{id: id}), do: id
+ defp reviewer_id(id) when is_integer(id), do: id
+ defp reviewer_id(_), do: nil
+
+ defp now, do: DateTime.utc_now() |> DateTime.truncate(:second)
+end
diff --git a/apps/atomine/lib/atomine/proof.ex b/apps/atomine/lib/atomine/proof.ex
new file mode 100644
index 0000000..8b5dde1
--- /dev/null
+++ b/apps/atomine/lib/atomine/proof.ex
@@ -0,0 +1,204 @@
+defmodule Atomine.Proof do
+ @moduledoc """
+ A proof signal used to establish that an account belongs to a real person.
+
+ Proofs are intentionally separate from `Accounts.User.verified`; they are
+ anti-bot/personhood signals that can be combined into a score.
+ """
+
+ use Ecto.Schema
+ import Ecto.Changeset
+
+ alias Elektrine.Security.SafeExternalURL
+
+ @kinds ~w(web dns social vouch payment passkey manual)
+ @claim_types ~w(positive negative)
+ @proof_modes ~w(snapshot live)
+ @live_statuses ~w(active stale inactive)
+ @verification_methods ~w(page dns github_gist email oauth manual none)
+ @statuses ~w(pending asserted verified rejected revoked)
+
+ schema "atomine_proofs" do
+ belongs_to :user, Elektrine.Accounts.User
+ field :kind, :string
+ field :claim_type, :string, default: "positive"
+ field :proof_mode, :string, default: "snapshot"
+ field :live_status, :string
+ field :verification_method, :string, default: "manual"
+ field :subject, :string
+ field :status, :string, default: "pending"
+ field :challenge, :string
+ field :evidence_url, :string
+ field :score_weight, :integer, default: 0
+ field :metadata, :map, default: %{}
+ field :checked_at, :utc_datetime
+ field :last_seen_at, :utc_datetime
+ field :next_check_at, :utc_datetime
+ field :stale_at, :utc_datetime
+ field :failed_check_count, :integer, default: 0
+ field :verified_at, :utc_datetime
+ field :rejected_at, :utc_datetime
+ field :revoked_at, :utc_datetime
+ belongs_to :reviewed_by_user, Elektrine.Accounts.User
+ field :review_notes, :string
+
+ timestamps(type: :utc_datetime)
+ end
+
+ def kinds, do: @kinds
+ def claim_types, do: @claim_types
+ def proof_modes, do: @proof_modes
+ def live_statuses, do: @live_statuses
+ def verification_methods, do: @verification_methods
+ def statuses, do: @statuses
+
+ def changeset(proof, attrs) do
+ proof
+ |> cast(attrs, [
+ :user_id,
+ :kind,
+ :claim_type,
+ :proof_mode,
+ :live_status,
+ :verification_method,
+ :subject,
+ :status,
+ :challenge,
+ :evidence_url,
+ :score_weight,
+ :metadata,
+ :checked_at,
+ :last_seen_at,
+ :next_check_at,
+ :stale_at,
+ :failed_check_count,
+ :verified_at,
+ :rejected_at,
+ :revoked_at,
+ :reviewed_by_user_id,
+ :review_notes
+ ])
+ |> normalize_string(:kind)
+ |> normalize_string(:claim_type)
+ |> normalize_string(:proof_mode)
+ |> normalize_string(:live_status)
+ |> normalize_string(:verification_method)
+ |> normalize_string(:status)
+ |> normalize_string(:evidence_url)
+ |> normalize_subject()
+ |> empty_to_nil(:evidence_url)
+ |> validate_required([
+ :user_id,
+ :kind,
+ :claim_type,
+ :proof_mode,
+ :verification_method,
+ :subject,
+ :status,
+ :challenge,
+ :score_weight
+ ])
+ |> validate_inclusion(:kind, @kinds)
+ |> validate_inclusion(:claim_type, @claim_types)
+ |> validate_inclusion(:proof_mode, @proof_modes)
+ |> validate_inclusion(:live_status, @live_statuses)
+ |> validate_inclusion(:verification_method, @verification_methods)
+ |> validate_inclusion(:status, @statuses)
+ |> validate_status_matches_claim_type()
+ |> validate_live_status_matches_mode()
+ |> validate_length(:subject, min: 1, max: 500)
+ |> validate_length(:challenge, min: 16, max: 2_000)
+ |> validate_length(:evidence_url, max: 2_000)
+ |> validate_evidence_url()
+ |> validate_length(:review_notes, max: 5_000)
+ |> validate_number(:score_weight, greater_than_or_equal_to: 0, less_than_or_equal_to: 100)
+ |> validate_number(:failed_check_count, greater_than_or_equal_to: 0)
+ |> foreign_key_constraint(:user_id)
+ |> foreign_key_constraint(:reviewed_by_user_id)
+ |> unique_constraint(:subject,
+ name: :atomine_proofs_active_dns_subject_unique,
+ message: "domain already has an active DNS proof"
+ )
+ |> unique_constraint(:subject,
+ name: :atomine_proofs_active_subject_unique,
+ message: "already has an active proof for this subject"
+ )
+ end
+
+ defp empty_to_nil(changeset, field) do
+ update_change(changeset, field, fn
+ "" -> nil
+ value -> value
+ end)
+ end
+
+ defp validate_evidence_url(changeset) do
+ case get_change(changeset, :evidence_url) do
+ nil ->
+ changeset
+
+ evidence_url ->
+ case SafeExternalURL.normalize_href(evidence_url) do
+ {:ok, safe_url} ->
+ put_change(changeset, :evidence_url, safe_url)
+
+ {:error, _reason} ->
+ add_error(changeset, :evidence_url, "must be a safe http:// or https:// URL")
+ end
+ end
+ end
+
+ defp validate_live_status_matches_mode(changeset) do
+ proof_mode = get_field(changeset, :proof_mode)
+ live_status = get_field(changeset, :live_status)
+
+ cond do
+ proof_mode == "live" and is_nil(live_status) ->
+ add_error(changeset, :live_status, "is required for live proofs")
+
+ proof_mode == "snapshot" and not is_nil(live_status) ->
+ add_error(changeset, :live_status, "must be empty for snapshot proofs")
+
+ true ->
+ changeset
+ end
+ end
+
+ defp validate_status_matches_claim_type(changeset) do
+ claim_type = get_field(changeset, :claim_type)
+ status = get_field(changeset, :status)
+
+ cond do
+ claim_type == "negative" and status == "verified" ->
+ add_error(changeset, :status, "negative assertions cannot be verified")
+
+ claim_type == "negative" and get_field(changeset, :score_weight) != 0 ->
+ add_error(changeset, :score_weight, "negative assertions cannot affect score")
+
+ true ->
+ changeset
+ end
+ end
+
+ defp normalize_string(changeset, field) do
+ update_change(changeset, field, fn
+ value when is_binary(value) -> value |> String.trim() |> String.downcase()
+ value -> value
+ end)
+ end
+
+ defp normalize_subject(changeset) do
+ kind = get_field(changeset, :kind)
+
+ update_change(changeset, :subject, fn
+ value when is_binary(value) and kind == "dns" ->
+ value |> String.trim() |> String.trim_trailing(".") |> String.downcase()
+
+ value when is_binary(value) ->
+ String.trim(value)
+
+ value ->
+ value
+ end)
+ end
+end
diff --git a/apps/atomine/lib/atomine/scoring.ex b/apps/atomine/lib/atomine/scoring.ex
new file mode 100644
index 0000000..8005690
--- /dev/null
+++ b/apps/atomine/lib/atomine/scoring.ex
@@ -0,0 +1,228 @@
+defmodule Atomine.Scoring do
+ @moduledoc """
+ Composite personhood scoring.
+
+ The score intentionally mixes proof strength, account age, security posture,
+ platform trust, and penalties. A single proof can help, but mature and healthy
+ accounts score better than freshly-created accounts with one weak signal.
+ """
+
+ import Ecto.Query, warn: false
+
+ alias Atomine.{Attestation, Proof}
+ alias Elektrine.Accounts.User
+ alias Elektrine.Repo
+
+ @max_score 100
+ @diversity_bonus_per_kind 5
+ @max_diversity_bonus 15
+
+ @doc "Returns a detailed personhood score breakdown for a user."
+ def breakdown(%User{} = user) do
+ proofs = verified_proofs(user.id)
+
+ positive = %{
+ proofs: proof_score(proofs),
+ proof_diversity: proof_diversity_bonus(proofs),
+ portable_effort: portable_effort_score(user.id),
+ continuity: continuity_score(user.id),
+ account_age: account_age_score(user),
+ security: security_score(user),
+ account_history: account_history_score(user),
+ platform_trust: platform_trust_score(user)
+ }
+
+ penalties = %{
+ account_restrictions: account_restriction_penalty(user),
+ onion_registration: onion_registration_penalty(user),
+ proof_rejections: proof_rejection_penalty(user.id)
+ }
+
+ raw_score = Enum.sum(Map.values(positive)) - Enum.sum(Map.values(penalties))
+ score = raw_score |> max(0) |> min(@max_score)
+
+ %{
+ score: score,
+ raw_score: raw_score,
+ level: level(score),
+ positive: positive,
+ penalties: penalties,
+ verified_proof_count: length(proofs),
+ verified_proof_kinds: proofs |> Enum.map(& &1.kind) |> Enum.uniq() |> Enum.sort()
+ }
+ end
+
+ def breakdown(user_id) when is_integer(user_id) do
+ case Repo.get(User, user_id) do
+ %User{} = user -> breakdown(user)
+ nil -> empty_breakdown()
+ end
+ end
+
+ def breakdown(_), do: empty_breakdown()
+
+ def level(score) when score >= 75, do: :high
+ def level(score) when score >= 40, do: :medium
+ def level(score) when score >= 15, do: :low
+ def level(_), do: :unknown
+
+ defp verified_proofs(user_id) do
+ Proof
+ |> where([p], p.user_id == ^user_id and p.claim_type == "positive" and p.status == "verified")
+ |> Repo.all()
+ end
+
+ defp proof_score(proofs) do
+ if Enum.any?(proofs, &(&1.kind == "manual" and &1.score_weight >= 100)) do
+ 100
+ else
+ proofs
+ |> Enum.reduce(0, fn proof, total -> total + weighted_proof_score(proof) end)
+ |> min(65)
+ end
+ end
+
+ defp weighted_proof_score(%Proof{proof_mode: "live", live_status: "active"} = proof),
+ do: proof.score_weight + 5
+
+ defp weighted_proof_score(%Proof{proof_mode: "live", live_status: "stale"} = proof),
+ do: div(proof.score_weight, 2)
+
+ defp weighted_proof_score(%Proof{proof_mode: "live", live_status: "inactive"}), do: 0
+
+ defp weighted_proof_score(%Proof{} = proof), do: proof.score_weight
+
+ defp proof_diversity_bonus(proofs) do
+ proofs
+ |> Enum.map(& &1.kind)
+ |> Enum.uniq()
+ |> length()
+ |> Kernel.-(1)
+ |> max(0)
+ |> Kernel.*(@diversity_bonus_per_kind)
+ |> min(@max_diversity_bonus)
+ end
+
+ defp portable_effort_score(user_id) do
+ user_id
+ |> active_attestations_query("pow_receipt")
+ |> Repo.all()
+ |> Enum.reduce(0, fn attestation, total -> total + effort_weight(attestation.difficulty) end)
+ |> min(15)
+ end
+
+ defp continuity_score(user_id) do
+ if Repo.exists?(active_attestations_query(user_id, "passkey_receipt")), do: 15, else: 0
+ end
+
+ defp active_attestations_query(user_id, kind) do
+ now = DateTime.utc_now() |> DateTime.truncate(:second)
+
+ from a in Attestation,
+ where:
+ a.user_id == ^user_id and a.kind == ^kind and a.status == "issued" and
+ a.expires_at >= ^now
+ end
+
+ defp effort_weight(difficulty) when is_integer(difficulty) and difficulty >= 24, do: 5
+ defp effort_weight(difficulty) when is_integer(difficulty) and difficulty >= 20, do: 3
+ defp effort_weight(difficulty) when is_integer(difficulty) and difficulty >= 16, do: 1
+ defp effort_weight(_), do: 0
+
+ defp account_age_score(%User{inserted_at: inserted_at}) do
+ days = account_age_days(inserted_at)
+
+ cond do
+ days >= 365 -> 20
+ days >= 180 -> 16
+ days >= 90 -> 12
+ days >= 30 -> 8
+ days >= 7 -> 4
+ true -> 0
+ end
+ end
+
+ defp security_score(user) do
+ recovery_email_score = if user.recovery_email_verified, do: 5, else: 0
+ two_factor_score = if user.two_factor_enabled, do: 10, else: 0
+ passkey_score = if has_verified_kind?(user.id, "passkey"), do: 5, else: 0
+
+ recovery_email_score + two_factor_score + passkey_score
+ end
+
+ defp account_history_score(user) do
+ login_score =
+ cond do
+ user.login_count >= 50 -> 10
+ user.login_count >= 10 -> 6
+ user.login_count >= 3 -> 3
+ true -> 0
+ end
+
+ seen_score = if user.last_seen_at || user.last_login_at, do: 5, else: 0
+ login_score + seen_score
+ end
+
+ defp platform_trust_score(user) do
+ cond do
+ user.is_admin -> 15
+ user.verified -> 10
+ user.trust_level >= 3 -> 12
+ user.trust_level >= 2 -> 8
+ user.trust_level >= 1 -> 4
+ true -> 0
+ end
+ end
+
+ defp account_restriction_penalty(user) do
+ banned = if user.banned, do: 100, else: 0
+ suspended = if user.suspended, do: 50, else: 0
+ email_restricted = if user.email_sending_restricted, do: 15, else: 0
+ banned + suspended + email_restricted
+ end
+
+ defp onion_registration_penalty(%User{registered_via_onion: true}), do: 5
+ defp onion_registration_penalty(_), do: 0
+
+ defp proof_rejection_penalty(user_id) do
+ rejected_count =
+ Proof
+ |> where([p], p.user_id == ^user_id and p.status in ["rejected", "revoked"])
+ |> Repo.aggregate(:count)
+
+ min(rejected_count * 10, 30)
+ end
+
+ defp has_verified_kind?(user_id, kind) do
+ Repo.exists?(
+ from p in Proof,
+ where:
+ p.user_id == ^user_id and p.kind == ^kind and p.claim_type == "positive" and
+ p.status == "verified"
+ )
+ end
+
+ defp account_age_days(%DateTime{} = inserted_at) do
+ DateTime.diff(DateTime.utc_now(), inserted_at, :day)
+ end
+
+ defp account_age_days(%NaiveDateTime{} = inserted_at) do
+ inserted_at
+ |> DateTime.from_naive!("Etc/UTC")
+ |> account_age_days()
+ end
+
+ defp account_age_days(_), do: 0
+
+ defp empty_breakdown do
+ %{
+ score: 0,
+ raw_score: 0,
+ level: :unknown,
+ positive: %{},
+ penalties: %{},
+ verified_proof_count: 0,
+ verified_proof_kinds: []
+ }
+ end
+end
diff --git a/apps/atomine/lib/atomine/trust_session.ex b/apps/atomine/lib/atomine/trust_session.ex
new file mode 100644
index 0000000..fe3a351
--- /dev/null
+++ b/apps/atomine/lib/atomine/trust_session.ex
@@ -0,0 +1,118 @@
+defmodule Atomine.TrustSession do
+ @moduledoc """
+ A short-lived trust decision session for checkout, signup, and other external flows.
+
+ Sessions can be attached to an Elektrine user or created for a guest/external
+ subject first, then upgraded later if the customer creates an account.
+ """
+
+ use Ecto.Schema
+ import Ecto.Changeset
+
+ @contexts ~w(checkout signup account_action api_access community_access seller_onboarding manual)
+ @statuses ~w(pending step_up completed expired cancelled)
+ @decisions ~w(allow step_up review block)
+ @step_ups ~w(passkey email proof manual none)
+ @levels ~w(unknown low medium high)
+
+ schema "atomine_trust_sessions" do
+ field :public_id, :string
+ belongs_to :user, Elektrine.Accounts.User
+ field :context, :string
+ field :merchant_id, :string
+ field :external_subject, :string
+ field :status, :string, default: "pending"
+ field :decision, :string, default: "review"
+ field :recommended_step_up, :string
+ field :score, :integer, default: 0
+ field :level, :string, default: "unknown"
+ field :signals, :map, default: %{}
+ field :metadata, :map, default: %{}
+ field :expires_at, :utc_datetime
+ field :completed_at, :utc_datetime
+
+ timestamps(type: :utc_datetime)
+ end
+
+ def contexts, do: @contexts
+ def statuses, do: @statuses
+ def decisions, do: @decisions
+ def step_ups, do: @step_ups
+ def levels, do: @levels
+
+ def changeset(session, attrs) do
+ session
+ |> cast(attrs, [
+ :public_id,
+ :user_id,
+ :context,
+ :merchant_id,
+ :external_subject,
+ :status,
+ :decision,
+ :recommended_step_up,
+ :score,
+ :level,
+ :signals,
+ :metadata,
+ :expires_at,
+ :completed_at
+ ])
+ |> normalize_string(:context)
+ |> normalize_string(:status)
+ |> normalize_string(:decision)
+ |> normalize_string(:recommended_step_up)
+ |> normalize_string(:level)
+ |> normalize_optional_string(:merchant_id)
+ |> normalize_optional_string(:external_subject)
+ |> put_public_id()
+ |> validate_required([:public_id, :context, :status, :decision, :score, :level])
+ |> validate_inclusion(:context, @contexts)
+ |> validate_inclusion(:status, @statuses)
+ |> validate_inclusion(:decision, @decisions)
+ |> validate_inclusion(:recommended_step_up, @step_ups)
+ |> validate_inclusion(:level, @levels)
+ |> validate_number(:score, greater_than_or_equal_to: 0, less_than_or_equal_to: 100)
+ |> validate_required_subject()
+ |> foreign_key_constraint(:user_id)
+ |> unique_constraint(:public_id)
+ end
+
+ defp validate_required_subject(changeset) do
+ if get_field(changeset, :user_id) || present?(get_field(changeset, :external_subject)) do
+ changeset
+ else
+ add_error(changeset, :external_subject, "or user is required")
+ end
+ end
+
+ defp put_public_id(changeset) do
+ case get_field(changeset, :public_id) do
+ value when is_binary(value) and value != "" -> changeset
+ _ -> put_change(changeset, :public_id, generate_public_id())
+ end
+ end
+
+ defp generate_public_id do
+ "ats_" <> (:crypto.strong_rand_bytes(18) |> Base.url_encode64(padding: false))
+ end
+
+ defp normalize_string(changeset, field) do
+ update_change(changeset, field, fn
+ value when is_binary(value) -> value |> String.trim() |> String.downcase()
+ value -> value
+ end)
+ end
+
+ defp normalize_optional_string(changeset, field) do
+ update_change(changeset, field, fn
+ value when is_binary(value) -> value |> String.trim() |> blank_to_nil()
+ value -> value
+ end)
+ end
+
+ defp blank_to_nil(""), do: nil
+ defp blank_to_nil(value), do: value
+
+ defp present?(value), do: is_binary(value) and String.trim(value) != ""
+end
diff --git a/apps/atomine/mix.exs b/apps/atomine/mix.exs
new file mode 100644
index 0000000..1714cd7
--- /dev/null
+++ b/apps/atomine/mix.exs
@@ -0,0 +1,39 @@
+defmodule Atomine.MixProject do
+ use Mix.Project
+
+ def project do
+ [
+ app: :atomine,
+ version: "0.1.0",
+ build_path: "../../_build",
+ config_path: "../../config/config.exs",
+ deps_path: "../../deps",
+ lockfile: "../../mix.lock",
+ elixir: "~> 1.19",
+ elixirc_paths: elixirc_paths(Mix.env()),
+ start_permanent: Mix.env() == :prod,
+ deps: deps()
+ ]
+ end
+
+ def application do
+ [extra_applications: [:logger, :crypto]]
+ end
+
+ defp elixirc_paths(:test), do: ["lib", "test/support"]
+ defp elixirc_paths(_), do: ["lib"]
+
+ defp deps do
+ [
+ internal_dep(:elektrine)
+ ]
+ end
+
+ defp internal_dep(app) do
+ if Mix.Project.umbrella?() do
+ {app, in_umbrella: true}
+ else
+ {app, path: "../#{app}"}
+ end
+ end
+end
diff --git a/apps/atomine/test/atomine/attestations_test.exs b/apps/atomine/test/atomine/attestations_test.exs
new file mode 100644
index 0000000..239caa3
--- /dev/null
+++ b/apps/atomine/test/atomine/attestations_test.exs
@@ -0,0 +1,147 @@
+defmodule Atomine.AttestationsTest do
+ use Elektrine.DataCase, async: true
+
+ alias Atomine.Attestation
+ alias Atomine.Attestations
+ alias Elektrine.Repo
+
+ describe "issuer_metadata/1" do
+ test "describes public verifier endpoints" do
+ metadata = Attestations.issuer_metadata("https://issuer.example")
+
+ assert metadata.protocol == "atomine-attestations"
+ assert metadata.version == "v1"
+ assert "gate_proof" in metadata.artifacts
+ assert "anonymous_effort_token" in metadata.artifacts
+
+ assert metadata.endpoints.verify_artifact ==
+ "https://issuer.example/api/atomine/artifacts/verify"
+
+ assert metadata.endpoints.spend_anonymous_token ==
+ "https://issuer.example/api/atomine/anonymous-tokens/spend"
+ end
+ end
+
+ describe "anonymous effort tokens" do
+ test "can be spent once against an audience" do
+ {:ok, challenge} = Attestations.issue_pow_challenge(difficulty: 0)
+
+ {:ok, token_attestation} =
+ Attestations.issue_anonymous_effort_token(%{
+ "challenge" => challenge["challenge"],
+ "solution" => "any-solution",
+ "gate_proof" => gate_proof(challenge["challenge"])
+ })
+
+ assert {:ok, redeemed} =
+ Attestations.redeem_anonymous_effort_token(token_attestation.artifact, %{
+ "audience" => "https://shop.example/signup",
+ "nonce" => "checkout-123"
+ })
+
+ assert redeemed.status == "redeemed"
+ assert redeemed.metadata["spend"]["audience"] == "https://shop.example/signup"
+ assert redeemed.metadata["spend"]["nonce"] == "checkout-123"
+
+ persisted = Repo.get!(Attestation, token_attestation.id)
+ assert persisted.metadata["spend"]["audience"] == "https://shop.example/signup"
+
+ assert {:error, :already_redeemed} =
+ Attestations.redeem_anonymous_effort_token(token_attestation.artifact, %{
+ "audience" => "https://other.example"
+ })
+ end
+
+ test "stores valid gate proof metadata with the issued token" do
+ {:ok, challenge} = Attestations.issue_pow_challenge(difficulty: 0)
+
+ {:ok, token_attestation} =
+ Attestations.issue_anonymous_effort_token(%{
+ "challenge" => challenge["challenge"],
+ "solution" => "any-solution",
+ "gate_proof" => gate_proof(challenge["challenge"])
+ })
+
+ assert token_attestation.metadata["gate_proof"]["version"] == "atomine-gate-v1"
+
+ assert token_attestation.metadata["gate_proof"]["layers"] == [
+ "pow",
+ "browser_instrumentation"
+ ]
+
+ checks = token_attestation.metadata["gate_proof"]["browser_instrumentation"]["checks"]
+
+ assert Enum.map(checks, & &1["name"]) == [
+ "layout.getComputedStyle",
+ "canvas.toDataURL",
+ "event.isTrusted",
+ "navigator.webdriver",
+ "dom.querySelector"
+ ]
+ end
+
+ test "rejects invalid gate proof when provided" do
+ {:ok, challenge} = Attestations.issue_pow_challenge(difficulty: 0)
+
+ assert {:error, :invalid_gate_proof} =
+ Attestations.issue_anonymous_effort_token(%{
+ "challenge" => challenge["challenge"],
+ "solution" => "any-solution",
+ "gate_proof" => %{
+ "version" => "atomine-gate-v1",
+ "layers" => ["pow", "browser_instrumentation"],
+ "browser_instrumentation" => %{
+ "challenge_hash" => "wrong",
+ "checks" => []
+ }
+ }
+ })
+ end
+
+ test "requires gate proof for anonymous effort tokens" do
+ {:ok, challenge} = Attestations.issue_pow_challenge(difficulty: 0)
+
+ assert {:error, :missing_gate_proof} =
+ Attestations.issue_anonymous_effort_token(%{
+ "challenge" => challenge["challenge"],
+ "solution" => "any-solution"
+ })
+ end
+
+ test "rejects invalid spend audiences" do
+ {:ok, challenge} = Attestations.issue_pow_challenge(difficulty: 0)
+
+ {:ok, token_attestation} =
+ Attestations.issue_anonymous_effort_token(%{
+ "challenge" => challenge["challenge"],
+ "solution" => "any-solution",
+ "gate_proof" => gate_proof(challenge["challenge"])
+ })
+
+ assert {:error, :invalid_audience} =
+ Attestations.redeem_anonymous_effort_token(token_attestation.artifact, %{
+ "audience" => ""
+ })
+ end
+ end
+
+ defp gate_proof(challenge) do
+ %{
+ "version" => "atomine-gate-v1",
+ "layers" => ["pow", "browser_instrumentation"],
+ "browser_instrumentation" => %{
+ "challenge_hash" => sha256_base64url(challenge),
+ "checks" =>
+ Enum.map(
+ ~w(layout.getComputedStyle canvas.toDataURL event.isTrusted navigator.webdriver dom.querySelector),
+ &%{"name" => &1, "ok" => true, "duration_ms" => 1}
+ ),
+ "signals" => %{"user_agent_hash" => sha256_base64url("test-browser")}
+ }
+ }
+ end
+
+ defp sha256_base64url(value) do
+ :crypto.hash(:sha256, value) |> Base.url_encode64(padding: false)
+ end
+end
diff --git a/apps/atomine/test/atomine/credits_test.exs b/apps/atomine/test/atomine/credits_test.exs
new file mode 100644
index 0000000..7f98da9
--- /dev/null
+++ b/apps/atomine/test/atomine/credits_test.exs
@@ -0,0 +1,74 @@
+defmodule Atomine.CreditsTest do
+ use Elektrine.DataCase, async: true
+
+ import Elektrine.AccountsFixtures
+
+ alias Atomine.{CreditAccount, CreditLedgerEntry, Credits, CreditSpend}
+ alias Elektrine.Repo
+
+ describe "grant/5" do
+ test "creates an account and records a positive ledger entry" do
+ user = user_fixture()
+
+ assert {:ok, ledger_entry} = Credits.grant(user.id, :atomine_credit, 3, "verified_passkey")
+
+ assert ledger_entry.amount == 3
+ assert ledger_entry.reason == "verified_passkey"
+ assert Credits.balance(user.id, :atomine_credit) == 3
+
+ account = Repo.get_by!(CreditAccount, user_id: user.id, credit_type: "atomine_credit")
+ assert account.balance == 3
+ assert account.lifetime_earned == 3
+ assert account.lifetime_spent == 0
+ end
+ end
+
+ describe "spend/6" do
+ test "debits the balance and records spend plus ledger rows" do
+ user = user_fixture()
+ assert {:ok, _} = Credits.grant(user.id, "atomine_credit", 2, "test_grant")
+
+ assert {:ok, spend} =
+ Credits.spend(user.id, :atomine_credit, 1, "first_dm", "user:#{user.id + 1}")
+
+ assert spend.amount == 1
+ assert spend.action == "first_dm"
+ assert Credits.balance(user.id, :atomine_credit) == 1
+
+ assert Repo.aggregate(CreditSpend, :count) == 1
+
+ assert [-1, 2] =
+ CreditLedgerEntry
+ |> order_by([e], desc: e.inserted_at, desc: e.id)
+ |> select([e], e.amount)
+ |> Repo.all()
+ end
+
+ test "rejects spends without enough credits" do
+ user = user_fixture()
+
+ assert {:error, :insufficient_credits} =
+ Credits.spend(user.id, :atomine_credit, 1, "first_dm", "user:999")
+
+ assert Credits.balance(user.id, :atomine_credit) == 0
+ assert Repo.aggregate(CreditSpend, :count) == 0
+ end
+
+ test "idempotency key prevents duplicate debits" do
+ user = user_fixture()
+ assert {:ok, _} = Credits.grant(user.id, :atomine_credit, 1, "test_grant")
+
+ opts = [idempotency_key: "first_dm:#{user.id}:user:999"]
+
+ assert {:ok, first_spend} =
+ Credits.spend(user.id, :atomine_credit, 1, "first_dm", "user:999", opts)
+
+ assert {:ok, second_spend} =
+ Credits.spend(user.id, :atomine_credit, 1, "first_dm", "user:999", opts)
+
+ assert second_spend.id == first_spend.id
+ assert Credits.balance(user.id, :atomine_credit) == 0
+ assert Repo.aggregate(CreditSpend, :count) == 1
+ end
+ end
+end
diff --git a/apps/atomine/test/atomine/personhood_test.exs b/apps/atomine/test/atomine/personhood_test.exs
new file mode 100644
index 0000000..3fa3487
--- /dev/null
+++ b/apps/atomine/test/atomine/personhood_test.exs
@@ -0,0 +1,680 @@
+defmodule Atomine.PersonhoodTest do
+ use Elektrine.DataCase, async: true
+
+ alias Atomine.{CreditEarningPolicy, Credits, Personhood, Proof}
+ alias Elektrine.Accounts
+ alias Elektrine.Repo
+
+ import Elektrine.AccountsFixtures
+
+ describe "create_proof/2" do
+ test "creates a pending proof with a generated challenge" do
+ user = user_fixture()
+
+ assert {:ok, proof} =
+ Personhood.create_proof(user, %{
+ kind: "web",
+ subject: "https://example.com/.well-known/elektrine.txt",
+ evidence_url: "https://example.com/.well-known/elektrine.txt"
+ })
+
+ assert proof.status == "pending"
+ assert proof.kind == "web"
+ assert proof.claim_type == "positive"
+ assert proof.proof_mode == "snapshot"
+ assert proof.live_status == nil
+ assert proof.verification_method == "page"
+ assert proof.score_weight == 20
+ assert proof.challenge =~ "Atomine identity claim"
+ assert proof.challenge =~ "kind=web"
+ assert proof.challenge =~ "sig="
+ assert proof.metadata["verification_snippet"] == proof.challenge
+ assert Personhood.page_snippet(proof) == proof.challenge
+ end
+
+ test "rejects unsafe evidence URLs" do
+ user = user_fixture()
+
+ for evidence_url <- [
+ "javascript:alert(1)",
+ "https://user:pass@example.com/proof",
+ "https://example.com\r\nLocation:https://evil.test",
+ "//example.com/proof"
+ ] do
+ assert {:error, changeset} =
+ Personhood.create_proof(user, %{
+ kind: "manual",
+ subject: "manual-#{System.unique_integer([:positive])}",
+ evidence_url: evidence_url
+ })
+
+ assert %{evidence_url: ["must be a safe http:// or https:// URL"]} =
+ errors_on(changeset)
+ end
+ end
+
+ test "creates signed challenge statements" do
+ user = user_fixture()
+
+ assert {:ok, proof} =
+ Personhood.create_proof(user, %{
+ kind: "dns",
+ subject: "example.com"
+ })
+
+ assert proof.challenge =~ "Atomine identity claim v1"
+ assert proof.challenge =~ "user=%40"
+ assert proof.challenge =~ "subject=example.com"
+ assert proof.challenge =~ "nonce="
+ assert proof.challenge =~ "sig="
+ end
+
+ test "signs DNS claims with the normalized subject" do
+ user = user_fixture()
+
+ assert {:ok, proof} =
+ Personhood.create_proof(user, %{
+ kind: "dns",
+ subject: " Example.COM. "
+ })
+
+ assert proof.subject == "example.com"
+ assert proof.challenge =~ "subject=example.com"
+ end
+
+ test "prevents duplicate active proofs for the same subject" do
+ user = user_fixture()
+ attrs = %{kind: "dns", subject: "example.com"}
+
+ assert {:ok, _proof} = Personhood.create_proof(user, attrs)
+ assert {:error, changeset} = Personhood.create_proof(user, attrs)
+ assert %{subject: ["already has an active proof for this subject"]} = errors_on(changeset)
+ end
+
+ test "prevents active DNS proofs for a domain already claimed by another user" do
+ first_user = user_fixture()
+ second_user = user_fixture()
+
+ assert {:ok, _proof} =
+ Personhood.create_proof(first_user, %{kind: "dns", subject: "Example.COM."})
+
+ assert {:error, changeset} =
+ Personhood.create_proof(second_user, %{kind: "dns", subject: "example.com"})
+
+ assert %{subject: ["domain already has an active DNS proof"]} = errors_on(changeset)
+ end
+
+ test "creates DNS claims with TXT record instructions" do
+ user = user_fixture()
+ {:ok, proof} = Personhood.create_proof(user, %{kind: "dns", subject: "example.com"})
+
+ assert proof.verification_method == "dns"
+ assert {"_atomine", challenge} = Personhood.dns_txt_record(proof)
+ assert challenge == proof.challenge
+ end
+
+ test "creates live proofs that are stale until first successful check" do
+ user = user_fixture()
+
+ {:ok, proof} =
+ Personhood.create_proof(user, %{
+ kind: "web",
+ subject: "https://example.com/profile",
+ proof_mode: "live"
+ })
+
+ assert proof.proof_mode == "live"
+ assert proof.live_status == "stale"
+ assert proof.next_check_at == nil
+ end
+
+ test "uses GitHub gist verification for GitHub profile URLs" do
+ user = user_fixture()
+
+ assert {:ok, proof} =
+ Personhood.create_proof(user, %{
+ kind: "social",
+ subject: "https://github.com/businessfunk"
+ })
+
+ assert proof.verification_method == "github_gist"
+ assert proof.challenge =~ "kind=social"
+
+ assert proof.challenge =~ "subject=https%3A%2F%2Fgithub.com%2Fbusinessfunk"
+ end
+
+ test "falls back to page verification for other social URLs" do
+ user = user_fixture()
+
+ assert {:ok, proof} =
+ Personhood.create_proof(user, %{
+ kind: "social",
+ subject: "https://social.example/@human"
+ })
+
+ assert proof.verification_method == "page"
+ end
+
+ test "creates negative assertions that cannot affect personhood score" do
+ user = user_fixture()
+
+ assert {:ok, assertion} =
+ Personhood.create_negative_assertion(user, %{
+ kind: "social",
+ subject: "https://twitter.com/not-me"
+ })
+
+ assert assertion.claim_type == "negative"
+ assert assertion.status == "asserted"
+ assert assertion.verification_method == "none"
+ assert assertion.score_weight == 0
+ assert Personhood.personhood_score(user) == 0
+ end
+ end
+
+ describe "review lifecycle" do
+ test "verified proofs count toward score and revoked proofs stop counting" do
+ user = user_fixture()
+ reviewer = user_fixture()
+
+ {:ok, proof} = Personhood.create_proof(user, %{kind: "dns", subject: "example.com"})
+ assert Personhood.personhood_score(user) == 0
+ assert Personhood.personhood_level(user) == :unknown
+
+ assert {:ok, verified} = Personhood.verify_proof(proof, reviewer, "TXT record matched")
+ assert verified.status == "verified"
+ assert verified.checked_at
+ assert verified.verified_at
+ assert verified.reviewed_by_user_id == reviewer.id
+ assert Personhood.personhood_score(user) == 25
+ assert Personhood.personhood_level(user) == :low
+
+ assert {:ok, revoked} = Personhood.revoke_proof(verified, reviewer, "domain changed hands")
+ assert revoked.status == "revoked"
+ assert revoked.revoked_at
+ assert Personhood.personhood_score(user) == 0
+ end
+
+ test "verified proofs grant Atomine Credits once" do
+ user = user_fixture()
+
+ {:ok, proof} = Personhood.create_proof(user, %{kind: "dns", subject: "example.com"})
+ assert Credits.balance(user.id, :atomine_credit) == 0
+
+ assert {:ok, verified} = Personhood.verify_proof(proof)
+ assert Credits.balance(user.id, :atomine_credit) == 10
+
+ assert {:ok, _verified_again} = Personhood.verify_proof(verified)
+ assert Credits.balance(user.id, :atomine_credit) == 10
+
+ assert [%{amount: 10, reason: "verified_proof:dns"}] =
+ Credits.list_ledger_entries(user.id, limit: 1)
+ end
+
+ test "deleting and recreating the same DNS proof does not grant twice" do
+ user = user_fixture()
+
+ {:ok, proof} = Personhood.create_proof(user, %{kind: "dns", subject: "Example.COM."})
+ assert {:ok, verified} = Personhood.verify_proof(proof)
+ assert Credits.balance(user.id, :atomine_credit) == 10
+
+ assert {:ok, _deleted} = Personhood.delete_proof(verified)
+
+ {:ok, recreated} = Personhood.create_proof(user, %{kind: "dns", subject: "example.com"})
+ assert {:ok, _verified_again} = Personhood.verify_proof(recreated)
+
+ assert Credits.balance(user.id, :atomine_credit) == 10
+
+ assert [%{reason: "verified_proof:dns"}] =
+ Credits.list_ledger_entries(user.id)
+ |> Enum.filter(&(&1.reason == "verified_proof:dns"))
+ end
+
+ test "profile URL variants share one credit reward" do
+ user = user_fixture()
+
+ {:ok, proof} =
+ Personhood.create_proof(user, %{
+ kind: "social",
+ subject: "https://github.com/businessfunk?utm_source=test#readme"
+ })
+
+ assert {:ok, verified} = Personhood.verify_proof(proof)
+ assert Credits.balance(user.id, :atomine_credit) == 5
+
+ assert {:ok, _deleted} = Personhood.delete_proof(verified)
+
+ {:ok, recreated} =
+ Personhood.create_proof(user, %{
+ kind: "social",
+ subject: "https://github.com/businessfunk/"
+ })
+
+ assert {:ok, _verified_again} = Personhood.verify_proof(recreated)
+ assert Credits.balance(user.id, :atomine_credit) == 5
+ end
+
+ test "a DNS claim already rewarded globally does not reward another account" do
+ first_user = user_fixture()
+ second_user = user_fixture()
+
+ {:ok, proof} =
+ Personhood.create_proof(first_user, %{kind: "dns", subject: "shared.example"})
+
+ assert {:ok, verified} = Personhood.verify_proof(proof)
+ assert Credits.balance(first_user.id, :atomine_credit) == 10
+
+ assert {:ok, _deleted} = Personhood.delete_proof(verified)
+
+ {:ok, second_proof} =
+ Personhood.create_proof(second_user, %{kind: "dns", subject: "shared.example"})
+
+ assert {:ok, _second_verified} = Personhood.verify_proof(second_proof)
+ assert Credits.balance(second_user.id, :atomine_credit) == 0
+ end
+
+ test "low-trust accounts cannot exceed the daily proof earning cap" do
+ user = user_fixture()
+
+ {:ok, dns_proof} =
+ Personhood.create_proof(user, %{kind: "dns", subject: "daily-cap.example"})
+
+ {:ok, web_proof} =
+ Personhood.create_proof(user, %{
+ kind: "web",
+ subject: "https://daily-cap.example/proof"
+ })
+
+ {:ok, social_proof} =
+ Personhood.create_proof(user, %{
+ kind: "social",
+ subject: "https://social.example/daily-cap"
+ })
+
+ assert {:ok, _dns} = Personhood.verify_proof(dns_proof)
+ assert {:ok, _web} = Personhood.verify_proof(web_proof)
+ assert {:ok, _social} = Personhood.verify_proof(social_proof)
+
+ assert Credits.balance(user.id, :atomine_credit) == 18
+ end
+
+ test "proof-of-work credits can be claimed up to 20 times per day" do
+ user = user_fixture()
+ today = Date.utc_today()
+
+ for _ <- 1..20 do
+ assert {:ok, _entry} = CreditEarningPolicy.grant_for_proof_of_work(user.id, date: today)
+ end
+
+ assert {:ok, :daily_claim_limit_reached} =
+ CreditEarningPolicy.grant_for_proof_of_work(user.id, date: today)
+
+ assert Credits.balance(user.id, :atomine_credit) == 20
+ end
+
+ test "live proofs can become active, stale, and inactive" do
+ user = user_fixture()
+
+ {:ok, proof} =
+ Personhood.create_proof(user, %{
+ kind: "dns",
+ subject: "live.example.com",
+ proof_mode: "live"
+ })
+
+ assert {:ok, active} = Personhood.verify_proof(proof)
+ assert active.status == "verified"
+ assert active.live_status == "active"
+ assert active.last_seen_at
+ assert active.next_check_at
+ assert active.stale_at
+ assert active.failed_check_count == 0
+ assert Personhood.personhood_score(user) == 30
+
+ assert {:ok, stale} = Personhood.mark_live_stale(active, "check timed out")
+ assert stale.live_status == "stale"
+ assert stale.failed_check_count == 1
+ assert Personhood.personhood_score(user) == 12
+
+ assert {:ok, inactive} = Personhood.mark_live_inactive(stale, "snippet missing")
+ assert inactive.live_status == "inactive"
+ assert inactive.failed_check_count == 2
+ assert Personhood.personhood_score(user) == 0
+ end
+
+ test "manual proof can satisfy a high anti-bot gate" do
+ user = user_fixture()
+ {:ok, proof} = Personhood.create_proof(user, %{kind: "manual", subject: "admin-review"})
+ {:ok, _verified} = Personhood.verify_proof(proof)
+
+ assert Personhood.personhood_score(user.id) == 100
+ assert Personhood.personhood_level(user.id) == :high
+ assert Personhood.sufficiently_human?(user.id, 75)
+ end
+
+ test "check_proof reports unsupported self-check proof types" do
+ user = user_fixture()
+ {:ok, proof} = Personhood.create_proof(user, %{kind: "manual", subject: "admin-review"})
+
+ assert {:error, :manual_review_required} = Personhood.check_proof(proof)
+ end
+
+ test "check_proof records failed web checks without verifying" do
+ user = user_fixture()
+
+ {:ok, proof} =
+ Personhood.create_proof(user, %{
+ kind: "web",
+ subject: "ftp://example.com/proof"
+ })
+
+ assert {:error, {:not_found, checked}} = Personhood.check_proof(proof)
+ assert checked.status == "pending"
+ assert checked.checked_at
+ assert checked.review_notes =~ "Web check failed"
+ assert Personhood.personhood_score(user) == 0
+ end
+
+ test "check_proof blocks private web proof hosts" do
+ user = user_fixture()
+
+ {:ok, proof} =
+ Personhood.create_proof(user, %{
+ kind: "web",
+ subject: "http://10.0.0.1/proof"
+ })
+
+ assert {:error, {:not_found, checked}} = Personhood.check_proof(proof)
+ assert checked.status == "pending"
+ assert checked.review_notes =~ "blocked_private_host"
+ end
+
+ test "check_proof reports failed live checks instead of returning success" do
+ user = user_fixture()
+
+ {:ok, proof} =
+ Personhood.create_proof(user, %{
+ kind: "web",
+ subject: "ftp://example.com/proof",
+ proof_mode: "live"
+ })
+
+ assert {:error, {:not_found, checked}} = Personhood.check_proof(proof)
+ assert checked.status == "pending"
+ assert checked.live_status == "stale"
+ assert checked.failed_check_count == 1
+ assert checked.checked_at
+ assert checked.review_notes =~ "Web check failed"
+ assert Personhood.personhood_score(user) == 0
+ end
+
+ test "lists live proofs due for scheduled recheck" do
+ user = user_fixture()
+
+ {:ok, proof} =
+ Personhood.create_proof(user, %{
+ kind: "dns",
+ subject: "due.example.com",
+ proof_mode: "live"
+ })
+
+ {:ok, verified} = Personhood.verify_proof(proof)
+
+ due_at = DateTime.utc_now() |> DateTime.add(-60, :second) |> DateTime.truncate(:second)
+
+ verified
+ |> Proof.changeset(%{next_check_at: due_at})
+ |> Repo.update!()
+
+ assert [%Proof{id: id}] = Personhood.list_due_live_proofs()
+ assert id == verified.id
+ end
+
+ test "check_proof rejects tampered signed statements" do
+ user = user_fixture()
+
+ {:ok, proof} =
+ Personhood.create_proof(user, %{
+ kind: "web",
+ subject: "https://example.com/proof"
+ })
+
+ {:ok, proof} =
+ proof
+ |> Proof.changeset(%{challenge: String.replace(proof.challenge, "kind=web", "kind=dns")})
+ |> Repo.update()
+
+ assert {:error, {:not_found, checked}} = Personhood.check_proof(proof)
+ assert checked.status == "pending"
+ assert checked.review_notes =~ "proof_kind_mismatch"
+ end
+
+ test "check_proof rejects non-GitHub profile URLs for GitHub gist verification" do
+ user = user_fixture()
+
+ {:ok, proof} =
+ Personhood.create_proof(user, %{
+ kind: "social",
+ subject: "https://social.example/businessfunk",
+ verification_method: "github_gist"
+ })
+
+ assert {:error, {:not_found, checked}} = Personhood.check_proof(proof)
+ assert checked.status == "pending"
+ assert checked.review_notes =~ "not_github_profile_url"
+ end
+ end
+
+ describe "composite scoring" do
+ test "combines proof strength, diversity, age, security, history, and trust" do
+ user =
+ user_fixture()
+ |> backdate_user(200)
+ |> update_user!(%{
+ login_count: 25,
+ last_seen_at: DateTime.utc_now() |> DateTime.truncate(:second),
+ recovery_email: "human@example.com",
+ recovery_email_verified: true,
+ two_factor_enabled: true,
+ trust_level: 2
+ })
+
+ {:ok, dns} = Personhood.create_proof(user, %{kind: "dns", subject: "example.com"})
+
+ {:ok, social} =
+ Personhood.create_proof(user, %{kind: "social", subject: "https://social.example/@human"})
+
+ {:ok, passkey} =
+ Personhood.create_proof(user, %{kind: "passkey", subject: "credential:primary"})
+
+ {:ok, _} = Personhood.verify_proof(dns)
+ {:ok, _} = Personhood.verify_proof(social)
+ {:ok, _} = Personhood.verify_proof(passkey)
+
+ breakdown = Personhood.personhood_breakdown(user)
+
+ assert breakdown.score == 100
+ assert breakdown.level == :high
+ assert breakdown.positive.proofs == 60
+ assert breakdown.positive.proof_diversity == 10
+ assert breakdown.positive.account_age == 16
+ assert breakdown.positive.security == 20
+ assert breakdown.positive.account_history == 11
+ assert breakdown.positive.platform_trust == 8
+ assert breakdown.verified_proof_kinds == ["dns", "passkey", "social"]
+ end
+
+ test "applies account health and rejected proof penalties" do
+ user =
+ user_fixture()
+ |> backdate_user(30)
+ |> update_user!(%{
+ banned: true,
+ email_sending_restricted: true,
+ registered_via_onion: true
+ })
+
+ {:ok, web} = Personhood.create_proof(user, %{kind: "web", subject: "https://site.example"})
+
+ {:ok, social} =
+ Personhood.create_proof(user, %{kind: "social", subject: "https://social.example/@bot"})
+
+ {:ok, _} = Personhood.verify_proof(web)
+ {:ok, _} = Personhood.reject_proof(social)
+
+ breakdown = Personhood.personhood_breakdown(user)
+
+ assert breakdown.positive.proofs == 20
+ assert breakdown.positive.account_age == 8
+ assert breakdown.penalties.account_restrictions == 115
+ assert breakdown.penalties.onion_registration == 5
+ assert breakdown.penalties.proof_rejections == 10
+ assert breakdown.score == 0
+ assert breakdown.level == :unknown
+ end
+ end
+
+ describe "list_pending_proofs/0" do
+ test "returns pending proofs oldest first" do
+ user = user_fixture()
+ {:ok, pending} = Personhood.create_proof(user, %{kind: "web", subject: "https://a.test"})
+
+ {:ok, verified} =
+ Personhood.create_proof(user, %{kind: "social", subject: "https://b.test"})
+
+ {:ok, _verified} = Personhood.verify_proof(verified)
+
+ assert [^pending] = Personhood.list_pending_proofs()
+ assert %Proof{} = Repo.get!(Proof, pending.id)
+ end
+ end
+
+ describe "trust sessions" do
+ test "creates a guest checkout trust session" do
+ assert {:ok, session} =
+ Personhood.create_trust_session(%{
+ context: "checkout",
+ merchant_id: "shop_123",
+ external_subject: "buyer@example.com",
+ signals: %{"email_verified" => true}
+ })
+
+ assert session.public_id =~ "ats_"
+ assert session.user_id == nil
+ assert session.context == "checkout"
+ assert session.merchant_id == "shop_123"
+ assert session.external_subject == "buyer@example.com"
+ assert session.status == "pending"
+ assert session.decision == "review"
+ assert session.recommended_step_up == "proof"
+ assert session.score == 0
+ assert session.level == "unknown"
+ assert session.expires_at
+ assert Personhood.get_trust_session(session.public_id).id == session.id
+ end
+
+ test "creates a known-user trust session from the user's personhood score" do
+ user = user_fixture() |> backdate_user(120)
+ {:ok, proof} = Personhood.create_proof(user, %{kind: "dns", subject: "example.com"})
+ {:ok, _proof} = Personhood.verify_proof(proof)
+
+ assert {:ok, session} =
+ Personhood.create_trust_session(user, %{
+ context: "checkout",
+ merchant_id: "shop_456"
+ })
+
+ assert session.user_id == user.id
+ assert session.score >= 15
+ assert session.decision == "step_up"
+ assert session.status == "step_up"
+ assert session.recommended_step_up == "passkey"
+ assert session.level == "low"
+ end
+
+ test "updates and completes trust sessions" do
+ {:ok, session} =
+ Personhood.create_trust_session(%{
+ context: "signup",
+ external_subject: "new@example.com"
+ })
+
+ assert {:ok, stepped_up} =
+ Personhood.update_trust_session(session, %{
+ status: "step_up",
+ decision: "step_up",
+ recommended_step_up: "passkey",
+ score: 22,
+ level: "low"
+ })
+
+ assert stepped_up.status == "step_up"
+ assert stepped_up.recommended_step_up == "passkey"
+
+ assert {:ok, completed} =
+ Personhood.complete_trust_session(stepped_up, %{
+ decision: "allow",
+ recommended_step_up: "none",
+ score: 55,
+ level: "medium"
+ })
+
+ assert completed.status == "completed"
+ assert completed.decision == "allow"
+ assert completed.completed_at
+ end
+
+ test "lists trust sessions with filters" do
+ user = user_fixture()
+ {:ok, user_session} = Personhood.create_trust_session(user, %{context: "checkout"})
+
+ {:ok, guest_session} =
+ Personhood.create_trust_session(%{
+ context: "signup",
+ merchant_id: "shop_filter",
+ external_subject: "guest@example.com"
+ })
+
+ assert [^user_session] = Personhood.list_trust_sessions(user_id: user.id)
+ assert [^guest_session] = Personhood.list_trust_sessions(merchant_id: "shop_filter")
+ assert guest_session in Personhood.list_trust_sessions(status: "pending")
+ end
+ end
+
+ describe "connected account proofs" do
+ test "creates a verified OAuth proof from a reusable connected account" do
+ user = user_fixture()
+
+ {:ok, connected_account} =
+ Accounts.upsert_connected_account(user, %{
+ provider: "github",
+ provider_account_id: "12345",
+ username: "octo",
+ display_name: "Octo",
+ profile_url: "https://github.com/octo"
+ })
+
+ assert {:ok, proof} = Personhood.verify_connected_account_proof(connected_account)
+ assert proof.status == "verified"
+ assert proof.verification_method == "oauth"
+ assert proof.subject == "oauth:github:12345"
+ assert proof.evidence_url == "https://github.com/octo"
+ assert proof.metadata["connected_account_id"] == connected_account.id
+ assert Personhood.personhood_score(user) > 0
+ end
+ end
+
+ defp backdate_user(user, days) do
+ inserted_at = DateTime.utc_now() |> DateTime.add(-days, :day) |> DateTime.truncate(:second)
+
+ user
+ |> Ecto.Changeset.change(inserted_at: inserted_at)
+ |> Repo.update!()
+ end
+
+ defp update_user!(user, attrs) do
+ user
+ |> Ecto.Changeset.change(attrs)
+ |> Repo.update!()
+ end
+end
diff --git a/apps/atomine/test/test_helper.exs b/apps/atomine/test/test_helper.exs
new file mode 100644
index 0000000..b9db890
--- /dev/null
+++ b/apps/atomine/test/test_helper.exs
@@ -0,0 +1,2 @@
+ExUnit.start()
+Ecto.Adapters.SQL.Sandbox.mode(Elektrine.Repo, :manual)
diff --git a/apps/elektrine/.gitignore b/apps/elektrine/.gitignore
new file mode 100644
index 0000000..09b88f8
--- /dev/null
+++ b/apps/elektrine/.gitignore
@@ -0,0 +1,24 @@
+# Elixir build/test artifacts
+/_build/
+/deps/
+/cover/
+/.elixir_ls/
+/tmp/
+erl_crash.dump
+
+# Asset build artifacts
+/assets/node_modules/
+/assets/.sass-cache/
+/assets/npm-debug.log*
+/assets/yarn-debug.log*
+/assets/yarn-error.log*
+/priv/static/assets/
+/priv/static/cache_manifest.json
+/priv/static/uploads/
+/priv/static/**/*.gz
+/priv/static/**/*-[0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f].*
+
+# Generated runtime data
+/priv/acme/
+/priv/exports/
+/priv/imap_rate_limit_*.ets
diff --git a/apps/elektrine/README.md b/apps/elektrine/README.md
new file mode 100644
index 0000000..e75fc44
--- /dev/null
+++ b/apps/elektrine/README.md
@@ -0,0 +1,24 @@
+# Elektrine Core
+
+Core domain app for the Elektrine umbrella.
+
+## What lives here
+
+- Accounts, auth, and privacy settings
+- Shared persistence and runtime services consumed by the other umbrella apps
+- ActivityPub domain logic and delivery workers
+- Calendar, contacts, profiles, notifications, and uploads
+- Module selection and shared platform wiring
+
+This app still carries a large share of the cross-product domain code. Other
+apps depend on it for `Elektrine.Repo`, shared schemas, and shared services.
+
+## Notes
+
+- `elektrine` is the main dependency for the feature apps.
+- Migrations for core domain tables live under `apps/elektrine/priv/repo/migrations`.
+- Chat call sites should prefer `Arblarg` over reaching into `Elektrine.Messaging` directly.
+
+## License
+
+AGPL-3.0-only (see `../../LICENSE`).
diff --git a/apps/elektrine/assets/css/animations.css b/apps/elektrine/assets/css/animations.css
new file mode 100644
index 0000000..8ec8ad7
--- /dev/null
+++ b/apps/elektrine/assets/css/animations.css
@@ -0,0 +1,99 @@
+/* ==========================================================================
+ ANIMATIONS - All @keyframes and animation classes
+ ========================================================================== */
+
+/* --------------------------------------------------------------------------
+ REDUCED MOTION - Respect user preferences
+ -------------------------------------------------------------------------- */
+
+@media (prefers-reduced-motion: reduce) {
+ *,
+ *::before,
+ *::after {
+ animation-duration: 0.01ms !important;
+ animation-iteration-count: 1 !important;
+ transition-duration: 0.01ms !important;
+ scroll-behavior: auto !important;
+ }
+
+ .marquee-container {
+ animation: none !important;
+ }
+}
+
+/* --------------------------------------------------------------------------
+ KEYFRAMES
+ -------------------------------------------------------------------------- */
+
+@keyframes fade-in {
+ from { opacity: 0; transform: scale(0.95); }
+ to { opacity: 1; transform: scale(1); }
+}
+
+@keyframes highlight-fade {
+ 0% { background-color: rgba(59, 130, 246, 0.2); }
+ 100% { background-color: transparent; }
+}
+
+@keyframes marquee {
+ from { transform: translate3d(0, 0, 0); }
+ to { transform: translate3d(-50%, 0, 0); }
+}
+
+@keyframes cyber-glow {
+ 0% { box-shadow: 0 0 8px rgba(18, 159, 184, 0.55), 0 0 12px rgba(18, 159, 184, 0.26); }
+ 50% { box-shadow: 0 0 12px rgba(44, 92, 224, 0.46), 0 0 16px rgba(18, 159, 184, 0.34); }
+ 100% { box-shadow: 0 0 8px rgba(18, 159, 184, 0.55), 0 0 12px rgba(18, 159, 184, 0.26); }
+}
+
+@keyframes blink {
+ 0%, 50% { opacity: 1; }
+ 51%, 100% { opacity: 0; }
+}
+
+/* --------------------------------------------------------------------------
+ ANIMATION CLASSES
+ -------------------------------------------------------------------------- */
+
+.animate-fade-in {
+ animation: fade-in 0.15s ease-out;
+}
+
+.highlight-message {
+ animation: highlight-fade 2s ease-in-out;
+}
+
+.rainbow-glow-hover:hover,
+.rainbow-glow-hover:focus {
+ animation: cyber-glow 2s ease-in-out infinite;
+}
+
+/* --------------------------------------------------------------------------
+ MARQUEE
+ -------------------------------------------------------------------------- */
+
+.marquee-viewport:hover .marquee-container,
+.marquee-viewport:focus-within .marquee-container {
+ animation-play-state: paused;
+}
+
+.marquee-container {
+ --marquee-gap: 0.75rem;
+ --marquee-duration: 40s;
+ display: flex;
+ align-items: center;
+ width: max-content;
+ min-width: max-content;
+ will-change: transform;
+ transform: translate3d(0, 0, 0);
+ backface-visibility: hidden;
+ animation: marquee var(--marquee-duration) linear infinite;
+}
+
+.marquee-content {
+ display: flex;
+ align-items: center;
+ gap: var(--marquee-gap);
+ flex: 0 0 auto;
+ padding-right: var(--marquee-gap);
+}
diff --git a/apps/elektrine/assets/css/app.css b/apps/elektrine/assets/css/app.css
new file mode 100644
index 0000000..7b3ee1f
--- /dev/null
+++ b/apps/elektrine/assets/css/app.css
@@ -0,0 +1,40 @@
+/* ==========================================================================
+ ELEKTRINE - Main Stylesheet
+ ==========================================================================
+
+ Structure:
+ - base.css : Reset, fonts, theme variables, grid backgrounds
+ - components.css: Buttons, inputs, cards, navbar, dropdowns, etc.
+ - animations.css: All @keyframes and animation classes
+ - email.css : Email content styling
+ - effects.css : Username, avatar, link, and visual effects
+ - utilities.css : Helpers, touch targets, typography, chat, misc
+
+ ========================================================================== */
+
+/* Tailwind & DaisyUI */
+@import "tailwindcss";
+@plugin "daisyui" {
+ /* DaisyUI's radial-progress support emits an @property rule that Lightning CSS warns about. */
+ exclude: properties, radialprogress;
+}
+@config "../tailwind.config.mjs";
+
+/* Design tokens: extra type-scale steps below text-xs.
+ Font-size only (no line-height sub-token) so these behave exactly like the
+ text-[11px] / text-[10px] arbitrary values they replace. */
+@theme {
+ --text-2xs: 0.6875rem;
+ --text-3xs: 0.625rem;
+}
+
+/* Vendor styles */
+@import "../node_modules/uplot/dist/uPlot.min.css";
+
+/* App styles */
+@import "./base.css";
+@import "./components.css";
+@import "./animations.css";
+@import "./email.css";
+@import "./effects.css";
+@import "./utilities.css";
diff --git a/apps/elektrine/assets/css/base.css b/apps/elektrine/assets/css/base.css
new file mode 100644
index 0000000..020b31f
--- /dev/null
+++ b/apps/elektrine/assets/css/base.css
@@ -0,0 +1,610 @@
+/* ==========================================================================
+ BASE - Reset, Fonts, Theme Variables
+ ========================================================================== */
+
+:root {
+ color-scheme: dark;
+}
+
+html {
+ color-scheme: dark;
+ overflow-x: clip;
+}
+
+body {
+ color: var(--color-base-content, #e5e2e1);
+ background-color: var(--color-base-100, #121214);
+ isolation: isolate;
+}
+
+::selection {
+ background: var(--theme-selection-bg, rgba(95, 135, 184, 0.28));
+ color: var(--theme-text-inverse, #ffffff);
+}
+
+* {
+ scrollbar-width: auto;
+ scrollbar-color: var(--theme-scrollbar-thumb, rgba(95, 135, 184, 0.28)) transparent;
+}
+
+::-webkit-scrollbar {
+ width: 10px;
+ height: 10px;
+}
+
+::-webkit-scrollbar-track {
+ background: transparent;
+}
+
+::-webkit-scrollbar-thumb {
+ background-color: var(--theme-scrollbar-thumb, rgba(95, 135, 184, 0.28));
+ border-radius: 999px;
+}
+
+::-webkit-scrollbar-thumb:hover {
+ background-color: var(--theme-scrollbar-thumb-hover, rgba(95, 135, 184, 0.42));
+}
+
+[phx-click],
+[phx-submit],
+button:not(:disabled),
+a[href],
+.btn,
+.cursor-pointer {
+ cursor: pointer;
+}
+
+.search-clear-input {
+ padding-right: 2rem;
+}
+
+.search-clear-auto[hidden] {
+ display: none !important;
+}
+
+.search-clear-auto {
+ align-items: center;
+ background: transparent;
+ border: 0;
+ display: inline-flex;
+ justify-content: center;
+ line-height: 1;
+ margin: 0;
+ padding: 0;
+}
+
+.search-clear-inline {
+ margin-left: 0.5rem;
+}
+
+.search-clear-auto:focus-visible {
+ border-radius: 0.25rem;
+ outline: 2px solid currentColor;
+ outline-offset: 2px;
+}
+
+.search-clear-overlay {
+ position: absolute;
+ right: 0.5rem;
+ top: 50%;
+ transform: translateY(-50%);
+ z-index: 2;
+}
+
+/* ==========================================================================
+ FONT SYSTEM - Self-hosted Geist (text), Geist Mono (mono), Geist Pixel (display)
+ ========================================================================== */
+
+@font-face {
+ font-family: 'Geist';
+ font-weight: 100 900;
+ font-style: italic;
+ font-display: swap;
+ src: url('/fonts/Geist-Italic-cyrillic-ext.woff2') format('woff2');
+ unicode-range: U+0460-052F, U+1C80-1C8A, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
+}
+
+@font-face {
+ font-family: 'Geist';
+ font-weight: 100 900;
+ font-style: italic;
+ font-display: swap;
+ src: url('/fonts/Geist-Italic-cyrillic.woff2') format('woff2');
+ unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
+}
+
+@font-face {
+ font-family: 'Geist';
+ font-weight: 100 900;
+ font-style: italic;
+ font-display: swap;
+ src: url('/fonts/Geist-Italic-vietnamese.woff2') format('woff2');
+ unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB;
+}
+
+@font-face {
+ font-family: 'Geist';
+ font-weight: 100 900;
+ font-style: italic;
+ font-display: swap;
+ src: url('/fonts/Geist-Italic-latin-ext.woff2') format('woff2');
+ unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
+}
+
+@font-face {
+ font-family: 'Geist';
+ font-weight: 100 900;
+ font-style: italic;
+ font-display: swap;
+ src: url('/fonts/Geist-Italic-latin.woff2') format('woff2');
+ unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
+}
+
+@font-face {
+ font-family: 'Geist';
+ font-weight: 100 900;
+ font-style: normal;
+ font-display: swap;
+ src: url('/fonts/Geist-cyrillic-ext.woff2') format('woff2');
+ unicode-range: U+0460-052F, U+1C80-1C8A, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
+}
+
+@font-face {
+ font-family: 'Geist';
+ font-weight: 100 900;
+ font-style: normal;
+ font-display: swap;
+ src: url('/fonts/Geist-cyrillic.woff2') format('woff2');
+ unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
+}
+
+@font-face {
+ font-family: 'Geist';
+ font-weight: 100 900;
+ font-style: normal;
+ font-display: swap;
+ src: url('/fonts/Geist-vietnamese.woff2') format('woff2');
+ unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB;
+}
+
+@font-face {
+ font-family: 'Geist';
+ font-weight: 100 900;
+ font-style: normal;
+ font-display: swap;
+ src: url('/fonts/Geist-latin-ext.woff2') format('woff2');
+ unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
+}
+
+@font-face {
+ font-family: 'Geist';
+ font-weight: 100 900;
+ font-style: normal;
+ font-display: swap;
+ src: url('/fonts/Geist-latin.woff2') format('woff2');
+ unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
+}
+
+@font-face {
+ font-family: 'Geist Mono';
+ font-weight: 100 900;
+ font-style: italic;
+ font-display: swap;
+ src: url('/fonts/GeistMono-Italic-cyrillic-ext.woff2') format('woff2');
+ unicode-range: U+0460-052F, U+1C80-1C8A, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
+}
+
+@font-face {
+ font-family: 'Geist Mono';
+ font-weight: 100 900;
+ font-style: italic;
+ font-display: swap;
+ src: url('/fonts/GeistMono-Italic-cyrillic.woff2') format('woff2');
+ unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
+}
+
+@font-face {
+ font-family: 'Geist Mono';
+ font-weight: 100 900;
+ font-style: italic;
+ font-display: swap;
+ src: url('/fonts/GeistMono-Italic-symbols2.woff2') format('woff2');
+ unicode-range: U+2000-2001, U+2004-2008, U+200A, U+23B8-23BD, U+2500-259F;
+}
+
+@font-face {
+ font-family: 'Geist Mono';
+ font-weight: 100 900;
+ font-style: italic;
+ font-display: swap;
+ src: url('/fonts/GeistMono-Italic-vietnamese.woff2') format('woff2');
+ unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB;
+}
+
+@font-face {
+ font-family: 'Geist Mono';
+ font-weight: 100 900;
+ font-style: italic;
+ font-display: swap;
+ src: url('/fonts/GeistMono-Italic-latin-ext.woff2') format('woff2');
+ unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
+}
+
+@font-face {
+ font-family: 'Geist Mono';
+ font-weight: 100 900;
+ font-style: italic;
+ font-display: swap;
+ src: url('/fonts/GeistMono-Italic-latin.woff2') format('woff2');
+ unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
+}
+
+@font-face {
+ font-family: 'Geist Mono';
+ font-weight: 100 900;
+ font-style: normal;
+ font-display: swap;
+ src: url('/fonts/GeistMono-cyrillic-ext.woff2') format('woff2');
+ unicode-range: U+0460-052F, U+1C80-1C8A, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
+}
+
+@font-face {
+ font-family: 'Geist Mono';
+ font-weight: 100 900;
+ font-style: normal;
+ font-display: swap;
+ src: url('/fonts/GeistMono-cyrillic.woff2') format('woff2');
+ unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
+}
+
+@font-face {
+ font-family: 'Geist Mono';
+ font-weight: 100 900;
+ font-style: normal;
+ font-display: swap;
+ src: url('/fonts/GeistMono-symbols2.woff2') format('woff2');
+ unicode-range: U+2000-2001, U+2004-2008, U+200A, U+23B8-23BD, U+2500-259F;
+}
+
+@font-face {
+ font-family: 'Geist Mono';
+ font-weight: 100 900;
+ font-style: normal;
+ font-display: swap;
+ src: url('/fonts/GeistMono-vietnamese.woff2') format('woff2');
+ unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB;
+}
+
+@font-face {
+ font-family: 'Geist Mono';
+ font-weight: 100 900;
+ font-style: normal;
+ font-display: swap;
+ src: url('/fonts/GeistMono-latin-ext.woff2') format('woff2');
+ unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
+}
+
+@font-face {
+ font-family: 'Geist Mono';
+ font-weight: 100 900;
+ font-style: normal;
+ font-display: swap;
+ src: url('/fonts/GeistMono-latin.woff2') format('woff2');
+ unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
+}
+
+@font-face {
+ font-family: 'Geist Pixel';
+ font-weight: 400;
+ font-style: normal;
+ font-display: swap;
+ src: url('/fonts/GeistPixel-latin-ext.woff2') format('woff2');
+ unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
+}
+
+@font-face {
+ font-family: 'Geist Pixel';
+ font-weight: 400;
+ font-style: normal;
+ font-display: swap;
+ src: url('/fonts/GeistPixel-latin.woff2') format('woff2');
+ unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
+}
+
+@layer base {
+ html {
+ font-family: 'Geist', ui-sans-serif, system-ui, -apple-system, sans-serif;
+ }
+
+ h1,
+ h2,
+ h3,
+ h4,
+ h5,
+ h6 {
+ font-weight: 600;
+ }
+}
+
+/* ==========================================================================
+ GRID BACKGROUNDS
+ ========================================================================== */
+
+:root {
+ --theme-grid-size: 40px;
+ --theme-grid-color: color-mix(in srgb, var(--color-base-content, #d1d5db) 7%, transparent);
+ --theme-grid-x-color: color-mix(in srgb, var(--color-primary, #8a7cc2) 5%, transparent);
+ --theme-grid-glow-color: color-mix(in srgb, var(--color-primary, #8a7cc2) 12%, transparent);
+ --theme-grid-glow-x-color: color-mix(in srgb, var(--color-primary, #8a7cc2) 7%, transparent);
+}
+
+/* Grid glow overlay - cursor-focused grid highlight */
+#cursor-glow-overlay,
+#blinkenlights-container { --top-chrome-height: 0px; clip-path: inset(var(--top-chrome-height) 0 0 0);
+} #cursor-glow-overlay { position: fixed; top: 0; left: 0; width: 100%; height: 100%; pointer-events: none; z-index: -1; contain: strict; background-size: var(--theme-grid-size) var(--theme-grid-size); background-image: linear-gradient(var(--theme-grid-glow-color) 1px, transparent 1px), linear-gradient(90deg, var(--theme-grid-glow-color) 1px, transparent 1px), linear-gradient( 45deg, transparent calc(50% - 0.6px), var(--theme-grid-glow-x-color) 50%, transparent calc(50% + 0.6px) ), linear-gradient( -45deg, transparent calc(50% - 0.6px), var(--theme-grid-glow-x-color) 50%, transparent calc(50% + 0.6px) ); opacity: calc(var(--cursor-opacity, 0) * 0.78); filter: saturate(1.08) brightness(1.12); will-change: opacity, mask-image, -webkit-mask-image; mask-image: radial-gradient( var(--cursor-radius, 300px) circle at var(--cursor-x, -1000px) var(--cursor-y, -1000px), rgba(0, 0, 0, 0.76) 0%, rgba(0, 0, 0, 0.56) 36%, rgba(0, 0, 0, 0.30) 62%, transparent 84% ); -webkit-mask-image: radial-gradient( var(--cursor-radius, 300px) circle at var(--cursor-x, -1000px) var(--cursor-y, -1000px), rgba(0, 0, 0, 0.76) 0%, rgba(0, 0, 0, 0.56) 36%, rgba(0, 0, 0, 0.30) 62%, transparent 84% );
+} body.bg-base-100 { background-color: var(--color-base-100); background-size: var(--theme-grid-size) var(--theme-grid-size); background-attachment: fixed; background-position: 0 0; background-image: linear-gradient(var(--theme-grid-color) 1px, transparent 1px), linear-gradient(90deg, var(--theme-grid-color) 1px, transparent 1px), linear-gradient( 45deg, transparent calc(50% - 0.6px), var(--theme-grid-x-color) 50%, transparent calc(50% + 0.6px) ), linear-gradient( -45deg, transparent calc(50% - 0.6px), var(--theme-grid-x-color) 50%, transparent calc(50% + 0.6px) );
+} /* Grid color variants */
+body[data-grid="purple"], body.grid-purple { --theme-grid-color: color-mix(in srgb, var(--color-primary, #8a7cc2) 8%, transparent); --theme-grid-x-color: color-mix(in srgb, var(--color-primary, #8a7cc2) 6%, transparent); --theme-grid-glow-color: color-mix(in srgb, var(--color-primary, #8a7cc2) 15%, transparent); --theme-grid-glow-x-color: color-mix(in srgb, var(--color-primary, #8a7cc2) 9%, transparent);
+} body[data-grid="cyan"], body.grid-cyan { --theme-grid-color: color-mix(in srgb, var(--color-info, #60a5fa) 8%, transparent); --theme-grid-x-color: color-mix(in srgb, var(--color-info, #60a5fa) 6%, transparent); --theme-grid-glow-color: color-mix(in srgb, var(--color-info, #60a5fa) 15%, transparent); --theme-grid-glow-x-color: color-mix(in srgb, var(--color-info, #60a5fa) 9%, transparent);
+} body[data-grid="red"], body.grid-red { --theme-grid-color: color-mix(in srgb, var(--color-error, #ef4444) 8%, transparent); --theme-grid-x-color: color-mix(in srgb, var(--color-error, #ef4444) 6%, transparent); --theme-grid-glow-color: color-mix(in srgb, var(--color-error, #ef4444) 15%, transparent); --theme-grid-glow-x-color: color-mix(in srgb, var(--color-error, #ef4444) 9%, transparent);
+} body[data-grid="green"], body.grid-green { --theme-grid-color: color-mix(in srgb, var(--color-success, #22c55e) 8%, transparent); --theme-grid-x-color: color-mix(in srgb, var(--color-success, #22c55e) 6%, transparent); --theme-grid-glow-color: color-mix(in srgb, var(--color-success, #22c55e) 15%, transparent); --theme-grid-glow-x-color: color-mix(in srgb, var(--color-success, #22c55e) 9%, transparent);
+} body[data-grid="orange"], body.grid-orange { --theme-grid-color: color-mix(in srgb, var(--color-warning, #f59e0b) 8%, transparent); --theme-grid-x-color: color-mix(in srgb, var(--color-warning, #f59e0b) 6%, transparent); --theme-grid-glow-color: color-mix(in srgb, var(--color-warning, #f59e0b) 15%, transparent); --theme-grid-glow-x-color: color-mix(in srgb, var(--color-warning, #f59e0b) 9%, transparent);
+} .admin-layout { color: var(--color-base-content); background-color: var(--color-base-100); background-image: none !important; min-height: 100vh;
+} .admin-layout > main { position: relative; background-image: none !important;
+} .admin-page { display: flex; flex-direction: column; gap: 1.5rem;
+} @media (min-width: 640px) { .admin-page { gap: 2rem; }
+} .admin-page > :first-child { margin-bottom: 0 !important;
+} .admin-page > :first-child:not(.card):not(.panel-card):not(.alert) { background-color: var(--surface-panel-bg-fallback) !important; background-image: none !important; border: 1px solid var(--surface-panel-border); border-radius: 0.875rem; box-shadow: var(--surface-panel-shadow); padding: 1.25rem;
+} @media (min-width: 640px) { .admin-page > :first-child:not(.card):not(.panel-card):not(.alert) { padding: 1.5rem 1.75rem; }
+} .admin-page > :first-child:not(.card):not(.panel-card):not(.alert) h1 { letter-spacing: -0.02em;
+} .admin-page > :first-child:not(.card):not(.panel-card):not(.alert) p { color: color-mix(in srgb, var(--color-base-content) 70%, transparent);
+} .admin-page > .card.panel-card,
+.admin-page > section.card.panel-card { margin-top: 0 !important; margin-bottom: 0 !important;
+} body[data-grid="blue"], body.grid-blue { --theme-grid-color: color-mix(in srgb, var(--color-secondary, #60a5fa) 8%, transparent); --theme-grid-x-color: color-mix(in srgb, var(--color-secondary, #60a5fa) 6%, transparent); --theme-grid-glow-color: color-mix(in srgb, var(--color-secondary, #60a5fa) 15%, transparent); --theme-grid-glow-x-color: color-mix(in srgb, var(--color-secondary, #60a5fa) 9%, transparent);
+} body[data-grid="pink"], body.grid-pink { --theme-grid-color: color-mix(in srgb, var(--color-accent, #ec4899) 8%, transparent); --theme-grid-x-color: color-mix(in srgb, var(--color-accent, #ec4899) 6%, transparent); --theme-grid-glow-color: color-mix(in srgb, var(--color-accent, #ec4899) 15%, transparent); --theme-grid-glow-x-color: color-mix(in srgb, var(--color-accent, #ec4899) 9%, transparent);
+} .bg-grid { background-size: var(--theme-grid-size) var(--theme-grid-size); background-image: linear-gradient(var(--theme-grid-color) 1px, transparent 1px), linear-gradient(90deg, var(--theme-grid-color) 1px, transparent 1px), linear-gradient( 45deg, transparent calc(50% - 0.6px), var(--theme-grid-x-color) 50%, transparent calc(50% + 0.6px) ), linear-gradient( -45deg, transparent calc(50% - 0.6px), var(--theme-grid-x-color) 50%, transparent calc(50% + 0.6px) );
+} .network-grid { --theme-grid-size: 50px; background-size: var(--theme-grid-size) var(--theme-grid-size); background-image: linear-gradient(var(--theme-grid-color) 1px, transparent 1px), linear-gradient(90deg, var(--theme-grid-color) 1px, transparent 1px), linear-gradient( 45deg, transparent calc(50% - 0.6px), var(--theme-grid-x-color) 50%, transparent calc(50% + 0.6px) ), linear-gradient( -45deg, transparent calc(50% - 0.6px), var(--theme-grid-x-color) 50%, transparent calc(50% + 0.6px) );
+} /* ========================================================================== BLINKENLIGHTS - Retro mainframe-style blinking lights grid ========================================================================== */ /* Hide regular grid when blinkenlights are active */
+body.has-blinkenlights { background-image: none !important;
+} #blinkenlights-container { position: fixed; top: 0; left: 0; width: 100%; height: calc(100vh + 100px); height: calc(100dvh + 100px); pointer-events: none; z-index: -1;
+} .blinkenlight { position: absolute; width: 3px; height: 3px; border-radius: 50%; background-color: rgba(150, 150, 150, 0.5); transition: opacity 0.15s;
+} .blinkenlight.active { animation: blinken-mono var(--blinken-duration, 6s) var(--blinken-delay, 0s) infinite ease-in-out;
+} .blinkenlight.finding { background-color: rgba(255, 255, 255, 1) !important; box-shadow: 0 0 6px rgba(255, 255, 255, 0.8);
+} .blinkenlight.action { background-color: rgba(220, 220, 220, 1) !important; box-shadow: 0 0 4px rgba(220, 220, 220, 0.6);
+} @keyframes blinken-mono { 0%, 75%, 100% { background-color: rgba(150, 150, 150, 0.5); } 80%, 95% { background-color: rgba(255, 255, 255, 0.9); box-shadow: 0 0 4px rgba(255, 255, 255, 0.5); }
+} .footer-brandmark { align-items: center; display: inline-flex; gap: 0.65rem; opacity: 0.94; filter: drop-shadow(0 12px 24px rgba(0, 0, 0, 0.24));
+} .footer-brandmark-icon { flex: none; height: auto; opacity: 0.88; width: clamp(1.9rem, 8vw, 3.6rem);
+} .footer-wordmark-glow { -webkit-text-stroke: 1px rgba(255, 255, 255, 0.08); filter: drop-shadow(0 0 14px rgba(138, 124, 194, 0.14)) drop-shadow(0 4px 14px rgba(0, 0, 0, 0.2)); opacity: 0.92;
+} .footer-wordmark-glow:hover { opacity: 1 !important;
+} @media (max-width: 639px) { .footer-brandmark { gap: 0.45rem; opacity: 1; } .footer-brandmark-icon { opacity: 0.96; width: clamp(1.7rem, 10vw, 2.4rem); } .footer-wordmark-glow { -webkit-text-stroke-width: 1.15px; filter: drop-shadow(0 0 16px rgba(138, 124, 194, 0.18)) drop-shadow(0 3px 10px rgba(0, 0, 0, 0.28)); opacity: 1; }
+}
+
+:root {
+ --theme-text-inverse: #ffffff;
+ --theme-selection-bg: color-mix(in srgb, var(--color-base-content, #d1d5db) 18%, transparent);
+ --theme-scrollbar-thumb: color-mix(in srgb, var(--color-base-content, #d1d5db) 26%, transparent);
+ --theme-scrollbar-thumb-hover: color-mix(in srgb, var(--color-base-content, #d1d5db) 38%, transparent);
+
+ --theme-surface-interactive-shadow: 0 1px 2px color-mix(in srgb, #0f172a 8%, transparent), 0 2px 8px color-mix(in srgb, #0f172a 5%, transparent);
+ --theme-surface-hover-shadow: 0 4px 12px color-mix(in srgb, #000000 10%, transparent);
+ --theme-navbar-shadow: 0 12px 28px -24px color-mix(in srgb, #08101e 72%, transparent);
+ --theme-modal-backdrop: color-mix(in srgb, #0a0e14 56%, transparent);
+
+ --theme-link-color: color-mix(in srgb, var(--color-primary, #8a7cc2) 88%, #ffffff 12%);
+ --theme-link-color-hover: color-mix(in srgb, var(--color-primary, #8a7cc2) 72%, #000000 28%);
+ --theme-email-wrapper-bg: #ffffff;
+ --theme-email-wrapper-text: color-mix(in srgb, var(--color-base-content, #e4e7eb) 26%, #000000 74%);
+
+ --theme-btn-ghost-text: color-mix(in srgb, var(--color-base-content, #e4e7eb) 88%, var(--color-primary, #8a7cc2) 12%);
+ --theme-btn-ghost-bg: color-mix(in srgb, var(--color-base-300, #202833) 84%, var(--color-primary, #8a7cc2) 16%);
+ --theme-btn-outline-border: color-mix(in srgb, var(--color-base-300, #202833) 84%, var(--color-base-content, #e4e7eb) 16%);
+ --theme-btn-outline-bg: color-mix(in srgb, var(--color-base-200, #151b22) 92%, var(--color-base-100, #101419) 8%);
+ --theme-btn-neutral-bg: color-mix(in srgb, var(--color-base-300, #202833) 90%, var(--color-base-200, #151b22) 10%);
+ --theme-btn-neutral-border: color-mix(in srgb, var(--color-base-300, #202833) 82%, var(--color-base-content, #e4e7eb) 18%);
+ --theme-btn-neutral-bg-hover: color-mix(in srgb, var(--color-base-300, #202833) 78%, var(--color-base-200, #151b22) 22%);
+ --theme-btn-neutral-border-hover: color-mix(in srgb, var(--color-base-300, #202833) 74%, var(--color-base-content, #e4e7eb) 26%);
+ --theme-input-border: color-mix(in srgb, var(--color-base-300, #202833) 72%, var(--theme-text-inverse) 28%);
+ --theme-input-highlight: color-mix(in srgb, var(--color-primary, #8a7cc2) 28%, transparent);
+ --theme-input-highlight-strong: color-mix(in srgb, var(--color-primary, #8a7cc2) 42%, transparent);
+ --theme-input-highlight-shadow: color-mix(in srgb, var(--color-primary, #8a7cc2) 12%, transparent);
+ --theme-input-inset-shadow: inset 0 1px 0 color-mix(in srgb, #ffffff 2.5%, transparent);
+ --theme-navbar-border: color-mix(in srgb, var(--color-base-300, #202833) 84%, var(--color-base-content, #e4e7eb) 16%);
+ --theme-nav-link: color-mix(in srgb, var(--color-base-content, #e4e7eb) 88%, var(--color-primary, #8a7cc2) 12%);
+ --theme-error-badge-bg: var(--color-error, #ef4444);
+ --theme-error-badge-border: var(--color-error, #ef4444);
+ --theme-table-row-hover: color-mix(in srgb, var(--color-base-200, #151b22) 90%, var(--color-base-300, #202833) 10%);
+
+ --theme-chat-error-soft: color-mix(in srgb, var(--color-error, #dc2626) 20%, transparent);
+ --theme-chat-error-text-soft: color-mix(in srgb, var(--color-error, #dc2626) 55%, var(--theme-text-inverse) 45%);
+ --theme-chat-error-text-strong: color-mix(in srgb, var(--color-error, #dc2626) 35%, var(--theme-text-inverse) 65%);
+ --theme-chat-error-border-50: color-mix(in srgb, var(--color-error, #dc2626) 50%, transparent);
+ --theme-chat-error-button-overlay: linear-gradient(135deg, color-mix(in srgb, var(--color-error, #dc2626) 24%, transparent), color-mix(in srgb, var(--color-error, #dc2626) 10%, transparent) 55%, transparent 100%);
+ --theme-chat-error-button-overlay-hover: linear-gradient(135deg, color-mix(in srgb, var(--color-error, #dc2626) 32%, transparent), color-mix(in srgb, var(--color-error, #dc2626) 14%, transparent) 55%, transparent 100%);
+ --theme-chat-error-button-border: color-mix(in srgb, var(--color-error, #dc2626) 52%, transparent);
+ --theme-chat-error-button-border-hover: color-mix(in srgb, var(--color-error, #dc2626) 64%, transparent);
+ --theme-chat-error-button-text: color-mix(in srgb, var(--theme-text-inverse) 98%, var(--color-error, #dc2626) 2%);
+ --theme-message-selected-bg: color-mix(in srgb, var(--color-primary, #8a7cc2) 10%, transparent);
+ --theme-message-selected-border: color-mix(in srgb, var(--color-primary, #8a7cc2) 45%, transparent);
+ --theme-message-selected-shadow: color-mix(in srgb, var(--color-primary, #8a7cc2) 18%, transparent);
+ --theme-message-selected-bg-hover: color-mix(in srgb, var(--color-primary, #8a7cc2) 15%, transparent);
+ --theme-message-selected-border-hover: color-mix(in srgb, var(--color-primary, #8a7cc2) 62%, transparent);
+ --theme-message-selected-shadow-hover: color-mix(in srgb, var(--color-primary, #8a7cc2) 26%, transparent);
+
+ --theme-effect-rainbow: linear-gradient(90deg, #ff0000 0%, #ff7f00 16.67%, #ffff00 33.33%, #00ff00 50%, #0000ff 66.67%, #4b0082 83.33%, #9400d3 100%);
+ --theme-effect-fire: linear-gradient(45deg, #ff0000, #ff7f00, #ffff00, #ff7f00, #ff0000);
+ --theme-effect-holographic: linear-gradient(90deg, #ff00ff 0%, #00ffff 25%, #00ff00 50%, #ffff00 75%, #ff00ff 100%);
+ --theme-effect-chrome: linear-gradient(90deg, #888888, #ffffff, #cccccc, #888888, #ffffff);
+ --theme-effect-retro: linear-gradient(180deg, #ff00ff 0%, #00ffff 100%);
+ --theme-effect-gold-frame: linear-gradient(135deg, #c41e3a 0%, #8b0000 50%, #c41e3a 100%);
+ --theme-effect-gold-surface: linear-gradient(45deg, #ffd700 0%, #ffed4e 25%, #ffa500 50%, #ffed4e 75%, #ffd700 100%);
+ --theme-effect-gold-frame-shadow: color-mix(in srgb, #c41e3a 60%, transparent);
+ --theme-effect-gold-inner-glow: color-mix(in srgb, #ffd700 30%, transparent);
+ --theme-effect-gold-outer-glow: color-mix(in srgb, #ffd700 80%, transparent);
+ --theme-effect-gold-outer-accent: color-mix(in srgb, #ffa500 40%, transparent);
+ --theme-effect-gold-top-highlight: color-mix(in srgb, #ffffff 50%, transparent);
+ --theme-effect-gold-bottom-shadow: color-mix(in srgb, #8b0000 30%, transparent);
+ --theme-effect-fire-shadow: drop-shadow(0 0 4px color-mix(in srgb, #ff6400 60%, transparent)) drop-shadow(0 0 8px color-mix(in srgb, #ff3200 40%, transparent));
+ --theme-effect-ice-color: #a0d8f1;
+ --theme-effect-ice-highlight: color-mix(in srgb, var(--theme-effect-ice-color) 78%, #ffffff 22%);
+ --theme-effect-ice-shadow: 0 0 10px color-mix(in srgb, #add8e6 80%, transparent), 0 0 20px color-mix(in srgb, #87cefa 60%, transparent), 0 0 30px color-mix(in srgb, #6495ed 40%, transparent), 2px 2px 4px color-mix(in srgb, #ffffff 30%, transparent);
+ --theme-effect-chrome-shadow: drop-shadow(1px 1px 2px color-mix(in srgb, #000000 50%, transparent));
+ --theme-effect-retro-shadow: drop-shadow(2px 2px 0px #ff00ff) drop-shadow(2px 2px 0px #00ffff);
+ --theme-effect-outline-stroke: var(--glow-color, var(--color-primary, #8a7cc2));
+ --theme-effect-shadow-color: var(--shadow-color, #000000);
+ --theme-effect-glitch-cyan: #00fffc;
+ --theme-effect-glitch-magenta: #fc00ff;
+ --theme-effect-glitch-yellow: #fffc00;
+ --theme-avatar-accent-color: var(--avatar-accent, var(--color-primary, #8a7cc2));
+ --theme-avatar-accent-light-color: var(--avatar-accent-light, color-mix(in srgb, var(--theme-avatar-accent-color) 72%, #ffffff 28%));
+ --theme-link-accent-color: var(--link-accent, var(--color-primary, #8a7cc2));
+ --theme-link-border-shadow: color-mix(in srgb, var(--theme-link-accent-color) 20%, transparent);
+ --theme-link-shine: linear-gradient(45deg, transparent 30%, color-mix(in srgb, #ffffff 30%, transparent) 50%, transparent 70%);
+ --theme-image-zoom-overlay: color-mix(in srgb, #000000 40%, transparent);
+}
+
+html[data-theme="light"],
+:root[data-theme="light"],
+[data-theme="light"],
+[data-theme=light] {
+ color-scheme: light;
+ --color-primary: var(--theme-override-color-primary, #4f7098) !important;
+ --color-primary-content: var(--theme-override-color-primary-content, #ffffff) !important;
+ --color-secondary: var(--theme-override-color-secondary, #a8662d) !important;
+ --color-secondary-content: var(--theme-override-color-secondary-content, #ffffff) !important;
+ --color-accent: var(--theme-override-color-accent, #62779f) !important;
+ --color-accent-content: var(--theme-override-color-accent-content, #ffffff) !important;
+ --color-neutral: #27313c !important;
+ --color-neutral-content: #ffffff !important;
+ --color-base-100: var(--theme-override-color-base-100, #f5f7fa) !important;
+ --color-base-200: var(--theme-override-color-base-200, #edf1f5) !important;
+ --color-base-300: var(--theme-override-color-base-300, #d2dae4) !important;
+ --color-base-content: var(--theme-override-color-base-content, #17202a) !important;
+ --color-info: var(--theme-override-color-info, #3f6fa8) !important;
+ --color-info-content: var(--theme-override-color-info-content, #ffffff) !important;
+ --color-success: var(--theme-override-color-success, #3f7a52) !important;
+ --color-success-content: var(--theme-override-color-success-content, #ffffff) !important;
+ --color-warning: var(--theme-override-color-warning, #9a5b18) !important;
+ --color-warning-content: var(--theme-override-color-warning-content, #ffffff) !important;
+ --color-error: var(--theme-override-color-error, #a34b48) !important;
+ --color-error-content: var(--theme-override-color-error-content, #ffffff) !important;
+ --surface-panel-bg-fallback: color-mix(in srgb, var(--color-base-200) 72%, #ffffff 28%);
+ --surface-panel-bg: color-mix(in srgb, var(--color-base-200) 72%, #ffffff 28%);
+ --surface-panel-border: color-mix(in srgb, var(--color-base-300) 78%, var(--color-primary) 22%);
+ --surface-panel-shadow: 0 18px 42px -32px rgba(31, 48, 68, 0.28), 0 1px 2px rgba(31, 48, 68, 0.05);
+ --surface-floating-bg-fallback: #fdfefe;
+ --surface-floating-bg: #fdfefe;
+ --surface-floating-border: color-mix(in srgb, var(--color-base-300) 78%, var(--color-primary) 22%);
+ --surface-floating-shadow: 0 22px 50px -30px rgba(31, 48, 68, 0.34), 0 2px 8px rgba(31, 48, 68, 0.08);
+ --surface-modal-bg-fallback: #ffffff;
+ --surface-modal-border: color-mix(in srgb, var(--color-base-300) 78%, var(--color-primary) 22%);
+ --surface-modal-shadow: 0 30px 70px -36px rgba(31, 48, 68, 0.42), 0 2px 10px rgba(31, 48, 68, 0.08);
+ --theme-navbar-border: color-mix(in srgb, var(--color-base-300) 88%, var(--color-primary) 12%);
+ --theme-navbar-shadow: 0 14px 34px -28px rgba(31, 48, 68, 0.38);
+ --theme-modal-backdrop: color-mix(in srgb, #263442 42%, transparent);
+ --theme-link-color: color-mix(in srgb, var(--color-primary) 90%, #000000 10%);
+ --theme-link-color-hover: color-mix(in srgb, var(--color-primary) 72%, #000000 28%);
+ --theme-btn-neutral-bg: color-mix(in srgb, var(--color-base-200) 64%, var(--color-base-300) 36%);
+ --theme-btn-neutral-border: color-mix(in srgb, var(--color-base-300) 72%, var(--color-base-content) 28%);
+ --theme-btn-neutral-bg-hover: color-mix(in srgb, var(--color-base-200) 42%, var(--color-base-300) 58%);
+ --theme-btn-neutral-border-hover: color-mix(in srgb, var(--color-base-300) 58%, var(--color-base-content) 42%);
+ --theme-btn-ghost-text: color-mix(in srgb, var(--color-base-content) 88%, var(--color-primary) 12%);
+ --theme-btn-ghost-bg: color-mix(in srgb, var(--color-base-200) 78%, var(--color-primary) 22%);
+ --theme-btn-outline-border: color-mix(in srgb, var(--color-base-300) 68%, var(--color-primary) 32%);
+ --theme-btn-outline-bg: color-mix(in srgb, var(--color-base-200) 88%, #ffffff 12%);
+ --theme-input-bg: color-mix(in srgb, var(--color-base-200) 90%, var(--color-primary) 10%);
+ --theme-input-border: color-mix(in srgb, var(--color-base-300) 68%, var(--color-primary) 32%);
+ --theme-input-highlight: color-mix(in srgb, var(--color-primary) 24%, transparent);
+ --theme-input-highlight-strong: color-mix(in srgb, var(--color-primary) 38%, transparent);
+ --theme-input-highlight-shadow: color-mix(in srgb, var(--color-primary) 10%, transparent);
+ --theme-table-row-hover: color-mix(in srgb, var(--color-base-200) 78%, var(--color-primary) 22%);
+}
+
+[data-theme-show] {
+ display: none;
+}
+
+html[data-theme="light"] [data-theme-show="light"],
+html[data-theme="dark"] [data-theme-show="dark"] {
+ display: inline-flex;
+}
+
+html[data-theme="dark"],
+:root[data-theme="dark"],
+[data-theme="dark"],
+[data-theme=dark] {
+ color-scheme: dark;
+ --color-primary: var(--theme-override-color-primary, #5f87b8) !important;
+ --color-secondary: var(--theme-override-color-secondary, #c9853f) !important;
+ --color-accent: var(--theme-override-color-accent, #7d99bb) !important;
+ --color-neutral: #171c22 !important;
+ --color-neutral-content: #e4e7eb !important;
+ --color-base-100: var(--theme-override-color-base-100, #121214) !important;
+ --color-base-200: var(--theme-override-color-base-200, #1a1a1d) !important;
+ --color-base-300: var(--theme-override-color-base-300, #2a2a31) !important;
+ --color-base-content: var(--theme-override-color-base-content, #e5e2e1) !important;
+ --color-info: var(--theme-override-color-info, #6f95c4) !important;
+ --color-success: var(--theme-override-color-success, #6f8b74) !important;
+ --color-warning: var(--theme-override-color-warning, #c99152) !important;
+ --color-error: var(--theme-override-color-error, #a56b68) !important;
+
+ --surface-panel-bg: var(--color-base-200);
+ --surface-floating-bg: var(--color-base-300);
+ --surface-panel-bg-fallback: color-mix(in srgb, var(--color-base-200) 92%, #222228 8%);
+ --surface-panel-border: color-mix(in srgb, var(--color-base-300) 72%, var(--color-primary) 28%);
+ --surface-panel-shadow: 0 24px 54px -36px rgba(2, 12, 30, 0.88), 0 0 0 1px color-mix(in srgb, var(--color-primary) 10%, transparent);
+ --surface-floating-bg-fallback: color-mix(in srgb, var(--color-base-200) 88%, #25252c 12%);
+ --surface-floating-border: color-mix(in srgb, var(--color-base-300) 80%, var(--color-base-content) 20%);
+ --surface-floating-shadow: 0 26px 58px -34px rgba(2, 12, 30, 0.92), 0 0 0 1px color-mix(in srgb, var(--color-base-content) 8%, transparent);
+ --surface-modal-bg-fallback: color-mix(in srgb, var(--color-base-200) 84%, #2a2a31 16%);
+ --surface-modal-border: color-mix(in srgb, var(--color-base-300) 62%, var(--color-primary) 38%);
+ --surface-modal-shadow: 0 32px 72px -34px rgba(1, 9, 26, 0.96), 0 0 0 1px color-mix(in srgb, var(--color-primary) 12%, transparent);
+ --theme-navbar-border: color-mix(in srgb, var(--color-base-300) 68%, var(--color-primary) 32%);
+ --theme-navbar-shadow: 0 18px 42px -28px rgba(2, 12, 30, 0.84);
+ --theme-modal-backdrop: color-mix(in srgb, #020611 72%, transparent);
+ --theme-btn-neutral-bg: color-mix(in srgb, var(--color-base-300) 92%, #232329 8%);
+ --theme-btn-neutral-border: color-mix(in srgb, var(--color-base-300) 68%, var(--color-primary) 32%);
+ --theme-btn-neutral-bg-hover: color-mix(in srgb, var(--color-base-300) 84%, #2a2a31 16%);
+ --theme-btn-neutral-border-hover: color-mix(in srgb, var(--color-base-300) 56%, var(--color-primary) 44%);
+ --theme-input-border: color-mix(in srgb, var(--color-base-300) 60%, var(--color-info) 40%);
+ --theme-input-highlight: color-mix(in srgb, var(--color-primary) 34%, transparent);
+ --theme-input-highlight-strong: color-mix(in srgb, var(--color-primary) 52%, transparent);
+ --theme-input-highlight-shadow: color-mix(in srgb, var(--color-primary) 18%, transparent);
+ --theme-table-row-hover: color-mix(in srgb, var(--color-base-200) 88%, #26262d 12%);
+ --theme-link-color: color-mix(in srgb, var(--color-primary) 88%, #f1f5f9 12%);
+ --theme-link-color-hover: color-mix(in srgb, var(--color-primary) 92%, #ffffff 8%);
+ --color-primary-content: var(--theme-override-color-primary-content, #ffffff) !important;
+ --color-secondary-content: var(--theme-override-color-secondary-content, #ffffff) !important;
+ --color-accent-content: var(--theme-override-color-accent-content, #ffffff) !important;
+ --color-info-content: var(--theme-override-color-info-content, #ffffff) !important;
+ --color-success-content: var(--theme-override-color-success-content, #ffffff) !important;
+ --color-warning-content: var(--theme-override-color-warning-content, #ffffff) !important;
+ --color-error-content: var(--theme-override-color-error-content, #ffffff) !important;
+}
+
+.footer-wordmark-glow {
+ filter: drop-shadow(0 0 14px rgba(95, 135, 184, 0.11))
+ drop-shadow(0 4px 14px rgba(0, 0, 0, 0.2));
+}
+
+@media (max-width: 639px) {
+ .footer-wordmark-glow {
+ filter: drop-shadow(0 0 16px rgba(95, 135, 184, 0.12))
+ drop-shadow(0 3px 10px rgba(0, 0, 0, 0.28));
+ }
+}
+
+/* Home poster page: the global scrollbar styling uses a transparent
+ track, which Firefox paints as a near-black strip; next to the
+ full-bleed hero it reads as a stray vertical line (invisible on
+ dark pages, obvious here). Hide the scrollbar on this page — wheel,
+ keyboard, and touch scrolling are unaffected — and drop daisyUI's
+ reserved root gutter so no strip is left behind. */
+:root:has(#home-poster-art) {
+ scrollbar-gutter: auto;
+ scrollbar-width: none;
+ background: #05070a;
+}
+
+:root:has(#home-poster-art)::-webkit-scrollbar {
+ display: none;
+}
diff --git a/apps/elektrine/assets/css/components.css b/apps/elektrine/assets/css/components.css
new file mode 100644
index 0000000..871c198
--- /dev/null
+++ b/apps/elektrine/assets/css/components.css
@@ -0,0 +1,391 @@
+/* ========================================================================== COMPONENTS - Buttons, Inputs, Cards, Navbar, etc. ========================================================================== */ /* Shared icon behavior for consistent look across pages. Explicit text-* / opacity-* icon classes keep full control. */
+.ui-icon { color: currentColor; vertical-align: middle; transition: color 0.15s ease, opacity 0.15s ease;
+} /* -------------------------------------------------------------------------- BUTTONS -------------------------------------------------------------------------- */ .btn-primary { --btn-color: color-mix(in srgb, var(--color-base-300) 72%, var(--color-primary) 28%); --btn-fg: var(--color-primary-content);
+} .vote-score-pending { display: none !important;
+} .vote-score--empty { display: none;
+} .vote-up-button.phx-click-loading + .vote-score .vote-score-current { display: none !important;
+} .vote-up-button.phx-click-loading + .vote-score .vote-score-pending { display: inline !important;
+} .vote-up-button.phx-click-loading + .vote-score.vote-score--empty { display: inline;
+} .btn-secondary { --btn-color: color-mix(in srgb, var(--color-base-300) 72%, var(--color-secondary) 28%); --btn-fg: var(--color-secondary-content);
+} .btn-accent { --btn-color: color-mix(in srgb, var(--color-base-300) 72%, var(--color-accent) 28%); --btn-fg: var(--color-accent-content);
+} .btn-info { --btn-color: color-mix(in srgb, var(--color-base-300) 72%, var(--color-info) 28%); --btn-fg: var(--color-info-content);
+} .btn-success { --btn-color: color-mix(in srgb, var(--color-base-300) 72%, var(--color-success) 28%); --btn-fg: var(--color-success-content);
+} .btn-warning { --btn-color: color-mix(in srgb, var(--color-base-300) 72%, var(--color-warning) 28%); --btn-fg: var(--color-warning-content);
+} .btn-error { --btn-color: color-mix(in srgb, var(--color-base-300) 72%, var(--color-error) 28%); --btn-fg: var(--color-error-content);
+} html[data-theme="light"] .btn-primary:not(.btn-outline) { --btn-color: var(--color-primary);
+} html[data-theme="light"] .btn-secondary:not(.btn-outline) { --btn-color: var(--color-secondary);
+} html[data-theme="light"] .btn-accent:not(.btn-outline) { --btn-color: var(--color-accent);
+} html[data-theme="light"] .btn-info:not(.btn-outline) { --btn-color: var(--color-info);
+} html[data-theme="light"] .btn-success:not(.btn-outline) { --btn-color: var(--color-success);
+} html[data-theme="light"] .btn-warning:not(.btn-outline) { --btn-color: var(--color-warning);
+} html[data-theme="light"] .btn-error:not(.btn-outline) { --btn-color: var(--color-error);
+} .btn-ghost { --btn-color: var(--theme-btn-ghost-bg); --btn-fg: var(--theme-btn-ghost-text); --btn-rest-fg: var(--theme-btn-ghost-text);
+} .btn-outline { --btn-color: var(--theme-btn-outline-border); --btn-fg: var(--color-base-content); --btn-rest-fg: var(--color-base-content);
+}
+/* Outline + color variants: understated at rest (normal base-content text + a
+ subtle dark themed border, mixed from base-300 so it stays muted on the dark
+ theme), then on hover fill with the same muted accent the solid buttons use,
+ text in the accent's content color. This keeps them at the same low saturation
+ as the rest of the buttons. The accent only reads strongly on hover.
+ Plain .btn-outline stays neutral; two-class selectors win regardless of order. */
+.btn-outline.btn-primary { --btn-color: color-mix(in srgb, var(--color-base-300) 70%, var(--color-primary) 30%); --btn-fg: var(--color-primary-content); color: var(--color-base-content);
+} .btn-outline.btn-primary:hover, .btn-outline.btn-primary:focus-visible { color: var(--color-primary-content);
+} .btn-outline.btn-secondary { --btn-color: color-mix(in srgb, var(--color-base-300) 70%, var(--color-secondary) 30%); --btn-fg: var(--color-secondary-content); color: var(--color-base-content);
+} .btn-outline.btn-secondary:hover, .btn-outline.btn-secondary:focus-visible { color: var(--color-secondary-content);
+} .btn-outline.btn-accent { --btn-color: color-mix(in srgb, var(--color-base-300) 70%, var(--color-accent) 30%); --btn-fg: var(--color-accent-content); color: var(--color-base-content);
+} .btn-outline.btn-accent:hover, .btn-outline.btn-accent:focus-visible { color: var(--color-accent-content);
+} .btn-outline.btn-info { --btn-color: color-mix(in srgb, var(--color-base-300) 70%, var(--color-info) 30%); --btn-fg: var(--color-info-content); color: var(--color-base-content);
+} .btn-outline.btn-info:hover, .btn-outline.btn-info:focus-visible { color: var(--color-info-content);
+} .btn-outline.btn-success { --btn-color: color-mix(in srgb, var(--color-base-300) 70%, var(--color-success) 30%); --btn-fg: var(--color-success-content); color: var(--color-base-content);
+} .btn-outline.btn-success:hover, .btn-outline.btn-success:focus-visible { color: var(--color-success-content);
+} .btn-outline.btn-warning { --btn-color: color-mix(in srgb, var(--color-base-300) 70%, var(--color-warning) 30%); --btn-fg: var(--color-warning-content); color: var(--color-base-content);
+} .btn-outline.btn-warning:hover, .btn-outline.btn-warning:focus-visible { color: var(--color-warning-content);
+} .btn-outline.btn-error { --btn-color: color-mix(in srgb, var(--color-base-300) 70%, var(--color-error) 30%); --btn-fg: var(--color-error-content); color: var(--color-base-content);
+} .btn-outline.btn-error:hover, .btn-outline.btn-error:focus-visible { color: var(--color-error-content);
+} .btn-soft,
+.btn-neutral { --btn-color: var(--theme-btn-neutral-bg); --btn-fg: var(--color-base-content); --btn-rest-fg: var(--color-base-content);
+} .btn-surface { border-color: var(--surface-panel-border); background-color: var(--surface-panel-bg-fallback) !important; color: var(--color-base-content);
+} .btn-surface:hover,
+.btn-surface:focus-visible { border-color: color-mix(in srgb, var(--color-base-content) 15%, var(--surface-panel-border) 85%); background-color: color-mix(in srgb, var(--surface-panel-bg-fallback) 82%, var(--color-base-300) 18%) !important;
+} .load-more-button { --btn-color: color-mix(in srgb, var(--theme-btn-neutral-bg) 72%, var(--color-primary) 28%); --btn-fg: var(--color-base-content); border-color: color-mix(in srgb, var(--theme-btn-neutral-border) 70%, var(--color-primary) 30%) !important; background-color: color-mix(in srgb, var(--theme-btn-neutral-bg) 84%, var(--color-primary) 16%) !important; color: color-mix(in srgb, var(--color-base-content) 90%, var(--color-primary) 10%) !important; background-image: none !important; box-shadow: var(--theme-surface-interactive-shadow);
+} .load-more-button:hover,
+.load-more-button:focus-visible { border-color: color-mix(in srgb, var(--theme-btn-neutral-border-hover) 48%, var(--color-primary) 52%) !important; background-color: color-mix(in srgb, var(--theme-btn-neutral-bg-hover) 72%, var(--color-primary) 28%) !important; color: var(--color-base-content) !important; box-shadow: var(--theme-surface-hover-shadow);
+} .load-more-button:disabled,
+.load-more-button.btn-disabled { opacity: 0.6; box-shadow: none;
+} /* -------------------------------------------------------------------------- BADGES -------------------------------------------------------------------------- */ .badge { --app-badge-accent: var(--color-base-content); border-color: color-mix(in srgb, var(--app-badge-accent) 6%, var(--theme-btn-outline-border) 94%) !important; border-radius: 9999px; background-color: color-mix(in srgb, var(--app-badge-accent) 5%, var(--theme-btn-ghost-bg) 95%) !important; color: color-mix(in srgb, var(--app-badge-accent) 18%, var(--theme-btn-ghost-text) 82%) !important; background-image: none !important; box-shadow: none; text-shadow: none; font-weight: 500;
+} .badge-outline { border-color: color-mix(in srgb, var(--app-badge-accent) 10%, var(--theme-btn-outline-border) 90%) !important; background-color: color-mix(in srgb, var(--app-badge-accent) 4%, var(--theme-btn-outline-bg) 96%) !important; color: color-mix(in srgb, var(--app-badge-accent) 20%, var(--theme-btn-ghost-text) 80%) !important;
+} .badge-ghost { border-color: transparent !important; background-color: color-mix(in srgb, var(--color-base-content) 3%, var(--theme-btn-ghost-bg) 97%) !important; color: color-mix(in srgb, var(--color-base-content) 76%, transparent) !important;
+} .badge-neutral { --app-badge-accent: var(--color-neutral, var(--color-base-content));
+} .badge-primary { --app-badge-accent: var(--color-primary);
+} .badge-secondary { --app-badge-accent: var(--color-secondary);
+} .badge-accent { --app-badge-accent: var(--color-accent);
+} .badge-info { --app-badge-accent: var(--color-info);
+} .badge-success { --app-badge-accent: var(--color-success);
+} .badge-warning { --app-badge-accent: var(--color-warning);
+} .badge-error { --app-badge-accent: var(--color-error);
+} html[data-theme="dark"] .btn,
+:root[data-theme="dark"] .btn,
+[data-theme="dark"] .btn,
+[data-theme=dark] .btn { --depth: 0; --btn-noise: none; box-shadow: none; text-shadow: none; background-image: none;
+} html[data-theme="dark"] .btn:hover,
+html[data-theme="dark"] .btn:active,
+html[data-theme="dark"] .btn:focus-visible,
+:root[data-theme="dark"] .btn:hover,
+:root[data-theme="dark"] .btn:active,
+:root[data-theme="dark"] .btn:focus-visible,
+[data-theme="dark"] .btn:hover,
+[data-theme="dark"] .btn:active,
+[data-theme="dark"] .btn:focus-visible,
+[data-theme=dark] .btn:hover,
+[data-theme=dark] .btn:active,
+[data-theme=dark] .btn:focus-visible { box-shadow: none; text-shadow: none; background-image: none;
+} .btn:disabled,
+.btn.btn-disabled { opacity: 0.45;
+} .menu a .ui-icon:not([class*="text-"]):not([class*="opacity-"]),
+.menu button .ui-icon:not([class*="text-"]):not([class*="opacity-"]),
+.navbar a .ui-icon:not([class*="text-"]):not([class*="opacity-"]),
+.navbar [role="button"] .ui-icon:not([class*="text-"]):not([class*="opacity-"]) { opacity: 0.88;
+} .menu a:hover .ui-icon:not([class*="text-"]):not([class*="opacity-"]),
+.menu button:hover .ui-icon:not([class*="text-"]):not([class*="opacity-"]),
+.navbar a:hover .ui-icon:not([class*="text-"]):not([class*="opacity-"]),
+.navbar [role="button"]:hover .ui-icon:not([class*="text-"]):not([class*="opacity-"]) { opacity: 1;
+} /* Primary - violet tint */ /* -------------------------------------------------------------------------- MOBILE TOUCH TARGETS - Minimum 44x44px for accessibility -------------------------------------------------------------------------- */ @media (max-width: 640px) { /* Increase touch targets on mobile */ .btn-xs { min-height: 2.5rem; min-width: 2.5rem; padding-left: 0.75rem; padding-right: 0.75rem; } .btn-sm { min-height: 2.75rem; min-width: 2.75rem; } .btn:not(.btn-xs):not(.btn-sm) { min-height: 2.75rem; min-width: 2.75rem; } /* Filter pills on mobile */ .filter-pill { min-height: 2.75rem; padding: 0.5rem 1rem; } /* Dropdown items */ .dropdown-content li button, .dropdown-content li a, .menu li button, .menu li a { min-height: 2.75rem; padding: 0.75rem 1rem; }
+} /* Loading state */
+.btn-loading { pointer-events: none; opacity: 0.7;
+} .btn-loading .btn-text { visibility: hidden;
+} .btn-loading .btn-spinner { position: absolute; left: 50%; top: 50%; transform: translate(-50%, -50%);
+} /* -------------------------------------------------------------------------- INPUTS & FORMS -------------------------------------------------------------------------- */ .input,
+.textarea,
+.select { border: 1px solid var(--theme-input-border) !important; background-color: var(--theme-input-bg, color-mix(in srgb, var(--color-base-200) 78%, var(--color-base-100) 22%)) !important; color: var(--color-base-content) !important; box-shadow: var(--theme-input-inset-shadow) !important;
+} .input:focus,
+.textarea:focus,
+.select:focus { outline: 2px solid var(--theme-input-highlight) !important; outline-offset: 2px !important; border-color: var(--theme-input-highlight-strong) !important; box-shadow: 0 0 0 1px var(--theme-input-highlight-shadow) !important;
+} .input-bordered:focus,
+.input-bordered:focus-within { outline: 2px solid var(--theme-input-highlight) !important; border-color: var(--theme-input-highlight-strong) !important;
+} .checkbox:focus,
+.radio:focus,
+.toggle:focus { outline: 2px solid color-mix(in srgb, var(--color-primary) 50%, transparent) !important; outline-offset: 2px !important;
+} .input:hover,
+.textarea:hover,
+.select:hover { border-color: color-mix(in srgb, var(--color-base-300) 55%, white 45%) !important;
+} html[data-theme="light"] .input:hover,
+html[data-theme="light"] .textarea:hover,
+html[data-theme="light"] .select:hover { border-color: color-mix(in srgb, var(--color-base-300) 52%, var(--color-primary) 48%) !important;
+} html[data-theme="light"] :is(.checkbox, .radio, .toggle):not(:checked) { border-color: color-mix(in srgb, var(--color-base-300) 68%, var(--color-base-content) 32%);
+} .input::placeholder,
+.textarea::placeholder { color: color-mix(in srgb, var(--color-base-content) 48%, transparent 52%) !important;
+} .select option { background-color: var(--color-base-200); color: var(--color-base-content);
+} /* Icon visibility fix */
+.input:focus ~ .icon,
+.input:focus-within ~ .icon,
+.relative .input:focus ~ [class*="icon"],
+.relative .input:focus ~ svg { opacity: 1 !important; visibility: visible !important; display: flex !important;
+} .input { position: relative; z-index: 1;
+} /* -------------------------------------------------------------------------- SURFACES -------------------------------------------------------------------------- */ .card,
+.panel-card,
+.social-page-card { border: 1px solid transparent; border-color: var(--surface-panel-border); background-color: var(--color-base-200) !important; background-image: none !important; box-shadow: var(--surface-panel-shadow);
+} /* -------------------------------------------------------------------------- CARDS -------------------------------------------------------------------------- */ .card { transition: background-color 0.3s ease, border-color 0.3s ease, box-shadow 0.3s ease;
+} .panel-card { border-color: var(--surface-panel-border); background-color: var(--surface-panel-bg-fallback) !important; position: relative;
+} .panel-card.sticky { contain: none; transform: none;
+} #timeline-session-continuity { --timeline-sidebar-gap: var(--app-sticky-sidebar-gap, 1.5rem);
+} .timeline-sidebar-scroll { contain: none; transform: none;
+} .timeline-sidebar-scroll { max-height: calc(100vh - var(--timeline-sidebar-gap)); overflow-y: auto; scrollbar-gutter: stable;
+} .panel-card > .card-body { position: relative;
+} .card-interactive:focus-visible,
+[data-post-id]:focus-visible { outline: none; box-shadow: 0 0 0 2px var(--color-primary);
+} .surface-subtle { border: 1px solid var(--surface-panel-border); background-color: var(--color-base-200, #151b22) !important; background-color: color-mix(in srgb, var(--surface-panel-bg-fallback) 78%, var(--color-base-100) 22%) !important; background-image: none !important; box-shadow: none;
+} .surface-muted { border: 1px solid var(--surface-panel-border); background-color: color-mix(in srgb, var(--color-base-200) 84%, transparent); box-shadow: none;
+} .surface-soft { border: 1px solid var(--surface-panel-border); background-color: color-mix(in srgb, var(--color-base-100) 65%, var(--surface-panel-bg-fallback) 35%); box-shadow: none;
+} .surface-interactive { border: 1px solid var(--surface-panel-border); background-color: var(--surface-panel-bg-fallback) !important; box-shadow: var(--surface-panel-shadow); transition: background-color 0.2s ease, border-color 0.2s ease, transform 0.2s ease;
+} .surface-interactive:hover,
+.surface-interactive:focus-visible { background-color: color-mix(in srgb, var(--surface-panel-bg-fallback) 82%, var(--color-base-300) 18%) !important; border-color: color-mix(in srgb, var(--color-base-content) 15%, var(--surface-panel-border) 85%);
+} /* -------------------------------------------------------------------------- ALERTS & FLASH MESSAGES -------------------------------------------------------------------------- */ #flash-group { perspective: 1000px;
+} .alert.app-flash { position: relative; align-items: flex-start; gap: 0.75rem; transform-origin: top right; animation: flash-slide-in 0.2s ease-out;
+} .app-flash__dismiss { margin-left: auto; align-self: flex-start;
+} .alert.app-flash.is-dismissing { animation: app-flash-exit var(--flash-exit, 260ms) cubic-bezier(0.35, 0.05, 0.68, 0.17) forwards; pointer-events: none;
+} @keyframes app-flash-exit { from { opacity: 1; transform: translate3d(0, 0, 0) scale(1); } to { opacity: 0; transform: translate3d(24px, -4px, 0) scale(0.95); }
+} .flash-message { position: fixed !important; right: 1rem !important; width: 20rem !important; max-width: calc(100vw - 2rem) !important; z-index: 9999 !important; pointer-events: auto !important; animation: flash-slide-in 0.3s ease-out;
+} @keyframes flash-slide-in { from { opacity: 0; transform: translateX(1rem); } to { opacity: 1; transform: translateX(0); }
+} .alert { border: 1px solid color-mix(in srgb, var(--color-base-300) 68%, white 32%); background-color: color-mix(in srgb, var(--color-base-200) 84%, var(--color-base-100) 16%); color: var(--color-base-content); box-shadow: none; flex-wrap: wrap; min-width: 0; overflow-wrap: anywhere;
+} .alert > * { min-width: 0;
+} .app-flash--info,
+.app-flash--success,
+.app-flash--loading { --flash-accent: color-mix(in srgb, var(--color-success) 90%, var(--color-base-content) 10%); --flash-accent-soft: color-mix(in srgb, var(--color-success) 24%, var(--color-base-200) 76%); --flash-tint-start: color-mix(in srgb, var(--color-success) 16%, var(--color-base-200) 84%); --flash-tint-mid: color-mix(in srgb, var(--color-success) 6%, var(--color-base-200) 94%); --flash-border: color-mix(in srgb, var(--color-success) 42%, var(--color-base-300) 58%); --flash-icon: color-mix(in srgb, var(--color-success) 96%, var(--color-base-content) 4%);
+} .app-flash--warning { --flash-accent: color-mix(in srgb, var(--color-warning) 90%, var(--color-base-content) 10%); --flash-accent-soft: color-mix(in srgb, var(--color-warning) 24%, var(--color-base-200) 76%); --flash-tint-start: color-mix(in srgb, var(--color-warning) 16%, var(--color-base-200) 84%); --flash-tint-mid: color-mix(in srgb, var(--color-warning) 6%, var(--color-base-200) 94%); --flash-border: color-mix(in srgb, var(--color-warning) 42%, var(--color-base-300) 58%); --flash-icon: color-mix(in srgb, var(--color-warning) 72%, var(--color-base-content) 28%);
+} .app-flash--error { --flash-accent: color-mix(in srgb, var(--color-error) 92%, var(--color-base-content) 8%); --flash-accent-soft: color-mix(in srgb, var(--color-error) 25%, var(--color-base-200) 75%); --flash-tint-start: color-mix(in srgb, var(--color-error) 20%, var(--color-base-200) 80%); --flash-tint-mid: color-mix(in srgb, var(--color-error) 8%, var(--color-base-200) 92%); --flash-border: color-mix(in srgb, var(--color-error) 48%, var(--color-base-300) 52%); --flash-icon: color-mix(in srgb, var(--color-error) 72%, var(--color-error-content) 28%);
+} .alert.app-flash { border-color: var(--flash-border); background-color: color-mix(in srgb, var(--flash-tint-start) 72%, var(--color-base-200) 28%); color: color-mix(in srgb, var(--flash-accent) 38%, var(--color-base-content) 62%);
+} .alert.app-flash > svg,
+.alert.app-flash .flash-message__icon { color: var(--flash-icon);
+} .alert-info { border-color: color-mix(in srgb, var(--color-info) 22%, var(--color-base-300) 78%); background-color: color-mix(in srgb, var(--color-info) 10%, var(--color-base-200) 90%); color: color-mix(in srgb, var(--color-info) 36%, var(--color-base-content) 64%);
+} .alert-success { border-color: color-mix(in srgb, var(--color-success) 24%, var(--color-base-300) 76%); background-color: color-mix(in srgb, var(--color-success) 10%, var(--color-base-200) 90%); color: color-mix(in srgb, var(--color-success) 34%, var(--color-base-content) 66%);
+} .alert-warning { border-color: color-mix(in srgb, var(--color-warning) 26%, var(--color-base-300) 74%); background-color: color-mix(in srgb, var(--color-warning) 11%, var(--color-base-200) 89%); color: color-mix(in srgb, var(--color-warning) 38%, var(--color-base-content) 62%);
+} .alert-error { border-color: color-mix(in srgb, var(--color-error) 24%, var(--color-base-300) 76%); background-color: color-mix(in srgb, var(--color-error) 10%, var(--color-base-200) 90%); color: color-mix(in srgb, var(--color-error) 36%, var(--color-base-content) 64%);
+} .system-announcement { --announcement-accent: var(--color-info); border: 1px solid var(--surface-panel-border); border-left: 4px solid var(--announcement-accent); background-color: var(--surface-panel-bg-fallback) !important; box-shadow: var(--surface-panel-shadow); color: var(--color-base-content);
+} .system-announcement__icon { background-color: color-mix(in srgb, var(--announcement-accent) 14%, var(--color-base-200) 86%); color: var(--announcement-accent);
+} .system-announcement--info { --announcement-accent: var(--color-info);
+} .system-announcement--warning { --announcement-accent: var(--color-warning);
+} /* -------------------------------------------------------------------------- SHORTCUT OVERLAYS -------------------------------------------------------------------------- */ .shortcut-overlay { background-color: var(--theme-modal-backdrop); color: var(--color-base-content);
+} .shortcut-dialog { border: 1px solid var(--surface-modal-border); background-color: var(--surface-modal-bg-fallback) !important; background-image: none !important; color: var(--color-base-content); box-shadow: var(--surface-modal-shadow);
+} .shortcut-floating { border: 1px solid var(--surface-floating-border); background-color: var(--surface-floating-bg-fallback) !important; background-image: none !important; color: var(--color-base-content); box-shadow: var(--surface-floating-shadow);
+} .shortcut-heading { color: color-mix(in srgb, var(--color-primary) 72%, var(--color-base-content) 28%);
+} .shortcut-muted { color: color-mix(in srgb, var(--color-base-content) 62%, transparent);
+} .shortcut-panel { border: 1px solid var(--surface-panel-border); background-color: color-mix(in srgb, var(--surface-panel-bg-fallback) 74%, var(--color-base-100) 26%) !important; color: var(--color-base-content);
+} .shortcut-divider { border-color: var(--surface-panel-border);
+} .shortcut-dialog .kbd,
+.shortcut-panel .kbd,
+.shortcut-floating .kbd { border-color: color-mix(in srgb, var(--color-primary) 42%, var(--surface-panel-border) 58%); background-color: color-mix(in srgb, var(--color-primary) 14%, var(--color-base-200) 86%); color: color-mix(in srgb, var(--color-primary) 64%, var(--color-base-content) 36%); box-shadow: inset 0 -1px 0 color-mix(in srgb, var(--color-primary) 18%, transparent);
+} .system-announcement--maintenance { --announcement-accent: color-mix(in srgb, var(--color-base-content) 55%, var(--color-base-300) 45%);
+} .system-announcement--feature { --announcement-accent: var(--color-success);
+} .system-announcement--urgent { --announcement-accent: var(--color-error);
+} /* -------------------------------------------------------------------------- NAVBAR -------------------------------------------------------------------------- */ .navbar { background-color: var(--surface-panel-bg-fallback) !important; background-image: none !important; border-bottom: 1px solid var(--theme-navbar-border) !important; box-shadow: var(--theme-navbar-shadow);
+} .app-navbar.navbar { background-color: var(--color-base-200) !important; background-image: none !important;
+} .navbar .navbar-start,
+.navbar .navbar-center,
+.navbar .navbar-end,
+.navbar .dropdown { overflow: visible;
+} .e-nav .e-nav-link,
+.e-nav .e-nav-link:hover,
+.e-nav .e-nav-link:focus,
+.e-nav .e-nav-link:focus-visible,
+.e-nav .e-nav-link:active,
+.e-nav .e-nav-link:visited { text-decoration: none !important; box-shadow: none;
+} .e-nav .e-nav-link::-moz-focus-inner { border: 0;
+} .e-nav .e-nav-scroll { scrollbar-width: none; -ms-overflow-style: none;
+} .e-nav .e-nav-scroll::-webkit-scrollbar { display: none;
+} .e-nav .e-nav-link { color: var(--theme-nav-link);
+} .e-nav .e-nav-link:hover,
+.e-nav .e-nav-link:focus-visible { color: var(--color-base-content);
+} .navbar .badge-error,
+.indicator .badge-error { background-color: var(--theme-error-badge-bg) !important; border-color: var(--theme-error-badge-border) !important; color: var(--theme-text-inverse) !important;
+} /* -------------------------------------------------------------------------- DROPDOWNS -------------------------------------------------------------------------- */ .floating-panel,
+.floating-menu,
+.dropdown-content { border: 1px solid transparent; border-color: var(--surface-floating-border); background-color: var(--surface-floating-bg-fallback) !important; background-image: none !important; box-shadow: var(--surface-floating-shadow);
+} .floating-panel { position: relative; isolation: isolate;
+} .user-hover-card-panel { background-color: var(--surface-floating-bg-fallback) !important; background-image: none !important; opacity: 1 !important;
+} .dropdown-content { z-index: 100;
+} .dropdown { position: relative;
+} .dropdown .dropdown-content { position: absolute; visibility: hidden; opacity: 0; transform: translateY(-4px) scale(0.98); transition: opacity 0.2s ease, visibility 0.2s ease, transform 0.2s ease;
+} .navbar .dropdown-content,
+.navbar .menu.dropdown-content { z-index: 200;
+} .dropdown:focus-within .dropdown-content,
+.dropdown:focus .dropdown-content,
+.dropdown-open .dropdown-content { visibility: visible; opacity: 1; transform: translateY(0) scale(1);
+} .dropdown[data-portal-dropdown-root]:not([data-portal-dropdown-open="true"]):focus-within .dropdown-content[data-portal-dropdown-menu],
+.dropdown[data-portal-dropdown-root]:not([data-portal-dropdown-open="true"]):focus .dropdown-content[data-portal-dropdown-menu] { visibility: hidden !important; opacity: 0 !important; pointer-events: none !important; transform: translateY(-4px) scale(0.98) !important;
+} /* Timeline cards need higher stacking when menus are open */
+.timeline-post-card { position: relative; overflow: visible !important; z-index: 1; isolation: isolate; contain: none; transform: none !important;
+} .timeline-post-card:hover,
+.timeline-post-card:focus-within,
+.timeline-post-card:has(.timeline-reaction-dropdown[open]) { z-index: 430 !important;
+} .timeline-post-card > .card-body,
+.timeline-post-card .timeline-post-card-body { overflow: visible;
+} .timeline-post-card--dense { border-color: color-mix(in srgb, var(--surface-panel-border) 84%, var(--color-base-content) 6%) !important; border-radius: 0.5rem !important; background-color: color-mix(in srgb, var(--color-base-200) 88%, var(--color-base-100) 12%) !important; backdrop-filter: none; box-shadow: none;
+} .timeline-post-card--dense:hover,
+.timeline-post-card--dense:focus-within { background-color: color-mix(in srgb, var(--color-base-200) 78%, var(--color-base-300) 22%) !important; border-color: color-mix(in srgb, var(--surface-panel-border) 72%, var(--color-primary) 18%) !important;
+} .timeline-post-card--dense.timeline-post-card--reply { border-left-width: 1px !important; border-left-color: color-mix(in srgb, var(--surface-panel-border) 84%, var(--color-base-content) 6%) !important;
+} .timeline-post-card--dense.timeline-post-card--reply:hover,
+.timeline-post-card--dense.timeline-post-card--reply:focus-within { border-left-width: 1px !important; border-left-color: color-mix(in srgb, var(--surface-panel-border) 72%, var(--color-primary) 18%) !important;
+} .timeline-post-card--dense .timeline-post-dense-content .post-content { font-size: 0.9375rem; line-height: 1.35;
+} .timeline-post-card--dense .timeline-post-dense-content h3.post-content { margin-bottom: 0.25rem !important; font-size: 1rem; line-height: 1.3;
+} .timeline-post-card--dense .timeline-post-dense-content :where(.mb-3) { margin-bottom: 0.5rem !important;
+} .timeline-post-card--dense .timeline-post-dense-content :where(.mt-3) { margin-top: 0.5rem !important;
+} .timeline-post-card--dense .timeline-post-dense-content :where(.p-3) { padding: 0.625rem !important;
+} .timeline-post-card--dense .timeline-post-dense-footer > div { justify-content: flex-start; border-top: 0; padding-top: 0.125rem;
+} .timeline-post-card--dense .timeline-post-dense-footer .btn { min-height: 1.75rem; height: 1.75rem;
+} .timeline-post-dropdown { position: relative; z-index: 90; overflow: visible;
+} .timeline-post-dropdown .dropdown-content,
+.timeline-post-card .timeline-post-dropdown-menu { z-index: 260; position: absolute;
+} .timeline-post-dropdown-menu { width: min(18rem, calc(100vw - 1rem)); max-width: calc(100vw - 1rem); overflow: hidden;
+} .timeline-post-dropdown-menu :where(li > a, li > button, li > details > summary) { min-width: 0; white-space: normal; overflow-wrap: anywhere; word-break: break-word; line-height: 1.25;
+} .timeline-post-dropdown-menu :where(svg, .ui-icon) { flex-shrink: 0;
+} .timeline-reaction-dropdown { position: relative; z-index: 90; overflow: visible;
+} .timeline-reaction-dropdown[open] { z-index: 430 !important;
+} .timeline-reaction-dropdown .dropdown-content { width: max-content; max-width: calc(100vw - 1rem); overflow: visible;
+} .timeline-reaction-dropdown[open] .dropdown-content { z-index: 430 !important;
+} .thread-context-card[role="button"] { cursor: pointer;
+} .thread-context-card[role="button"]:focus-visible { outline: 2px solid color-mix(in srgb, var(--color-primary) 55%, transparent); outline-offset: -2px;
+}
+.timeline-thread-current-card { box-shadow: 0 10px 28px color-mix(in srgb, var(--color-base-content) 6%, transparent);
+} .timeline-inline-reply-target { position: relative;
+} .timeline-inline-reply-target__card { display: block;
+} .timeline-thread-replies { position: relative; margin-left: 0.65rem; padding-left: 2.05rem; overflow: visible;
+} .timeline-thread-replies__rail { display: block; pointer-events: none; position: absolute; left: 0.78rem; top: -0.65rem; bottom: 0.65rem; width: 2px; border-radius: 9999px; background: linear-gradient(to bottom, color-mix(in srgb, var(--color-primary) 34%, var(--surface-panel-border) 66%), color-mix(in srgb, var(--color-base-content) 12%, transparent)); box-shadow: none; z-index: 0;
+} .timeline-thread-replies--stub::before { bottom: 0;
+} .timeline-thread-reply-row { position: relative; overflow: visible; z-index: 1;
+} .timeline-thread-reply-node { display: block; pointer-events: none; position: absolute; left: -1.34rem; top: 0.95rem; width: 10px; height: 10px; border-radius: 9999px; background: color-mix(in srgb, var(--color-base-200) 84%, var(--color-base-100) 16%); border: 2px solid color-mix(in srgb, var(--color-primary) 50%, var(--surface-panel-border) 50%); box-shadow: none; z-index: 3;
+} .timeline-thread-reply-elbow { display: block; pointer-events: none; position: absolute; left: -1.08rem; top: 1.22rem; width: 0.88rem; height: 2px; border-radius: 9999px; background: color-mix(in srgb, var(--color-primary) 32%, var(--surface-panel-border) 68%); z-index: 2;
+} .timeline-thread-reply-card { position: relative; z-index: 1; border: 1px solid color-mix(in srgb, var(--surface-panel-border) 82%, var(--color-base-content) 6%) !important; border-radius: 0.5rem !important; background: color-mix(in srgb, var(--color-base-200) 86%, var(--color-base-100) 14%) !important; box-shadow: none;
+} .timeline-thread-reply-card:hover,
+.timeline-thread-reply-card:focus-within { background: color-mix(in srgb, var(--color-base-200) 76%, var(--color-base-300) 24%) !important; border-color: color-mix(in srgb, var(--surface-panel-border) 72%, var(--color-primary) 18%) !important; box-shadow: none;
+} .timeline-thread-comment-card { position: relative; border-color: color-mix(in srgb, var(--surface-panel-border) 82%, var(--color-base-content) 6%) !important; border-radius: 0.5rem !important; background: color-mix(in srgb, var(--color-base-200) 86%, var(--color-base-100) 14%) !important; backdrop-filter: none; box-shadow: none;
+} .timeline-thread-comment-card:hover,
+.timeline-thread-comment-card:focus-within { background: color-mix(in srgb, var(--color-base-200) 76%, var(--color-base-300) 24%) !important; border-color: color-mix(in srgb, var(--surface-panel-border) 72%, var(--color-primary) 18%) !important; box-shadow: none;
+} .timeline-thread-inline-form { margin-left: 0.875rem;
+} .timeline-thread-tree-node { position: relative; overflow: visible;
+} .timeline-thread-tree-node--nested { margin-left: 1.05rem; padding-left: 1.7rem; overflow: visible; z-index: 1;
+} .timeline-thread-tree-node--nested::before { content: ""; position: absolute; left: 0.62rem; top: -0.3rem; bottom: -0.3rem; width: 2px; border-radius: 9999px; background: color-mix(in srgb, var(--color-primary) 32%, var(--surface-panel-border) 68%); box-shadow: none; pointer-events: none; z-index: 1;
+} .timeline-thread-tree-node--nested::after { content: ""; position: absolute; left: 0.62rem; top: 1.15rem; width: 0.95rem; height: 2px; border-radius: 9999px; background: color-mix(in srgb, var(--color-primary) 32%, var(--surface-panel-border) 68%); pointer-events: none; z-index: 1;
+} .timeline-thread-tree-card { border: 1px solid color-mix(in srgb, var(--surface-panel-border) 82%, var(--color-base-content) 6%); background: color-mix(in srgb, var(--color-base-200) 86%, var(--color-base-100) 14%);
+} .timeline-thread-reply-row:hover,
+.timeline-thread-reply-row:focus-within,
+.timeline-thread-reply-card:hover,
+.timeline-thread-reply-card:focus-within,
+.timeline-thread-tree-node:hover,
+.timeline-thread-tree-node:focus-within,
+.timeline-thread-tree-node--nested:hover,
+.timeline-thread-tree-node--nested:focus-within,
+.timeline-thread-tree-card:hover,
+.timeline-thread-tree-card:focus-within { z-index: 320;
+} .timeline-post-card:has([data-reaction-picker-root][open]),
+.timeline-thread-tree-node:has([data-reaction-picker-root][open]),
+.timeline-thread-reply-row:has([data-reaction-picker-root][open]),
+.timeline-thread-reply-card:has([data-reaction-picker-root][open]),
+.timeline-thread-comment-card:has([data-reaction-picker-root][open]),
+.timeline-thread-tree-card:has([data-reaction-picker-root][open]),
+.timeline-thread-preview-item:has([data-reaction-picker-root][open]),
+.thread-context-card:has([data-reaction-picker-root][open]) { z-index: 430 !important;
+} .timeline-thread-preview-list { position: relative; margin-top: 0.5rem; padding-left: 1.55rem;
+} .timeline-thread-preview-list::before { content: ""; position: absolute; left: 0.62rem; top: 0.35rem; bottom: 0.3rem; width: 2px; border-radius: 9999px; background: linear-gradient(to bottom, color-mix(in srgb, var(--color-primary) 22%, var(--surface-panel-border) 78%), color-mix(in srgb, var(--color-base-content) 8%, transparent));
+} .timeline-thread-preview-item { position: relative; background: var(--color-base-100);
+} .timeline-thread-preview-item::before { content: ""; position: absolute; left: -0.82rem; top: 0.9rem; width: 0.58rem; height: 2px; border-radius: 9999px; background: color-mix(in srgb, var(--color-primary) 18%, var(--surface-panel-border) 82%);
+} .timeline-thread-preview-list--flush { padding-left: 0;
+} .timeline-thread-preview-list--flush::before { display: none;
+} .timeline-thread-preview-item--flush::before { display: none;
+} @media (max-width: 640px) {
+ .timeline-thread-replies { margin-left: 0.2rem; padding-left: 1.15rem;
+ } .timeline-thread-replies__rail { left: 0.34rem; top: -0.65rem; width: 2px;
+ } .timeline-thread-reply-node { left: -0.88rem; top: 0.95rem; width: 8px; height: 8px;
+ } .timeline-thread-reply-elbow { left: -0.68rem; top: 1.17rem; width: 0.45rem;
+ } .timeline-thread-inline-form { margin-left: 0.25rem;
+ } .timeline-thread-tree-node--nested { margin-left: 0.4rem; padding-left: 0.9rem;
+ } .timeline-thread-tree-node--nested::before { left: 0.34rem; width: 2px;
+ } .timeline-thread-tree-node--nested::after { left: 0.34rem; top: 1.12rem; width: 0.5rem; height: 2px;
+ } .timeline-thread-preview-list { padding-left: 1rem;
+ } .timeline-thread-preview-list::before { left: 0.34rem;
+ } .timeline-thread-preview-item::before { left: -0.58rem; width: 0.4rem;
+ }
+} /* -------------------------------------------------------------------------- MODALS -------------------------------------------------------------------------- */ .modal,
+.modal.modal-open { background: var(--theme-modal-backdrop);
+} .modal::backdrop { background: var(--theme-modal-backdrop);
+} .modal-backdrop { background: var(--theme-modal-backdrop) !important;
+} .modal-surface,
+.modal-box { border: 1px solid transparent; border-color: var(--surface-modal-border); background-color: var(--surface-modal-bg-fallback) !important; background-image: none !important; box-shadow: var(--surface-modal-shadow);
+} .modal-box { position: relative; isolation: isolate; border-radius: 0.75rem !important; animation: modal-pop 0.2s ease-out; max-width: min(32rem, calc(100dvw - 2rem));
+} .modal-action { flex-wrap: wrap;
+} @keyframes modal-pop { from { opacity: 0; transform: scale(0.96) translateY(8px); } to { opacity: 1; transform: scale(1) translateY(0); }
+} /* -------------------------------------------------------------------------- ANALYTICS CHARTS -------------------------------------------------------------------------- */ .analytics-chart { color: var(--color-base-content); min-width: 0;
+} .analytics-uplot { min-height: 18rem; overflow: visible; width: 100%;
+} .analytics-uplot .uplot { background: transparent; color: var(--color-base-content); font-family: inherit; max-width: 100%;
+} .analytics-uplot .u-title { display: none;
+} .analytics-uplot .u-wrap { background: transparent;
+} .analytics-uplot .u-under, .analytics-uplot .u-over { border-radius: 0.75rem;
+} .analytics-uplot .u-axis { color: color-mix(in srgb, var(--color-base-content) 62%, transparent); font-size: 11px;
+} .analytics-uplot .u-legend { background: color-mix(in srgb, var(--color-base-200) 88%, var(--color-base-100) 12%); border: 1px solid var(--surface-panel-border); border-radius: 0.75rem; color: var(--color-base-content); font-size: 12px; margin: 0 0 0.75rem 0; padding: 0.45rem 0.65rem;
+} .analytics-uplot .u-legend th, .analytics-uplot .u-legend td { border: 0 !important; padding: 0.15rem 0.4rem;
+} .analytics-uplot .u-legend .u-marker { border-radius: 9999px;
+} .analytics-uplot .u-cursor-x, .analytics-uplot .u-cursor-y { border-color: color-mix(in srgb, var(--color-base-content) 32%, transparent) !important;
+} .analytics-uplot .u-cursor-pt { box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-base-100) 88%, transparent);
+} /* -------------------------------------------------------------------------- MENUS -------------------------------------------------------------------------- */ /* -------------------------------------------------------------------------- BADGES & TABS -------------------------------------------------------------------------- */ /* -------------------------------------------------------------------------- AVATARS -------------------------------------------------------------------------- */ .avatar:hover,
+.avatar img:hover,
+[class*="avatar"]:hover { transform: none !important;
+} /* -------------------------------------------------------------------------- LINKS -------------------------------------------------------------------------- */ a:focus-visible { outline: none; border-radius: 0.25rem; box-shadow: 0 0 0 2px color-mix(in srgb, var(--color-primary) 50%, transparent);
+} .table :where(thead, tbody) :where(tr:not(:last-child)),
+.table tr { border-bottom-color: var(--theme-navbar-border);
+} .table :where(thead, tbody) :where(tr:hover),
+table tr:hover { background-color: var(--theme-table-row-hover);
+} /* -------------------------------------------------------------------------- PROSE (Markdown content) -------------------------------------------------------------------------- */ .prose table { display: block; overflow-x: auto; white-space: nowrap; max-width: 100%;
+} .prose img { max-width: 100% !important; height: auto !important; display: block !important; margin: 0 auto !important;
+} .prose pre { overflow-x: auto; max-width: 100%;
+} .prose code { word-wrap: break-word;
+} .prose > * { max-width: 100%; overflow-wrap: break-word; word-wrap: break-word;
+} .prose table td,
+.prose table th { min-width: auto; white-space: normal;
+} /* Vertical rhythm — Tailwind preflight zeroes these margins and there is no typography plugin, so without these rules markdown paragraphs render with no gap between them */ .prose p,
+.prose ul,
+.prose ol,
+.prose blockquote,
+.prose pre,
+.prose table { margin-top: 0.75em; margin-bottom: 0.75em;
+} .prose h1,
+.prose h2,
+.prose h3,
+.prose h4,
+.prose h5,
+.prose h6 { margin-top: 1.25em; margin-bottom: 0.5em; font-weight: 600; line-height: 1.3;
+} .prose h1 { font-size: 1.5em;
+} .prose h2 { font-size: 1.25em;
+} .prose h3 { font-size: 1.125em;
+} .prose ul { list-style-type: disc; padding-left: 1.5em;
+} .prose ol { list-style-type: decimal; padding-left: 1.5em;
+} .prose li { margin-top: 0.25em; margin-bottom: 0.25em;
+} .prose blockquote { border-left: 3px solid color-mix(in srgb, var(--color-base-content) 25%, transparent); padding-left: 1em;
+} .prose hr { margin-top: 1.5em; margin-bottom: 1.5em; border-color: color-mix(in srgb, var(--color-base-content) 15%, transparent);
+} .prose > :first-child { margin-top: 0;
+} .prose > :last-child { margin-bottom: 0;
+}
+
+/* daisyUI 5.6 parity: keep chrome text as it looked on 5.5.
+ 5.6 paints active/focused menu items solid neutral (a dark blue-gray in our
+ palette) and dims inactive tab text to a hardcoded 50% mix. Menu colors are
+ themable via the new variables; the tab dim has no variable, so that one is
+ a rule (ours is unlayered and wins over daisyUI's layered rule). Checked
+ buttons also stay base-content instead of switching to primary. Disabled
+ states keep daisyUI's 5.6 styling. */
+
+.tabs > .tab:not(.tab-active, [aria-selected="true"], [aria-current="true"], [aria-current="page"], .tab-disabled, [disabled]) {
+ color: var(--color-base-content);
+}
+
+.btn:where(:checked) {
+ color: var(--color-base-content);
+}
+
+/* Success flash on a copy trigger. `JS.transition` adds this for two seconds
+ and removes it again, so the state lives here rather than in a hook holding
+ a setTimeout. Its own class rather than daisyUI's `btn-success` because the
+ triggers are ghost buttons and daisyUI's layered rules would win. */
+.copy-flash,
+.copy-flash:hover {
+ background-color: var(--color-success);
+ color: var(--color-success-content);
+}
diff --git a/apps/elektrine/assets/css/effects.css b/apps/elektrine/assets/css/effects.css
new file mode 100644
index 0000000..a589e7a
--- /dev/null
+++ b/apps/elektrine/assets/css/effects.css
@@ -0,0 +1,444 @@
+/* ==========================================================================
+ EFFECTS - Username, Avatar, Link, and Visual Effects
+ ========================================================================== */
+
+/* --------------------------------------------------------------------------
+ USERNAME EFFECTS
+ -------------------------------------------------------------------------- */
+
+.username-effect-glow {
+ text-shadow:
+ 0 0 2px color-mix(in srgb, var(--theme-effect-outline-stroke) 70%, transparent),
+ 0 0 5px color-mix(in srgb, var(--theme-effect-outline-stroke) 50%, transparent),
+ 0 0 10px color-mix(in srgb, var(--theme-effect-outline-stroke) 30%, transparent);
+ padding: 0 4px;
+ margin: 0 -4px;
+}
+
+.username-effect-neon {
+ color: var(--theme-effect-outline-stroke);
+ text-shadow:
+ 0 0 5px var(--theme-effect-outline-stroke),
+ 0 0 10px var(--theme-effect-outline-stroke),
+ 0 0 20px var(--theme-effect-outline-stroke),
+ 0 0 40px var(--theme-effect-outline-stroke);
+ animation: neon-pulse var(--animation-duration, 2s) ease-in-out infinite;
+}
+
+@keyframes neon-pulse {
+ 0%, 100% {
+ opacity: 1;
+ text-shadow:
+ 0 0 5px var(--theme-effect-outline-stroke),
+ 0 0 10px var(--theme-effect-outline-stroke),
+ 0 0 20px var(--theme-effect-outline-stroke),
+ 0 0 40px var(--theme-effect-outline-stroke);
+ }
+ 50% {
+ opacity: 0.8;
+ text-shadow:
+ 0 0 3px var(--theme-effect-outline-stroke),
+ 0 0 7px var(--theme-effect-outline-stroke),
+ 0 0 15px var(--theme-effect-outline-stroke),
+ 0 0 30px var(--theme-effect-outline-stroke);
+ }
+}
+
+.username-effect-rainbow {
+ background: var(--theme-effect-rainbow);
+ -webkit-background-clip: text;
+ -webkit-text-fill-color: transparent;
+ background-clip: text;
+ background-size: 200% 100%;
+ animation: rainbow-slide var(--animation-duration, 3s) linear infinite;
+}
+
+@keyframes rainbow-slide {
+ 0% { background-position: 0% 50%; }
+ 100% { background-position: 200% 50%; }
+}
+
+.username-effect-shadow {
+ text-shadow: 3px 3px 6px var(--theme-effect-shadow-color);
+}
+
+.username-effect-gradient {
+ background: linear-gradient(90deg, var(--gradient-from, var(--color-primary)), var(--gradient-to, var(--color-secondary)));
+ -webkit-background-clip: text;
+ -webkit-text-fill-color: transparent;
+ background-clip: text;
+}
+
+.username-effect-glitch {
+ position: relative;
+ animation: glitch var(--animation-duration, 1s) infinite;
+}
+
+@keyframes glitch {
+ 0% { text-shadow: 0.05em 0 0 var(--theme-effect-glitch-cyan), -0.03em -0.04em 0 var(--theme-effect-glitch-magenta), 0.025em 0.04em 0 var(--theme-effect-glitch-yellow); }
+ 15% { text-shadow: 0.05em 0 0 var(--theme-effect-glitch-cyan), -0.03em -0.04em 0 var(--theme-effect-glitch-magenta), 0.025em 0.04em 0 var(--theme-effect-glitch-yellow); }
+ 16% { text-shadow: -0.05em -0.025em 0 var(--theme-effect-glitch-cyan), 0.025em 0.035em 0 var(--theme-effect-glitch-magenta), -0.05em -0.05em 0 var(--theme-effect-glitch-yellow); }
+ 49% { text-shadow: -0.05em -0.025em 0 var(--theme-effect-glitch-cyan), 0.025em 0.035em 0 var(--theme-effect-glitch-magenta), -0.05em -0.05em 0 var(--theme-effect-glitch-yellow); }
+ 50% { text-shadow: 0.05em 0.035em 0 var(--theme-effect-glitch-cyan), 0.03em 0 0 var(--theme-effect-glitch-magenta), 0 -0.04em 0 var(--theme-effect-glitch-yellow); }
+ 99% { text-shadow: 0.05em 0.035em 0 var(--theme-effect-glitch-cyan), 0.03em 0 0 var(--theme-effect-glitch-magenta), 0 -0.04em 0 var(--theme-effect-glitch-yellow); }
+ 100% { text-shadow: -0.05em 0 0 var(--theme-effect-glitch-cyan), -0.025em -0.04em 0 var(--theme-effect-glitch-magenta), -0.04em -0.025em 0 var(--theme-effect-glitch-yellow); }
+}
+
+.username-effect-fire {
+ background: var(--theme-effect-fire);
+ -webkit-background-clip: text;
+ -webkit-text-fill-color: transparent;
+ background-clip: text;
+ background-size: 200% 100%;
+ animation: fire-flicker var(--animation-duration, 2s) ease-in-out infinite;
+ filter: var(--theme-effect-fire-shadow);
+}
+
+.username-effect-ice {
+ color: var(--theme-effect-ice-color);
+ text-shadow: var(--theme-effect-ice-shadow);
+ animation: ice-shimmer var(--animation-duration, 3s) ease-in-out infinite;
+}
+
+@keyframes ice-shimmer {
+ 0%, 100% { filter: brightness(1); }
+ 50% { filter: brightness(1.3); }
+}
+
+.username-effect-chrome {
+ background: var(--theme-effect-chrome);
+ -webkit-background-clip: text;
+ -webkit-text-fill-color: transparent;
+ background-clip: text;
+ background-size: 200% 100%;
+ filter: var(--theme-effect-chrome-shadow);
+}
+
+.username-effect-holographic {
+ background: var(--theme-effect-holographic);
+ -webkit-background-clip: text;
+ -webkit-text-fill-color: transparent;
+ background-clip: text;
+ background-size: 300% 100%;
+ animation: holographic-shift var(--animation-duration, 3s) linear infinite;
+ filter: brightness(1.2);
+}
+
+@keyframes holographic-shift {
+ 0% { background-position: 0% 50%; }
+ 100% { background-position: 300% 50%; }
+}
+
+.username-effect-outline {
+ color: transparent;
+ -webkit-text-stroke: 2px var(--theme-effect-outline-stroke);
+ text-stroke: 2px var(--theme-effect-outline-stroke);
+}
+
+.username-effect-double {
+ position: relative;
+}
+
+.username-effect-double::after {
+ content: attr(data-text);
+ position: absolute;
+ left: 3px;
+ top: 3px;
+ z-index: -1;
+ color: var(--theme-effect-shadow-color);
+ opacity: 0.5;
+}
+
+.username-effect-retro {
+ background: var(--theme-effect-retro);
+ -webkit-background-clip: text;
+ -webkit-text-fill-color: transparent;
+ background-clip: text;
+ filter: var(--theme-effect-retro-shadow);
+ font-weight: bold;
+ letter-spacing: 2px;
+}
+
+.username-effect-pixelated {
+ image-rendering: pixelated;
+ -ms-interpolation-mode: nearest-neighbor;
+ font-weight: 700;
+ text-shadow:
+ 1px 1px 0 var(--theme-effect-outline-stroke),
+ 2px 2px 0 var(--theme-effect-shadow-color);
+}
+
+/* Animation speed modifiers */
+.username-animation-slow { --animation-duration: 4s; }
+.username-animation-normal { --animation-duration: 2s; }
+.username-animation-fast { --animation-duration: 1s; }
+
+/* Typewriter */
+.typewriter-cursor::after {
+ content: '|';
+ animation: blink 1s infinite;
+ margin-left: 2px;
+}
+
+/* --------------------------------------------------------------------------
+ AVATAR EFFECTS
+ -------------------------------------------------------------------------- */
+
+.avatar-effect-wrapper {
+ position: relative;
+ display: inline-block;
+}
+
+.avatar-effect-glow {
+ animation: avatar-glow-pulse 2s ease-in-out infinite;
+}
+
+@keyframes avatar-glow-pulse {
+ 0%, 100% {
+ filter: drop-shadow(0 0 8px var(--theme-avatar-accent-color)) drop-shadow(0 0 12px var(--theme-avatar-accent-color));
+ }
+ 50% {
+ filter: drop-shadow(0 0 16px var(--theme-avatar-accent-color)) drop-shadow(0 0 24px var(--theme-avatar-accent-color));
+ }
+}
+
+.avatar-effect-rainbow::before {
+ content: '';
+ position: absolute;
+ inset: -4px;
+ border-radius: inherit;
+ background: var(--theme-effect-rainbow);
+ background-size: 400% 400%;
+ animation: rainbow-rotate 3s linear infinite;
+ z-index: -1;
+}
+
+@keyframes rainbow-rotate {
+ 0% { background-position: 0% 50%; }
+ 100% { background-position: 400% 50%; }
+}
+
+.avatar-effect-fire::before {
+ content: '';
+ position: absolute;
+ inset: -4px;
+ border-radius: inherit;
+ background: var(--theme-effect-fire);
+ background-size: 300% 300%;
+ animation: fire-flicker 2s ease-in-out infinite;
+ z-index: -1;
+ filter: blur(2px);
+}
+
+@keyframes fire-flicker {
+ 0%, 100% { opacity: 0.8; background-position: 0% 50%; }
+ 25% { opacity: 1; background-position: 50% 50%; }
+ 50% { opacity: 0.7; background-position: 100% 50%; }
+ 75% { opacity: 0.9; background-position: 50% 50%; }
+}
+
+.avatar-effect-ice {
+ animation: avatar-ice-shimmer 3s ease-in-out infinite;
+}
+
+@keyframes avatar-ice-shimmer {
+ 0%, 100% {
+ filter: drop-shadow(0 0 8px var(--theme-effect-ice-color)) drop-shadow(0 0 12px var(--theme-effect-ice-highlight)) brightness(1);
+ }
+ 50% {
+ filter: drop-shadow(0 0 16px var(--theme-effect-ice-color)) drop-shadow(0 0 24px var(--theme-effect-ice-highlight)) brightness(1.2);
+ }
+}
+
+.avatar-effect-sparkle {
+ position: relative;
+}
+
+.avatar-effect-sparkle::after {
+ content: '';
+ position: absolute;
+ font-size: 20px;
+ animation: sparkle-float 3s ease-in-out infinite;
+ pointer-events: none;
+}
+
+@keyframes sparkle-float {
+ 0%, 100% { top: -10px; right: -10px; opacity: 0; }
+ 50% { top: -20px; right: 0px; opacity: 1; }
+}
+
+.avatar-effect-holographic::before {
+ content: '';
+ position: absolute;
+ inset: -4px;
+ border-radius: inherit;
+ background: var(--theme-effect-holographic);
+ background-size: 400% 400%;
+ animation: holographic-shift 3s linear infinite;
+ z-index: -1;
+ opacity: 0.8;
+}
+
+.avatar-effect-gold-frame {
+ position: relative;
+}
+
+.avatar-effect-gold-frame::before {
+ content: '';
+ position: absolute;
+ inset: -6px;
+ border-radius: inherit;
+ background: var(--theme-effect-gold-frame);
+ z-index: -1;
+ box-shadow: 0 0 20px var(--theme-effect-gold-frame-shadow), inset 0 0 20px var(--theme-effect-gold-inner-glow);
+}
+
+.avatar-effect-gold-frame::after {
+ content: '';
+ position: absolute;
+ inset: -3px;
+ border-radius: inherit;
+ background: var(--theme-effect-gold-surface);
+ z-index: -1;
+ box-shadow: 0 0 30px var(--theme-effect-gold-outer-glow), 0 0 40px var(--theme-effect-gold-outer-accent), inset 0 2px 4px var(--theme-effect-gold-top-highlight), inset 0 -2px 4px var(--theme-effect-gold-bottom-shadow);
+ animation: imperial-shimmer 3s ease-in-out infinite;
+}
+
+@keyframes imperial-shimmer {
+ 0%, 100% { filter: brightness(1) saturate(1); }
+ 50% { filter: brightness(1.2) saturate(1.3); }
+}
+
+.avatar-effect-pulse {
+ animation: avatar-pulse 2s ease-in-out infinite;
+}
+
+@keyframes avatar-pulse {
+ 0%, 100% {
+ transform: scale(1);
+ box-shadow: 0 0 0 0 color-mix(in srgb, var(--theme-avatar-accent-color) 70%, transparent);
+ }
+ 50% {
+ transform: scale(1.05);
+ box-shadow: 0 0 0 10px color-mix(in srgb, var(--theme-avatar-accent-color) 0%, transparent);
+ }
+}
+
+.avatar-effect-rotate-border::before {
+ content: '';
+ position: absolute;
+ inset: -4px;
+ border-radius: inherit;
+ background: conic-gradient(from 0deg, var(--theme-avatar-accent-color), var(--theme-avatar-accent-light-color), var(--theme-avatar-accent-color));
+ animation: rotate-border 3s linear infinite;
+ z-index: -1;
+}
+
+@keyframes rotate-border {
+ 0% { transform: rotate(0deg); }
+ 100% { transform: rotate(360deg); }
+}
+
+/* --------------------------------------------------------------------------
+ IMAGE ZOOM TRIGGER
+ -------------------------------------------------------------------------- */
+
+.image-zoom-trigger {
+ position: relative;
+ cursor: zoom-in;
+ display: block;
+}
+
+.image-zoom-trigger::after {
+ content: '';
+ position: absolute;
+ inset: 0;
+ background: var(--theme-image-zoom-overlay);
+ opacity: 0;
+ transition: opacity 0.2s ease;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ border-radius: inherit;
+}
+
+.image-zoom-trigger::before {
+ content: '';
+ position: absolute;
+ top: 50%;
+ left: 50%;
+ transform: translate(-50%, -50%);
+ width: 40px;
+ height: 40px;
+ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24' stroke-width='2' stroke='white'%3E%3Cpath stroke-linecap='round' stroke-linejoin='round' d='m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607ZM10.5 7.5v6m3-3h-6'/%3E%3C/svg%3E");
+ background-size: contain;
+ background-repeat: no-repeat;
+ opacity: 0;
+ transition: opacity 0.2s ease;
+ z-index: 1;
+ pointer-events: none;
+}
+
+.image-zoom-trigger:hover::after,
+.image-zoom-trigger:hover::before {
+ opacity: 1;
+}
+
+.image-zoom-trigger img {
+ transition: opacity 0.2s ease;
+}
+
+/* Variant for rounded images (avatars) */
+.image-zoom-trigger-rounded::after {
+ border-radius: 9999px;
+}
+
+/* --------------------------------------------------------------------------
+ LINK EFFECTS
+ -------------------------------------------------------------------------- */
+
+.link-effect-glow {
+ box-shadow: 0 0 15px var(--theme-link-accent-color), 0 0 30px var(--theme-link-accent-color);
+ animation: link-glow-pulse 2s ease-in-out infinite;
+}
+
+@keyframes link-glow-pulse {
+ 0%, 100% { box-shadow: 0 0 15px var(--theme-link-accent-color), 0 0 25px var(--theme-link-accent-color); }
+ 50% { box-shadow: 0 0 20px var(--theme-link-accent-color), 0 0 35px var(--theme-link-accent-color); }
+}
+
+.link-effect-pulse {
+ animation: link-pulse-scale 2s ease-in-out infinite;
+}
+
+@keyframes link-pulse-scale {
+ 0%, 100% { transform: scale(1); }
+ 50% { transform: scale(1.1); }
+}
+
+.link-effect-border {
+ border-width: 3px !important;
+ border-color: var(--theme-link-accent-color) !important;
+ box-shadow: 0 0 0 3px var(--theme-link-border-shadow);
+}
+
+.link-effect-shine {
+ position: relative;
+ overflow: hidden;
+}
+
+.link-effect-shine::after {
+ content: '';
+ position: absolute;
+ top: -50%;
+ left: -50%;
+ width: 200%;
+ height: 200%;
+ background: var(--theme-link-shine);
+ animation: shine-sweep 3s ease-in-out infinite;
+}
+
+@keyframes shine-sweep {
+ 0% { transform: translateX(-100%) translateY(-100%) rotate(45deg); }
+ 100% { transform: translateX(100%) translateY(100%) rotate(45deg); }
+}
diff --git a/apps/elektrine/assets/css/email.css b/apps/elektrine/assets/css/email.css
new file mode 100644
index 0000000..2612da9
--- /dev/null
+++ b/apps/elektrine/assets/css/email.css
@@ -0,0 +1,245 @@
+/* ==========================================================================
+ EMAIL - Email content styling
+ ========================================================================== */
+
+/* --------------------------------------------------------------------------
+ EMAIL SIDEBAR
+ -------------------------------------------------------------------------- */
+
+.email-sidebar-layout {
+ --email-sidebar-gap: var(--app-sticky-sidebar-gap, 1.5rem);
+}
+
+.email-sidebar-scroll {
+ contain: none;
+ transform: none;
+}
+
+@media (min-width: 1024px) {
+ .email-sidebar-scroll {
+ max-height: calc(100vh - var(--email-sidebar-gap, 1.5rem));
+ overflow-y: auto;
+ scrollbar-gutter: stable;
+ }
+}
+
+/* --------------------------------------------------------------------------
+ EMAIL IFRAME
+ -------------------------------------------------------------------------- */
+
+#email-iframe {
+ overflow-x: auto !important;
+ overflow-y: auto !important;
+ border: 0 !important;
+ width: 100% !important;
+ max-width: 100% !important;
+ box-sizing: border-box !important;
+}
+
+.email-content-iframe {
+ display: block;
+ width: 100%;
+ border: 0;
+ background: var(--theme-email-wrapper-bg);
+}
+
+/* --------------------------------------------------------------------------
+ EMAIL WRAPPER (Alternative styling)
+ -------------------------------------------------------------------------- */
+
+.email-content {
+ font-family: 'Geist', ui-sans-serif, system-ui, -apple-system, sans-serif;
+ font-size: 14px;
+ line-height: 1.5;
+ color: var(--theme-email-wrapper-text);
+ word-wrap: break-word;
+ max-width: 100%;
+}
+
+.email-content img {
+ max-width: 100% !important;
+ height: auto !important;
+ display: block;
+ margin: 0.5rem 0;
+}
+
+.email-content table {
+ max-width: 100%;
+ border-collapse: collapse;
+}
+
+.email-content table td,
+.email-content table th {
+ word-wrap: break-word;
+ overflow-wrap: break-word;
+}
+
+.email-content a {
+ color: var(--theme-link-color) !important;
+ text-decoration: underline;
+ padding: 2px 4px;
+ min-height: 32px;
+ display: inline-block;
+}
+
+.email-content a:hover {
+ color: var(--theme-link-color-hover) !important;
+}
+
+/* --------------------------------------------------------------------------
+ EMAIL CARDS & INTERFACE
+ -------------------------------------------------------------------------- */
+
+.message-card {
+ transition-property: border-color, box-shadow;
+ transition-duration: 140ms;
+ transition-timing-function: ease;
+}
+
+.message-card:hover,
+.message-card:focus-within {
+ border-color: color-mix(in srgb, var(--color-primary) 45%, transparent);
+ box-shadow: var(--theme-surface-interactive-shadow);
+}
+
+.message-card .message-sender,
+.message-card .message-subject,
+.message-card .message-preview,
+.message-card .message-attachment-indicator,
+.message-card .message-unread-dot,
+.message-card .message-timestamp,
+.message-card .message-checkbox,
+.message-card .message-quick-actions {
+ transition: color 140ms ease, opacity 140ms ease, transform 140ms ease, box-shadow 140ms ease;
+}
+
+.message-card:hover .message-sender,
+.message-card:focus-within .message-sender,
+.message-card:hover .message-subject,
+.message-card:focus-within .message-subject {
+ color: color-mix(in srgb, var(--color-base-content) 95%, transparent);
+}
+
+.message-card:hover .message-preview,
+.message-card:focus-within .message-preview,
+.message-card:hover .message-attachment-indicator,
+.message-card:focus-within .message-attachment-indicator,
+.message-card:hover .message-timestamp,
+.message-card:focus-within .message-timestamp {
+ color: color-mix(in srgb, var(--color-base-content) 72%, transparent);
+}
+
+.message-card:hover .message-checkbox,
+.message-card:focus-within .message-checkbox {
+ transform: scale(1.03);
+}
+
+.message-card:hover .message-unread-dot,
+.message-card:focus-within .message-unread-dot {
+ transform: scale(1.15);
+ box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-secondary) 16%, transparent);
+}
+
+@media (min-width: 640px) {
+ .message-card .message-quick-actions {
+ opacity: 0.78;
+ transform: translateY(2px);
+ }
+
+ .message-card:hover .message-quick-actions,
+ .message-card:focus-within .message-quick-actions {
+ opacity: 1;
+ transform: translateY(0);
+ }
+}
+
+.email-card {
+ transition: all 0.2s ease;
+ border: 1px solid transparent;
+}
+
+.email-card:hover {
+ transform: translateY(-1px);
+ box-shadow: var(--theme-surface-hover-shadow);
+ border-color: color-mix(in srgb, var(--color-primary) 35%, transparent);
+}
+
+.line-clamp-2 {
+ display: -webkit-box;
+ -webkit-line-clamp: 2;
+ -webkit-box-orient: vertical;
+ overflow: hidden;
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .message-card,
+ .message-card .message-sender,
+ .message-card .message-subject,
+ .message-card .message-preview,
+ .message-card .message-attachment-indicator,
+ .message-card .message-unread-dot,
+ .message-card .message-timestamp,
+ .message-card .message-checkbox,
+ .message-card .message-quick-actions {
+ transition: none !important;
+ transform: none !important;
+ }
+}
+
+/* --------------------------------------------------------------------------
+ EMAIL RESPONSIVE
+ -------------------------------------------------------------------------- */
+
+@media (max-width: 768px) {
+ .email-content {
+ font-size: 16px;
+ }
+
+ .email-content table {
+ font-size: 14px;
+ }
+
+ .email-content * {
+ max-width: 100% !important;
+ box-sizing: border-box !important;
+ }
+
+ .email-content table {
+ width: 100% !important;
+ min-width: 0 !important;
+ display: block !important;
+ overflow-x: auto !important;
+ }
+
+ .email-content tbody,
+ .email-content thead,
+ .email-content tr {
+ display: block !important;
+ width: 100% !important;
+ }
+
+ .email-content td,
+ .email-content th {
+ display: block !important;
+ width: 100% !important;
+ white-space: normal !important;
+ word-wrap: break-word !important;
+ overflow-wrap: break-word !important;
+ }
+
+ .email-content img {
+ width: 100% !important;
+ max-width: 100% !important;
+ height: auto !important;
+ }
+
+ .email-content div[width],
+ .email-content div[style*="width"] {
+ width: 100% !important;
+ max-width: 100% !important;
+ }
+
+ .email-content * {
+ min-width: 0 !important;
+ }
+}
diff --git a/apps/elektrine/assets/css/utilities.css b/apps/elektrine/assets/css/utilities.css
new file mode 100644
index 0000000..19fa9cb
--- /dev/null
+++ b/apps/elektrine/assets/css/utilities.css
@@ -0,0 +1,541 @@
+/* ==========================================================================
+ UTILITIES - Helpers, Touch Targets, Typography, Chat, Misc
+ ========================================================================== */
+
+/* --------------------------------------------------------------------------
+ AUTO-SIZING TEXTAREAS
+ -------------------------------------------------------------------------- */
+
+/* Grow a textarea with its content. The element's own `min-height`/`max-height`
+ still bound it, so each composer keeps the size it declares.
+
+ This replaces the height maths the chat hooks used to run on every keystroke
+ (measure scrollHeight against a min/max parsed out of the style attribute,
+ write height back in pixels). The browser does it here, which also means the
+ height is right again after LiveView resets the value — the hooks had to
+ re-apply it by hand on submit, on `clear_message_input`, and on `updated`.
+
+ Where `field-sizing` is unsupported the textarea falls back to its `rows` and
+ `min-height` and scrolls, which is what it did before any of this ran. */
+.textarea-autosize {
+ field-sizing: content;
+}
+
+/* --------------------------------------------------------------------------
+ STICKY SIDEBARS
+ -------------------------------------------------------------------------- */
+
+:root {
+ --app-sticky-sidebar-gap: 1.5rem;
+}
+
+.app-sticky-sidebar {
+ position: sticky !important;
+ top: var(--app-sticky-sidebar-gap, 1.5rem);
+ align-self: flex-start;
+ contain: none;
+ transform: none;
+}
+
+.app-sticky-sidebar .panel-card {
+ contain: none;
+ transform: none;
+}
+
+/* A rail taller than the viewport would otherwise be unreachable — sticky pins
+ its top, so the overflow has nowhere to go.
+
+ No `scrollbar-gutter: stable` here: it reserves the gutter whether or not a
+ scrollbar is showing, which set this rail ~15px further from its column than
+ the same rail on Timeline and Communities. Those pages don't scroll their
+ sidebar at all, so the gutter was a visible difference between feeds that are
+ meant to look alike. A rail that does overflow gets a scrollbar the ordinary
+ way, and the shift that costs is the rarer case. */
+.app-sticky-sidebar-scroll {
+ max-height: calc(100vh - var(--app-sticky-sidebar-gap, 1.5rem));
+ overflow-y: auto;
+ scrollbar-width: thin;
+}
+
+/* --------------------------------------------------------------------------
+ SOCIAL PAGES
+ -------------------------------------------------------------------------- */
+
+.social-page-wrap {
+ max-width: 80rem;
+ margin-inline: auto;
+ padding-inline: 1rem;
+ padding-bottom: 0.75rem;
+}
+
+@media (min-width: 640px) {
+ .social-page-wrap {
+ padding-inline: 1.5rem;
+ }
+}
+
+@media (min-width: 1024px) {
+ .social-page-wrap {
+ padding-inline: 2rem;
+ }
+}
+
+.social-page-rail {
+ width: 100%;
+ max-width: 48rem;
+ margin-inline: auto;
+}
+
+.social-page-toolbar {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 0.5rem;
+ margin-bottom: 1rem;
+}
+
+.remote-user-sticky-follow {
+ isolation: isolate;
+ position: sticky;
+ top: 1.5rem;
+ width: 100%;
+ z-index: 900;
+}
+
+#remote-profile-infinite-scroll-person {
+ position: relative;
+ z-index: 0;
+}
+
+.social-page-wrap,
+.social-page-rail,
+.card,
+.card-body,
+.panel-card,
+.modal-box,
+.modal-action,
+#editor-container.flex {
+ min-width: 0;
+}
+
+.card-body > *,
+.modal-box > *,
+.modal-action > *,
+#editor-container.flex > * {
+ min-width: 0;
+ max-width: 100%;
+}
+
+/* --------------------------------------------------------------------------
+ PROFILE THEME UTILITIES
+ -------------------------------------------------------------------------- */
+
+.profile-theme-scope[data-profile-custom="true"] {
+ --color-primary: var(--profile-accent);
+ --color-primary-content: var(--profile-button-text);
+ --color-base-100: var(--profile-bg);
+ --color-base-200: color-mix(in srgb, var(--profile-bg) 78%, var(--profile-text) 22%);
+ --color-base-300: color-mix(in srgb, var(--profile-bg) 62%, var(--profile-text) 38%);
+ --color-base-content: var(--profile-text);
+ --surface-panel-bg-fallback: color-mix(in srgb, var(--color-base-200) 88%, var(--profile-accent) 12%);
+ --surface-panel-border: color-mix(in srgb, var(--color-base-300) 72%, var(--profile-accent) 28%);
+ --surface-panel-shadow: 0 18px 40px -34px rgba(0, 0, 0, 0.45),
+ 0 0 0 1px color-mix(in srgb, var(--profile-accent) 10%, transparent);
+ --theme-btn-ghost-text: color-mix(in srgb, var(--profile-text) 86%, var(--profile-accent) 14%);
+ --theme-btn-ghost-bg: color-mix(in srgb, var(--color-base-200) 84%, var(--profile-accent) 16%);
+ --theme-btn-outline-border: color-mix(in srgb, var(--color-base-300) 76%, var(--profile-accent) 24%);
+ --theme-btn-outline-bg: color-mix(in srgb, var(--color-base-200) 86%, var(--color-base-100) 14%);
+ --theme-link-color: var(--profile-accent);
+ --theme-link-color-hover: color-mix(in srgb, var(--profile-accent) 82%, var(--profile-text) 18%);
+ --theme-input-border: color-mix(in srgb, var(--color-base-300) 68%, var(--profile-text) 32%);
+ --theme-input-highlight: color-mix(in srgb, var(--profile-accent) 28%, transparent);
+ --theme-input-highlight-strong: color-mix(in srgb, var(--profile-accent) 42%, transparent);
+ --theme-input-highlight-shadow: color-mix(in srgb, var(--profile-accent) 12%, transparent);
+ --theme-table-row-hover: color-mix(in srgb, var(--color-base-200) 82%, var(--profile-accent) 18%);
+}
+
+.profile-overlay-surface {
+ background-color: color-mix(in srgb, var(--profile-bg, var(--color-base-100)) 30%, transparent);
+}
+
+.profile-overlay-surface-strong {
+ background-color: color-mix(in srgb, var(--profile-bg, var(--color-base-100)) 60%, transparent);
+}
+
+.profile-tooltip-surface {
+ background-color: color-mix(in srgb, var(--profile-bg, var(--color-base-100)) 80%, transparent);
+ color: var(--profile-text, var(--color-base-content));
+}
+
+.profile-modal-surface {
+ background-color: color-mix(in srgb, var(--profile-bg, var(--color-base-100)) 88%, var(--color-base-100) 12%) !important;
+ border-color: color-mix(in srgb, var(--profile-accent, var(--color-primary)) 28%, transparent) !important;
+ color: var(--profile-text, var(--color-base-content)) !important;
+}
+
+.profile-modal-row {
+ color: var(--profile-text, var(--color-base-content));
+}
+
+.profile-modal-row:hover {
+ background-color: color-mix(in srgb, var(--profile-accent, var(--color-primary)) 14%, transparent);
+}
+
+.profile-text-muted {
+ color: color-mix(in srgb, var(--profile-text, var(--color-base-content)) 70%, transparent);
+}
+
+.profile-text-subtle {
+ color: color-mix(in srgb, var(--profile-text, var(--color-base-content)) 55%, transparent);
+}
+
+.profile-text-faint {
+ color: color-mix(in srgb, var(--profile-text, var(--color-base-content)) 40%, transparent);
+}
+
+.profile-divider-soft {
+ background-color: color-mix(in srgb, var(--profile-text, var(--color-base-content)) 20%, transparent);
+}
+
+/* --------------------------------------------------------------------------
+ CHAT
+ -------------------------------------------------------------------------- */
+
+#chat-container a,
+#chat-container a:active,
+#chat-container a:focus,
+#chat-container button,
+#chat-container button:active,
+#chat-container button:focus {
+ -webkit-tap-highlight-color: transparent;
+ outline: none !important;
+ box-shadow: none !important;
+}
+
+#chat-container a:focus-visible,
+#chat-container button:focus-visible {
+ outline: 2px solid color-mix(in srgb, var(--color-primary) 50%, transparent) !important;
+ outline-offset: 2px !important;
+ box-shadow:
+ 0 0 0 1px color-mix(in srgb, var(--color-base-100) 80%, transparent),
+ 0 0 0 4px color-mix(in srgb, var(--color-primary) 24%, transparent) !important;
+}
+
+#chat-container a {
+ -webkit-user-select: none;
+ -moz-user-select: none;
+ -ms-user-select: none;
+ user-select: none;
+}
+
+#chat-container .badge-error {
+ background-color: var(--color-error) !important;
+ border-color: var(--color-error) !important;
+ color: var(--theme-text-inverse) !important;
+}
+
+#chat-container .text-error {
+ color: var(--theme-chat-error-text-soft) !important;
+}
+
+#chat-container .bg-error {
+ background-color: var(--color-error) !important;
+ color: var(--theme-text-inverse) !important;
+}
+
+#chat-container .bg-error\/20 {
+ background-color: var(--theme-chat-error-soft) !important;
+}
+
+#chat-container .border-error {
+ border-color: var(--color-error) !important;
+}
+
+#chat-container .btn-error {
+ background:
+ var(--theme-chat-error-button-overlay),
+ linear-gradient(
+ 135deg,
+ color-mix(in srgb, var(--color-base-100) 96%, transparent),
+ color-mix(in srgb, var(--color-base-200) 90%, transparent)
+ ) !important;
+ border-color: var(--theme-chat-error-button-border) !important;
+ color: var(--theme-chat-error-button-text) !important;
+}
+
+#chat-container .btn-error:hover {
+ background:
+ var(--theme-chat-error-button-overlay-hover),
+ linear-gradient(
+ 135deg,
+ color-mix(in srgb, var(--color-base-100) 96%, transparent),
+ color-mix(in srgb, var(--color-base-200) 90%, transparent)
+ ) !important;
+ border-color: var(--theme-chat-error-button-border-hover) !important;
+}
+
+#chat-container .hover\:text-error:hover {
+ color: var(--theme-chat-error-text-strong) !important;
+}
+
+#chat-container .hover\:border-error\/50:hover {
+ border-color: var(--theme-chat-error-border-50) !important;
+}
+
+#chat-container .progress-error::-webkit-progress-value {
+ background-color: var(--color-error) !important;
+}
+
+#chat-container .progress-error::-moz-progress-bar {
+ background-color: var(--color-error) !important;
+}
+
+
+/* --------------------------------------------------------------------------
+ TEXT SELECTION
+ -------------------------------------------------------------------------- */
+
+.post-content,
+.message-content {
+ user-select: text;
+ -webkit-user-select: text;
+ -moz-user-select: text;
+ -ms-user-select: text;
+ overflow-wrap: anywhere;
+ word-break: break-word;
+}
+
+.message-selected,
+.card.message-selected,
+div.message-selected {
+ background-color: var(--theme-message-selected-bg) !important;
+ border-color: var(--theme-message-selected-border) !important;
+ box-shadow: 0 0 0 1px var(--theme-message-selected-shadow) !important;
+}
+
+.message-selected:hover,
+.card.message-selected:hover,
+div.message-selected:hover {
+ background-color: var(--theme-message-selected-bg-hover) !important;
+ border-color: var(--theme-message-selected-border-hover) !important;
+ box-shadow: 0 0 0 1px var(--theme-message-selected-shadow-hover) !important;
+}
+
+/* --------------------------------------------------------------------------
+ MARKDOWN EDITOR
+ -------------------------------------------------------------------------- */
+
+#editor-container.flex {
+ display: flex;
+ gap: 0.5rem;
+}
+
+#editor-container.flex #editor-write {
+ flex: 1 1 0;
+}
+
+#editor-container.flex #editor-preview {
+ flex: 1 1 0;
+}
+
+#editor-container.flex textarea {
+ border-radius: 0.5rem !important;
+ border: 1px solid var(--color-base-300) !important;
+}
+
+#editor-container.flex #editor-preview > div {
+ border-radius: 0.5rem !important;
+ border: 1px solid var(--color-base-300) !important;
+ height: 100%;
+}
+
+#preview-content {
+ line-height: 1.6;
+}
+
+#preview-content h1 { font-size: 1.875rem; font-weight: 700; margin: 1rem 0; }
+#preview-content h2 { font-size: 1.5rem; font-weight: 600; margin: 0.75rem 0; }
+#preview-content h3 { font-size: 1.25rem; font-weight: 600; margin: 0.5rem 0; }
+
+#preview-content blockquote {
+ border-left: 4px solid var(--color-primary);
+ padding-left: 1rem;
+ margin: 0.5rem 0;
+ font-style: italic;
+}
+
+#preview-content pre {
+ background: var(--color-base-200);
+ padding: 0.75rem;
+ border-radius: 0.375rem;
+ overflow-x: auto;
+ margin: 0.5rem 0;
+}
+
+#preview-content code {
+ background: var(--color-base-200);
+ padding: 0.125rem 0.25rem;
+ border-radius: 0.25rem;
+ font-weight: 500;
+}
+
+/* --------------------------------------------------------------------------
+ CUSTOM EMOJI
+ -------------------------------------------------------------------------- */
+
+.custom-emoji {
+ height: 1.5em;
+ width: auto;
+ vertical-align: -0.3em;
+ display: inline-block;
+ object-fit: contain;
+ margin: 0 0.1em;
+}
+
+.custom-emoji.inline-emoji {
+ height: 1.2em;
+ vertical-align: -0.2em;
+}
+
+.message-content .custom-emoji {
+ height: 1.5em;
+}
+
+/* --------------------------------------------------------------------------
+ LAYOUT STABILITY
+ -------------------------------------------------------------------------- */
+
+.post-actions-container {
+ contain: layout;
+}
+
+.timeline-stream-entry {
+ position: relative;
+ z-index: 1;
+ overflow: visible;
+ content-visibility: auto;
+ contain-intrinsic-size: auto 34rem;
+}
+
+.timeline-stream-entry:hover,
+.timeline-stream-entry:focus-within,
+.timeline-stream-entry:has(.timeline-reaction-dropdown[open]) {
+ content-visibility: visible;
+ z-index: 220;
+}
+
+.timeline-feed-region,
+.timeline-load-more-zone,
+[data-infinite-scroll-sentinel] {
+ overflow-anchor: none;
+}
+
+.timeline-post-card,
+.timeline-stream-entry,
+[data-post-id] {
+ overflow-anchor: auto;
+}
+
+/* --------------------------------------------------------------------------
+ E-NAV (product modes toolbar)
+ Compact single-row toolbar for mid-size desktop screens: primary modes
+ scroll horizontally, secondary modes live in the More menu.
+ -------------------------------------------------------------------------- */
+
+.e-nav .e-nav-scroll {
+ scrollbar-width: none;
+ -ms-overflow-style: none;
+ -webkit-overflow-scrolling: touch;
+}
+
+.e-nav .e-nav-scroll::-webkit-scrollbar {
+ display: none;
+}
+
+/* Edge fades hinting at overflowed tabs; classes toggled by e_nav.js. */
+.e-nav .e-nav-scroll.e-nav-fade-start {
+ -webkit-mask-image: linear-gradient(to right, transparent, #000 1.75rem);
+ mask-image: linear-gradient(to right, transparent, #000 1.75rem);
+}
+
+.e-nav .e-nav-scroll.e-nav-fade-end {
+ -webkit-mask-image: linear-gradient(to left, transparent, #000 1.75rem);
+ mask-image: linear-gradient(to left, transparent, #000 1.75rem);
+}
+
+.e-nav .e-nav-scroll.e-nav-fade-start.e-nav-fade-end {
+ -webkit-mask-image: linear-gradient(
+ to right,
+ transparent,
+ #000 1.75rem,
+ #000 calc(100% - 1.75rem),
+ transparent
+ );
+ mask-image: linear-gradient(
+ to right,
+ transparent,
+ #000 1.75rem,
+ #000 calc(100% - 1.75rem),
+ transparent
+ );
+}
+
+.e-nav .e-nav-more {
+ position: relative;
+}
+
+.e-nav .e-nav-more-menu {
+ max-height: min(70vh, 28rem);
+ overflow-y: auto;
+}
+
+.e-nav .e-nav-more-menu a,
+.e-nav .e-nav-more-menu a:hover,
+.e-nav .e-nav-more-menu a:focus,
+.e-nav .e-nav-more-menu a:focus-visible,
+.e-nav .e-nav-more-menu a:active,
+.e-nav .e-nav-more-menu a:visited {
+ text-decoration: none !important;
+}
+
+/* --------------------------------------------------------------------------
+ MOBILE RESPONSIVE
+ -------------------------------------------------------------------------- */
+
+@media (max-width: 640px) {
+ iframe {
+ max-width: 100% !important;
+ }
+
+ #editor-container.flex {
+ flex-direction: column;
+ }
+
+ #profile-container {
+ overflow-x: hidden;
+ }
+
+ h1, h2, h3 {
+ word-wrap: break-word;
+ overflow-wrap: break-word;
+ hyphens: auto;
+ }
+
+ .username-effect-glow,
+ .username-effect-neon {
+ --glow-intensity: calc(var(--glow-intensity, 10px) * 0.7);
+ }
+
+ .card-body {
+ overflow-x: hidden;
+ }
+
+ .truncate.min-w-0 {
+ overflow: hidden;
+ }
+
+ .timeline-post-card > .card-body,
+ .timeline-post-card .timeline-post-card-body {
+ overflow: visible;
+ }
+}
diff --git a/apps/elektrine/assets/js/admin_security.js b/apps/elektrine/assets/js/admin_security.js
new file mode 100644
index 0000000..6091374
--- /dev/null
+++ b/apps/elektrine/assets/js/admin_security.js
@@ -0,0 +1,361 @@
+const ADMIN_PREFIX = '/pripyat'
+const ADMIN_SECURITY_PREFIX = '/pripyat/security'
+const ADMIN_SECURITY_EXEMPT_PATHS = new Set(['/pripyat/stop-impersonation'])
+const MUTATING_METHODS = new Set(['POST', 'PUT', 'PATCH', 'DELETE'])
+
+function bufferToBase64URL(buffer) {
+ const bytes = new Uint8Array(buffer)
+ let binary = ''
+ for (let i = 0; i < bytes.byteLength; i++) {
+ binary += String.fromCharCode(bytes[i])
+ }
+
+ return btoa(binary)
+ .replace(/\+/g, '-')
+ .replace(/\//g, '_')
+ .replace(/=/g, '')
+}
+
+function base64URLToBuffer(base64url) {
+ const padding = '='.repeat((4 - (base64url.length % 4)) % 4)
+ const base64 = base64url.replace(/-/g, '+').replace(/_/g, '/') + padding
+ const binary = atob(base64)
+ const bytes = new Uint8Array(binary.length)
+
+ for (let i = 0; i < binary.length; i++) {
+ bytes[i] = binary.charCodeAt(i)
+ }
+
+ return bytes.buffer
+}
+
+function getCsrfToken() {
+ return document.querySelector("meta[name='csrf-token']")?.getAttribute('content') || ''
+}
+
+function parseActionPath(action) {
+ try {
+ const url = new URL(action || window.location.pathname, window.location.origin)
+ return url.pathname
+ } catch (_error) {
+ return null
+ }
+}
+
+function resolveFormMethod(form) {
+ const override = form.querySelector("input[name='_method']")
+ const method = override?.value || form.getAttribute('method') || form.method || 'GET'
+ return method.toUpperCase()
+}
+
+function isSensitiveAdminPath(path) {
+ return Boolean(path) &&
+ path.startsWith(ADMIN_PREFIX) &&
+ !path.startsWith(ADMIN_SECURITY_PREFIX) &&
+ !ADMIN_SECURITY_EXEMPT_PATHS.has(path)
+}
+
+function shouldInterceptForm(form) {
+ if (!form || form.dataset.adminResignSkip === 'true') return false
+ if (form.hasAttribute('phx-submit') || form.hasAttribute('data-phx-submit')) return false
+
+ const method = resolveFormMethod(form)
+ if (!MUTATING_METHODS.has(method)) return false
+
+ const path = parseActionPath(form.getAttribute('action') || window.location.pathname)
+ return isSensitiveAdminPath(path)
+}
+
+async function parseJsonResponse(response) {
+ let body = {}
+ try {
+ body = await response.json()
+ } catch (_error) {
+ body = {}
+ }
+
+ if (!response.ok) {
+ throw new Error(body.error || 'Admin security request failed.')
+ }
+
+ return body
+}
+
+async function postJson(path, payload) {
+ const response = await fetch(path, {
+ method: 'POST',
+ headers: {
+ 'content-type': 'application/json',
+ 'x-csrf-token': getCsrfToken()
+ },
+ credentials: 'same-origin',
+ body: JSON.stringify(payload)
+ })
+
+ return parseJsonResponse(response)
+}
+
+async function createPasskeyAssertion(challengeData) {
+ if (!window.PublicKeyCredential) {
+ throw new Error('Passkeys are not supported by this browser.')
+ }
+
+ const publicKey = {
+ challenge: base64URLToBuffer(challengeData.challenge_b64),
+ rpId: challengeData.rp_id,
+ timeout: challengeData.timeout,
+ userVerification: challengeData.user_verification || 'preferred',
+ allowCredentials: (challengeData.allow_credentials || []).map((credential) => ({
+ type: credential.type,
+ id: base64URLToBuffer(credential.id),
+ transports: credential.transports
+ }))
+ }
+
+ const credential = await navigator.credentials.get({ publicKey })
+ const response = credential.response
+
+ const assertion = {
+ id: credential.id,
+ rawId: bufferToBase64URL(credential.rawId),
+ type: credential.type,
+ response: {
+ clientDataJSON: bufferToBase64URL(response.clientDataJSON),
+ authenticatorData: bufferToBase64URL(response.authenticatorData),
+ signature: bufferToBase64URL(response.signature)
+ }
+ }
+
+ if (response.userHandle) {
+ assertion.response.userHandle = bufferToBase64URL(response.userHandle)
+ }
+
+ return assertion
+}
+
+function upsertGrantInput(form, grantToken) {
+ let input = form.querySelector("input[name='_admin_action_grant']")
+ if (!input) {
+ input = document.createElement('input')
+ input.type = 'hidden'
+ input.name = '_admin_action_grant'
+ form.appendChild(input)
+ }
+
+ input.value = grantToken
+}
+
+function notifyAdminSecurityError(message) {
+ if (window.showNotification) {
+ window.showNotification(message, 'error')
+ } else {
+ window.alert(message)
+ }
+}
+
+function requestFormSubmit(form, submitter) {
+ if (typeof form.requestSubmit === 'function') {
+ if (submitter) {
+ form.requestSubmit(submitter)
+ } else {
+ form.requestSubmit()
+ }
+
+ return
+ }
+
+ const submitEvent = new Event('submit', { bubbles: true, cancelable: true })
+
+ if (form.dispatchEvent(submitEvent)) {
+ HTMLFormElement.prototype.submit.call(form)
+ }
+}
+
+function bindSensitiveConfirm(submitter, form) {
+ if (!submitter) return
+ if (submitter.dataset.adminResignConfirmBound === 'true') return
+ if (!submitter.hasAttribute('data-confirm')) return
+
+ submitter.dataset.adminResignConfirmBound = 'true'
+
+ submitter.addEventListener(
+ 'click',
+ (event) => {
+ if (submitter.form !== form) return
+ if (!window.confirm(submitter.getAttribute('data-confirm') || 'Are you sure?')) {
+ event.preventDefault()
+ event.stopImmediatePropagation()
+ return
+ }
+
+ event.preventDefault()
+ event.stopImmediatePropagation()
+ requestFormSubmit(form, submitter)
+ },
+ true
+ )
+}
+
+async function signFormAction(form) {
+ const method = resolveFormMethod(form)
+ const path = parseActionPath(form.getAttribute('action') || window.location.pathname)
+
+ if (!path) {
+ throw new Error('Unable to resolve admin action path.')
+ }
+
+ const startData = await postJson('/pripyat/security/action/start', { method, path })
+ const assertion = await createPasskeyAssertion(startData)
+
+ const finishData = await postJson('/pripyat/security/action/finish', {
+ intent_token: startData.intent_token,
+ challenge: startData.challenge_b64,
+ assertion
+ })
+
+ if (!finishData.grant_token) {
+ throw new Error('Missing action grant token.')
+ }
+
+ return finishData.grant_token
+}
+
+function bindSensitiveForm(form) {
+ if (!shouldInterceptForm(form) || form.dataset.adminResignBound === 'true') return
+
+ form.dataset.adminResignBound = 'true'
+
+ form
+ .querySelectorAll("button[type='submit'][data-confirm], input[type='submit'][data-confirm]")
+ .forEach((submitter) => bindSensitiveConfirm(submitter, form))
+
+ form.addEventListener('submit', async (event) => {
+ if (form.dataset.adminResignSkip === 'true') {
+ delete form.dataset.adminResignSkip
+ return
+ }
+
+ if (form.dataset.adminResignInFlight === 'true') return
+
+ event.preventDefault()
+ form.dataset.adminResignInFlight = 'true'
+
+ try {
+ const grantToken = await signFormAction(form)
+ upsertGrantInput(form, grantToken)
+ form.dataset.adminResignSkip = 'true'
+ delete form.dataset.adminResignInFlight
+ requestFormSubmit(form)
+ } catch (error) {
+ delete form.dataset.adminResignInFlight
+ notifyAdminSecurityError(error.message || 'Passkey confirmation failed.')
+ }
+ })
+}
+
+function resolveLinkMethod(link) {
+ return (link.dataset.adminActionMethod || 'GET').toUpperCase()
+}
+
+function appendGrantToken(href, grantToken) {
+ const url = new URL(href, window.location.origin)
+ url.searchParams.set('_admin_action_grant', grantToken)
+
+ if (url.origin === window.location.origin) {
+ return `${url.pathname}${url.search}${url.hash}`
+ }
+
+ return url.toString()
+}
+
+async function signLinkAction(link) {
+ const method = resolveLinkMethod(link)
+ const path = parseActionPath(link.getAttribute('href') || link.href)
+
+ if (!path) {
+ throw new Error('Unable to resolve admin action path.')
+ }
+
+ const startData = await postJson('/pripyat/security/action/start', { method, path })
+ const assertion = await createPasskeyAssertion(startData)
+
+ const finishData = await postJson('/pripyat/security/action/finish', {
+ intent_token: startData.intent_token,
+ challenge: startData.challenge_b64,
+ assertion
+ })
+
+ if (!finishData.grant_token) {
+ throw new Error('Missing action grant token.')
+ }
+
+ return appendGrantToken(link.href, finishData.grant_token)
+}
+
+function bindSensitiveLink(link) {
+ if (!link || link.dataset.adminActionBound === 'true') return
+
+ const path = parseActionPath(link.getAttribute('href') || link.href)
+ if (!isSensitiveAdminPath(path)) return
+
+ link.dataset.adminActionBound = 'true'
+
+ link.addEventListener('click', async (event) => {
+ if (event.defaultPrevented || event.button !== 0) return
+
+ event.preventDefault()
+
+ const openInNewTab = link.target === '_blank' || event.metaKey || event.ctrlKey || event.shiftKey
+ const popup = openInNewTab ? window.open('about:blank', '_blank', 'noopener') : null
+
+ try {
+ const signedHref = await signLinkAction(link)
+
+ if (popup) {
+ popup.location = signedHref
+ } else {
+ window.location.href = signedHref
+ }
+ } catch (error) {
+ if (popup) popup.close()
+ notifyAdminSecurityError(error.message || 'Passkey confirmation failed.')
+ }
+ })
+}
+
+function bindAdminElevation(root = document) {
+ const container = root.querySelector('[data-admin-elevation="true"]')
+ const button = root.querySelector('#admin-elevate-passkey')
+ if (!container || !button || button.dataset.adminElevationBound === 'true') return
+
+ button.dataset.adminElevationBound = 'true'
+
+ button.addEventListener('click', async () => {
+ button.disabled = true
+
+ try {
+ const returnTo = container.dataset.returnTo || '/pripyat'
+ const startData = await postJson('/pripyat/security/elevate/start', { return_to: returnTo })
+ const assertion = await createPasskeyAssertion(startData)
+
+ const finishData = await postJson('/pripyat/security/elevate/finish', {
+ intent_token: startData.intent_token,
+ challenge: startData.challenge_b64,
+ assertion
+ })
+
+ window.location.href = finishData.redirect_to || returnTo
+ } catch (error) {
+ button.disabled = false
+ notifyAdminSecurityError(error.message || 'Admin elevation failed.')
+ }
+ })
+}
+
+export function initAdminSecurity(root = document) {
+ const forms = root.querySelectorAll ? root.querySelectorAll('form') : []
+ const links = root.querySelectorAll ? root.querySelectorAll('[data-admin-action-sign="true"]') : []
+ forms.forEach(bindSensitiveForm)
+ links.forEach(bindSensitiveLink)
+ bindAdminElevation(root)
+}
diff --git a/apps/elektrine/assets/js/app.js b/apps/elektrine/assets/js/app.js
new file mode 100644
index 0000000..de60ae8
--- /dev/null
+++ b/apps/elektrine/assets/js/app.js
@@ -0,0 +1,594 @@
+/**
+ * Main Application Entry Point
+ * Initializes Phoenix LiveView and all UI modules
+ */
+
+// Phoenix and LiveView core
+import "phoenix_html"
+import { Socket } from "phoenix"
+import { LiveSocket } from "phoenix_live_view"
+import topbar from "../vendor/topbar"
+
+// Import hooks
+import { Hooks } from "./hooks"
+import { detectDevice } from "./hooks/presence_hooks"
+import { initTimezoneDetectors } from "./hooks/form_hooks"
+import { initBackupCodesPrinters } from "./hooks/backup_codes_printer"
+import { initPrivateMailboxAuthForms } from "./hooks/mailbox_private_auth_forms"
+
+// Import utilities
+import {
+ initEmailSelection,
+ initDropdownManagement,
+ initClipboardHandlers
+} from "./utils"
+import { submitFormPreservingEvents } from "./utils/form_submission"
+
+// Import UI modules
+import { initModalControls } from "./modal_controls"
+import { initMarkdownToolbar } from "./markdown_toolbar"
+import { initFormHelpers } from "./form_helpers"
+import { initEmailRaw } from "./email_raw"
+import "./hashtag_links" // self-initializes
+import { initCursorGlow, destroyCursorGlow } from "./cursor_glow"
+import { initBlinkenlights, checkBlinkenlights } from "./blinkenlights"
+import { initMarkdownEditor } from "./markdown_editor"
+import { initLiveClock } from "./live_clock"
+import { initIpLookup } from "./ip_lookup"
+import { initTaglineCycler } from "./tagline_cycler"
+import { initProfileStatic } from "./profile_static"
+import { initENav } from "./e_nav"
+import { initAdminSecurity } from "./admin_security"
+import { initThemeToggle, syncThemeControls } from "./theme"
+
+// Import shared modules
+import {
+ showNotification,
+ showLoadingNotification,
+ showUndoNotification,
+ showConfirmNotification,
+ showKeyboardShortcuts
+} from "./notification_system"
+import { insertMarkdownFormat, toggleMarkdownPreview, markdownToHtml } from "./markdown_helpers"
+
+// ============================================================================
+// Global Exports (for inline scripts and non-module code)
+// ============================================================================
+
+window.showNotification = showNotification
+window.showLoadingNotification = showLoadingNotification
+window.showUndoNotification = showUndoNotification
+window.showConfirmNotification = showConfirmNotification
+window.showKeyboardShortcuts = showKeyboardShortcuts
+window.insertMarkdownFormat = insertMarkdownFormat
+window.toggleMarkdownPreview = toggleMarkdownPreview
+window.markdownToHtml = markdownToHtml
+
+// ============================================================================
+// LiveSocket Configuration
+// ============================================================================
+
+const csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content")
+
+const liveSocket = new LiveSocket("/live", Socket, {
+ longPollFallbackMs: 10000,
+ params: { _csrf_token: csrfToken, ...detectDevice() },
+ hooks: Hooks,
+ heartbeatIntervalMs: 15000
+})
+
+// ============================================================================
+// Progress Bar Configuration
+// ============================================================================
+
+function currentThemePrimary() {
+ const primary = getComputedStyle(document.documentElement)
+ .getPropertyValue("--color-primary")
+ .trim()
+
+ return primary || "#8a7cc2"
+}
+
+function currentThemePrimaryShadow() {
+ const primary = currentThemePrimary()
+
+ if (primary.startsWith("#")) {
+ let hex = primary.slice(1)
+
+ if (hex.length === 3) {
+ hex = hex.split("").map((char) => char + char).join("")
+ }
+
+ if (hex.length === 6) {
+ const red = parseInt(hex.slice(0, 2), 16)
+ const green = parseInt(hex.slice(2, 4), 16)
+ const blue = parseInt(hex.slice(4, 6), 16)
+
+ return `rgba(${red}, ${green}, ${blue}, 0.28)`
+ }
+ }
+
+ return primary
+}
+
+function syncTopbarTheme() {
+ topbar.config({
+ barColors: { 0: currentThemePrimary() },
+ shadowColor: currentThemePrimaryShadow()
+ })
+}
+
+window.addEventListener("phx:page-loading-start", () => {
+ syncTopbarTheme()
+ topbar.show(300)
+})
+window.addEventListener("phx:page-loading-stop", () => {
+ topbar.hide()
+ checkBlinkenlights()
+ initProfileStatic()
+ initENav()
+ initAutoDismissFlashes()
+ initTimezoneDetectors()
+ initBackupCodesPrinters()
+ initPrivateMailboxAuthForms()
+ syncCursorGlowForRoute()
+ initAutoSearchClearButtons()
+ initAdminSecurity()
+ syncThemeControls()
+})
+
+window.addEventListener("elektrine:theme-changed", syncTopbarTheme)
+
+// ============================================================================
+// Global Event Handlers
+// ============================================================================
+
+// Scroll to top (used by new posts button)
+window.addEventListener("scroll-to-top", () => {
+ window.scrollTo({ top: 0, behavior: "instant" })
+})
+
+function initAutoDismissFlashes(rootCandidate = document) {
+ const root =
+ rootCandidate && typeof rootCandidate.querySelectorAll === 'function' ? rootCandidate : document
+
+ root.querySelectorAll('[data-flash-auto-dismiss="true"]').forEach((flashEl) => {
+ bindFlashDismissButton(flashEl)
+ scheduleFlashAutoDismiss(flashEl)
+ })
+}
+
+const flashAutoDismissTimers = new WeakMap()
+
+function clearFlashAutoDismissTimer(flashEl) {
+ const timerId = flashAutoDismissTimers.get(flashEl)
+ if (timerId) {
+ window.clearTimeout(timerId)
+ flashAutoDismissTimers.delete(flashEl)
+ }
+}
+
+function dismissFlashElement(flashEl) {
+ if (!flashEl || flashEl.dataset.flashDismissed === 'true') return
+
+ flashEl.dataset.flashDismissed = 'true'
+ clearFlashAutoDismissTimer(flashEl)
+ flashEl.classList.add('is-dismissing')
+
+ const exitMsRaw = parseInt(flashEl.dataset.flashExitMs || '260', 10)
+ const exitMs = Number.isFinite(exitMsRaw) ? exitMsRaw : 260
+
+ window.setTimeout(() => {
+ if (flashEl.parentNode) {
+ flashEl.remove()
+ }
+ }, Math.max(exitMs, 0) + 40)
+}
+
+function bindFlashDismissButton(flashEl) {
+ if (flashEl.dataset.flashDismissBound === 'true') return
+
+ const dismissBtn = flashEl.querySelector('[data-flash-dismiss="true"]')
+ if (!dismissBtn) return
+
+ dismissBtn.addEventListener('click', () => {
+ dismissFlashElement(flashEl)
+ })
+
+ flashEl.dataset.flashDismissBound = 'true'
+}
+
+function scheduleFlashAutoDismiss(flashEl) {
+ if (flashEl.dataset.flashTimerInitialized === 'true') return
+ flashEl.dataset.flashTimerInitialized = 'true'
+
+ const timeoutMsRaw = parseInt(flashEl.dataset.flashAutoDismissMs || '5000', 10)
+ const timeoutMs = Number.isFinite(timeoutMsRaw) ? timeoutMsRaw : 5000
+
+ const timerId = window.setTimeout(() => {
+ if (!document.body.contains(flashEl)) {
+ clearFlashAutoDismissTimer(flashEl)
+ return
+ }
+
+ const dismissBtn = flashEl.querySelector('[data-flash-dismiss="true"]')
+ if (dismissBtn) {
+ dismissBtn.click()
+ } else {
+ dismissFlashElement(flashEl)
+ }
+ }, timeoutMs)
+
+ flashAutoDismissTimers.set(flashEl, timerId)
+}
+
+window.dismissFlashElement = dismissFlashElement
+window.initAutoDismissFlashes = initAutoDismissFlashes
+
+function shouldEnableCursorGlow(pathname = window.location.pathname) {
+ return !/^\/chat(?:\/|$)/.test(pathname)
+}
+
+function syncCursorGlowForRoute() {
+ if (shouldEnableCursorGlow()) {
+ initCursorGlow()
+ } else {
+ destroyCursorGlow()
+ }
+}
+
+const SEARCH_CLEAR_SELECTOR = '[data-search-clear]'
+const SEARCH_INPUT_SELECTOR = [
+ '[data-search-input]',
+ 'input[type="search"]',
+ 'input[name="q"]',
+ 'input[name="query"]',
+ 'input[name$="[query]"]',
+ 'input[name="search"]'
+].join(', ')
+
+let searchClearButtonsInitialized = false
+let searchClearInputIdCounter = 0
+let searchClearObserverInitialized = false
+
+function isClearableSearchInput(candidate) {
+ if (!(candidate instanceof HTMLInputElement)) return false
+ if (candidate.disabled || candidate.readOnly) return false
+
+ const inputType = (candidate.getAttribute('type') || 'text').toLowerCase()
+ return inputType === 'text' || inputType === 'search'
+}
+
+function ensureSearchInputId(input) {
+ if (!input.id) {
+ searchClearInputIdCounter += 1
+ input.id = `search-clear-input-${searchClearInputIdCounter}`
+ }
+
+ return input.id
+}
+
+function getSearchInputScopes(input) {
+ return [
+ input.closest('label.input'),
+ input.parentElement
+ ].filter(Boolean)
+}
+
+function hasLocalSearchClearControl(input) {
+ const inputId = ensureSearchInputId(input)
+
+ return getSearchInputScopes(input).some((scope) => {
+ const controls = scope.querySelectorAll(`${SEARCH_CLEAR_SELECTOR}, .search-clear-auto`)
+
+ return Array.from(controls).some((control) => {
+ if (control === input) return false
+
+ const target = control.getAttribute('data-search-clear-target')
+ return !target || target === `#${inputId}`
+ })
+ })
+}
+
+function getSearchClearPlacement(input) {
+ const labelContainer = input.closest('label.input')
+ if (labelContainer && labelContainer.contains(input)) {
+ return { mode: 'label', container: labelContainer }
+ }
+
+ const parent = input.parentElement
+ if (!parent) return null
+
+ if (parent.classList.contains('join')) {
+ return { mode: 'join', container: parent }
+ }
+
+ if (parent.classList.contains('relative')) {
+ return { mode: 'overlay', container: parent }
+ }
+
+ if (parent.classList.contains('flex') || parent.tagName === 'FORM') {
+ return { mode: 'inline', container: parent }
+ }
+
+ return { mode: 'overlay', container: parent }
+}
+
+function updateAutoSearchClearButton(input, button) {
+ button.hidden = !isClearableSearchInput(input) || input.value === ''
+}
+
+function getSearchClearTrailingWidth(input) {
+ const parent = input.parentElement
+ if (!parent) return 0
+
+ let trailingWidth = 0
+ let sibling = input.nextElementSibling
+
+ while (sibling) {
+ if (!sibling.hidden) {
+ trailingWidth += sibling.getBoundingClientRect().width
+ }
+
+ sibling = sibling.nextElementSibling
+ }
+
+ return trailingWidth
+}
+
+function searchClearIconSize(input) {
+ if (input.classList.contains('input-xs')) return '12'
+ if (input.classList.contains('input-sm')) return '12'
+ if (input.classList.contains('input-lg')) return '20'
+ return '16'
+}
+
+function createSearchClearIcon(input) {
+ const icon = document.createElementNS('http://www.w3.org/2000/svg', 'svg')
+ const path = document.createElementNS('http://www.w3.org/2000/svg', 'path')
+ const size = searchClearIconSize(input)
+
+ icon.setAttribute('xmlns', 'http://www.w3.org/2000/svg')
+ icon.setAttribute('viewBox', '0 0 24 24')
+ icon.setAttribute('fill', 'none')
+ icon.setAttribute('stroke', 'currentColor')
+ icon.setAttribute('stroke-width', '1.8')
+ icon.setAttribute('width', size)
+ icon.setAttribute('height', size)
+ icon.setAttribute('aria-hidden', 'true')
+
+ path.setAttribute('stroke-linecap', 'round')
+ path.setAttribute('stroke-linejoin', 'round')
+ path.setAttribute('d', 'M6 18 18 6M6 6l12 12')
+
+ icon.appendChild(path)
+
+ return icon
+}
+
+function clearSearchInput(clearTrigger) {
+ const input = resolveSearchInput(clearTrigger)
+
+ if (!isClearableSearchInput(input) || input.value === '') return input
+
+ input.value = ''
+ input.dispatchEvent(new Event('input', { bubbles: true }))
+ return input
+}
+
+function createAutoSearchClearButton(input) {
+ const placement = getSearchClearPlacement(input)
+ if (!placement) return null
+
+ const inputId = ensureSearchInputId(input)
+ const button = document.createElement('button')
+ button.type = 'button'
+ button.setAttribute('aria-label', 'Clear search')
+ button.setAttribute('title', 'Clear search')
+ button.setAttribute('data-search-clear', 'true')
+ button.setAttribute('data-search-clear-target', `#${inputId}`)
+ button.setAttribute('data-search-clear-mode', 'auto')
+ button.appendChild(createSearchClearIcon(input))
+
+ if (placement.mode === 'label') {
+ button.className = 'search-clear-auto text-base-content/60 hover:text-base-content shrink-0'
+ placement.container.appendChild(button)
+ } else if (placement.mode === 'join') {
+ placement.container.classList.add('relative')
+ input.classList.add('search-clear-input')
+ button.className =
+ 'search-clear-auto search-clear-overlay text-base-content/60 hover:text-base-content'
+ button.style.right = `${getSearchClearTrailingWidth(input) + 8}px`
+ placement.container.appendChild(button)
+ } else if (placement.mode === 'inline') {
+ button.className =
+ 'search-clear-auto search-clear-inline text-base-content/60 hover:text-base-content'
+ input.insertAdjacentElement('afterend', button)
+ } else {
+ placement.container.classList.add('relative')
+ input.classList.add('search-clear-input')
+ button.className =
+ 'search-clear-auto search-clear-overlay text-base-content/60 hover:text-base-content'
+ placement.container.appendChild(button)
+ }
+
+ const syncButton = () => updateAutoSearchClearButton(input, button)
+ input.addEventListener('input', syncButton)
+ input.addEventListener('change', syncButton)
+
+ updateAutoSearchClearButton(input, button)
+
+ return button
+}
+
+function initAutoSearchClearButtons(rootCandidate = document) {
+ const root =
+ rootCandidate && typeof rootCandidate.querySelectorAll === 'function' ? rootCandidate : document
+
+ root.querySelectorAll(SEARCH_INPUT_SELECTOR).forEach((input) => {
+ if (!isClearableSearchInput(input)) return
+ if (input.dataset.searchClearEnhanced === 'true') return
+ if (hasLocalSearchClearControl(input)) return
+
+ const button = createAutoSearchClearButton(input)
+ if (!button) return
+
+ input.dataset.searchClearEnhanced = 'true'
+ })
+}
+
+function initSearchClearObserver() {
+ if (searchClearObserverInitialized) return
+ if (!document.body) return
+
+ searchClearObserverInitialized = true
+
+ const observer = new MutationObserver(() => initAutoSearchClearButtons(document))
+ observer.observe(document.body, { childList: true, subtree: true })
+}
+
+function resolveSearchInput(clearTrigger) {
+ const explicitSelector = clearTrigger.getAttribute('data-search-clear-target')
+ if (explicitSelector) return document.querySelector(explicitSelector)
+
+ const activeElement = document.activeElement
+ if (isClearableSearchInput(activeElement)) return activeElement
+
+ const scopes = [
+ clearTrigger.closest('label'),
+ clearTrigger.parentElement,
+ clearTrigger.closest('form')
+ ].filter(Boolean)
+
+ for (const scope of scopes) {
+ const input = scope.querySelector(SEARCH_INPUT_SELECTOR)
+ if (isClearableSearchInput(input)) return input
+ }
+
+ return null
+}
+
+function initSearchClearButtons() {
+ if (searchClearButtonsInitialized) return
+ searchClearButtonsInitialized = true
+
+ document.addEventListener('pointerdown', (event) => {
+ const clearTrigger = event.target.closest(SEARCH_CLEAR_SELECTOR)
+ if (!clearTrigger) return
+
+ clearSearchInput(clearTrigger)
+ })
+
+ document.addEventListener('click', (event) => {
+ const clearTrigger =
+ event.target.closest(`${SEARCH_CLEAR_SELECTOR}[data-search-clear-mode="auto"]`)
+
+ if (!clearTrigger) return
+
+ event.preventDefault()
+
+ const input = clearSearchInput(clearTrigger) || resolveSearchInput(clearTrigger)
+ if (!isClearableSearchInput(input)) return
+
+ const form = input.form || clearTrigger.closest('form')
+
+ if (form && !form.hasAttribute('phx-change') && !input.hasAttribute('phx-change')) {
+ submitFormPreservingEvents(form)
+ }
+
+ input.focus()
+ })
+}
+
+initSearchClearButtons()
+initAutoSearchClearButtons()
+initSearchClearObserver()
+
+// ============================================================================
+// DOM Ready Initialization
+// ============================================================================
+
+document.addEventListener('DOMContentLoaded', () => {
+ // Initialize utility handlers
+ initEmailSelection()
+ initDropdownManagement()
+ initClipboardHandlers()
+ initAutoDismissFlashes()
+ initTimezoneDetectors()
+ initBackupCodesPrinters()
+ initPrivateMailboxAuthForms()
+ syncCursorGlowForRoute()
+ initAutoSearchClearButtons()
+ initSearchClearObserver()
+
+ // Initialize UI modules
+ initBlinkenlights()
+ initMarkdownEditor()
+ initLiveClock()
+ initTaglineCycler()
+ initModalControls()
+ initMarkdownToolbar()
+ initFormHelpers()
+ initEmailRaw()
+ initIpLookup()
+ initProfileStatic()
+ initENav()
+ initAdminSecurity()
+ initThemeToggle()
+
+ // Auto-print functionality
+ const body = document.querySelector('body[data-auto-print="true"]')
+ if (body) {
+ window.print()
+ }
+
+ // Error page actions
+ const goBackBtn = document.querySelector('[data-action="go-back"]')
+ if (goBackBtn) {
+ goBackBtn.addEventListener('click', () => window.history.back())
+ }
+
+ const reloadBtn = document.querySelector('[data-action="reload-page"]')
+ if (reloadBtn) {
+ reloadBtn.addEventListener('click', () => window.location.reload())
+ }
+})
+
+// ============================================================================
+// Connect LiveSocket
+// ============================================================================
+
+function shouldConnectLiveSocket() {
+ // data-phx-main covers full LiveView pages; data-phx-session also matches
+ // live_render islands embedded in controller-rendered pages (e.g. the
+ // navbar notification badge), which need the socket too.
+ return Boolean(document.querySelector('[data-phx-main], [data-phx-session]'))
+}
+
+if (shouldConnectLiveSocket()) {
+ liveSocket.connect()
+}
+
+// Ensure LiveSocket reconnects when returning from static pages (e.g., profile subdomains)
+// The socket may disconnect when on non-LiveView pages, so we reconnect on visibility change
+document.addEventListener("visibilitychange", () => {
+ if (document.visibilityState === "visible" && shouldConnectLiveSocket() && !liveSocket.isConnected()) {
+ liveSocket.connect()
+ }
+})
+
+// Also attempt reconnection on page focus (backup for browsers that don't fire visibilitychange)
+window.addEventListener("focus", () => {
+ if (shouldConnectLiveSocket() && !liveSocket.isConnected()) {
+ liveSocket.connect()
+ }
+ syncCursorGlowForRoute()
+})
+
+window.addEventListener("popstate", syncCursorGlowForRoute)
+
+// Expose for debugging
+// >> liveSocket.enableDebug()
+// >> liveSocket.enableLatencySim(1000)
+// >> liveSocket.disableLatencySim()
+window.liveSocket = liveSocket
diff --git a/apps/elektrine/assets/js/blinkenlights.js b/apps/elektrine/assets/js/blinkenlights.js
new file mode 100644
index 0000000..54a1656
--- /dev/null
+++ b/apps/elektrine/assets/js/blinkenlights.js
@@ -0,0 +1,233 @@
+// Blinkenlights - Functional status lights that respond to system activity
+// Inspired by mainframe front panels - lights indicate actual system activity
+
+let container = null
+let lights = []
+let resizeHandler = null
+let activityLevel = 0 // 0 = idle, 1 = light activity, 2 = moderate, 3 = heavy
+let sitePopularity = 0 // 0-100 based on active users
+
+const GRID_SPACING = 30
+const LIGHT_DENSITY = 0.5
+const COLORS = ['color-red', 'color-green', 'color-blue', 'color-amber', 'color-purple']
+
+// Deterministic hash for consistent "random" pattern
+function hash(x, y) {
+ const n = Math.sin(x * 12.9898 + y * 78.233) * 43758.5453
+ return n - Math.floor(n)
+}
+
+function syncTopChromeHeight() {
+ if (!container) return
+
+ const appNavbar = document.querySelector('.app-navbar')
+ const topChromeHeight = appNavbar ? appNavbar.getBoundingClientRect().height : 0
+
+ container.style.setProperty('--top-chrome-height', `${topChromeHeight}px`)
+}
+
+export function initBlinkenlights() {
+ const path = window.location.pathname
+ const showOnPages = ['/']
+
+ if (!showOnPages.some(p => path === p || path.startsWith(p + '/'))) {
+ destroyBlinkenlights()
+ return
+ }
+
+ if (!window.matchMedia('(pointer: fine)').matches) return
+
+ const nextContainer = document.getElementById('blinkenlights-container')
+ if (!nextContainer) return
+
+ if (container === nextContainer && resizeHandler) return
+
+ container = nextContainer
+
+ document.body.classList.add('has-blinkenlights')
+ container.hidden = false
+ syncTopChromeHeight()
+
+ createLights()
+
+ let resizeTimeout
+ resizeHandler = () => {
+ clearTimeout(resizeTimeout)
+ resizeTimeout = setTimeout(() => {
+ syncTopChromeHeight()
+ createLights()
+ }, 300)
+ }
+ window.addEventListener('resize', resizeHandler)
+
+ // Listen for site popularity updates
+ window.addEventListener('phx:site-activity', handleSiteActivity)
+ window.addEventListener('site:activity', handleSiteActivity)
+}
+
+function handleSiteActivity(event) {
+ const activeUsers = event.detail?.active_users || 0
+ const recentActions = event.detail?.recent_actions || 0
+
+ // Calculate popularity (0-100) based on users and activity
+ sitePopularity = Math.min(100, activeUsers * 5 + recentActions)
+
+ // Update activity on existing lights (don't rebuild grid to avoid visual shift)
+ updateActivityLights()
+
+ // Flash some lights for recent actions
+ if (recentActions > 0) {
+ flashLights(Math.min(recentActions, 10), 'action')
+ }
+}
+
+function setActivityLevel(level) {
+ activityLevel = level
+ updateActivityLights()
+}
+
+function updateActivityLights() {
+ if (!lights.length) return
+
+ // Base ratio from activity level
+ const baseRatio = [0.02, 0.08, 0.15, 0.3][activityLevel] || 0.02
+
+ // Add popularity bonus (0-100 maps to 0-0.3 additional ratio)
+ const popularityBonus = (sitePopularity / 100) * 0.3
+
+ // Combined ratio (cap at 0.6 so not every light is on)
+ const activeRatio = Math.min(0.6, baseRatio + popularityBonus)
+
+ lights.forEach((light, i) => {
+ light.classList.remove('active', 'finding', 'action')
+
+ // Deterministic activation based on light index
+ if (hash(i, i * 7) < activeRatio) {
+ light.classList.add('active')
+ // Deterministic timing based on index
+ const speedFactor = 1 - (sitePopularity / 200)
+ const baseTime = hash(i * 3, i * 5)
+ const duration = activityLevel >= 2 ? 0.5 + baseTime * 1.5 : (2 + baseTime * 4) * speedFactor
+ light.style.setProperty('--blinken-duration', `${duration}s`)
+ light.style.setProperty('--blinken-delay', `${hash(i * 11, i * 13) * 2}s`)
+ }
+ })
+}
+
+function flashLights(count, type = 'pulse') {
+ if (!lights.length) return
+
+ // Flash random lights briefly
+ const toFlash = []
+ for (let i = 0; i < count && i < lights.length; i++) {
+ const idx = Math.floor(Math.random() * lights.length)
+ toFlash.push(lights[idx])
+ }
+
+ toFlash.forEach(light => {
+ light.classList.add(type)
+ setTimeout(() => light.classList.remove(type), 300 + Math.random() * 400)
+ })
+}
+
+function getPageHeight() {
+ // Use viewport height + extra buffer to ensure lights reach the bottom
+ return window.innerHeight + 100
+}
+
+function createLights() {
+ if (!container) return
+
+ const width = window.innerWidth
+ const height = getPageHeight()
+ const cols = Math.ceil(width / GRID_SPACING) + 1
+ const rows = Math.ceil(height / GRID_SPACING) + 1
+ const cacheKey = `blinkenlights_${cols}_${rows}`
+
+ // Check cache for this viewport size
+ const cached = localStorage.getItem(cacheKey)
+ if (cached && container.dataset.cacheKey === cacheKey) {
+ // Already showing correct grid
+ return
+ }
+
+ container.innerHTML = ''
+ lights = []
+ container.dataset.cacheKey = cacheKey
+
+ const fragment = document.createDocumentFragment()
+ const positions = cached ? JSON.parse(cached) : []
+
+ if (positions.length > 0) {
+ // Restore from cache
+ positions.forEach(([x, y]) => {
+ const light = document.createElement('div')
+ light.className = 'blinkenlight'
+ light.style.left = `${x}px`
+ light.style.top = `${y}px`
+ fragment.appendChild(light)
+ lights.push(light)
+ })
+ } else {
+ // Generate and cache
+ const newPositions = []
+ for (let row = 0; row < rows; row++) {
+ for (let col = 0; col < cols; col++) {
+ if (hash(col, row) > LIGHT_DENSITY) continue
+
+ const x = col * GRID_SPACING
+ const y = row * GRID_SPACING
+ newPositions.push([x, y])
+
+ const light = document.createElement('div')
+ light.className = 'blinkenlight'
+ light.style.left = `${x}px`
+ light.style.top = `${y}px`
+ fragment.appendChild(light)
+ lights.push(light)
+ }
+ }
+ localStorage.setItem(cacheKey, JSON.stringify(newPositions))
+ }
+
+ container.appendChild(fragment)
+ updateActivityLights()
+}
+
+export function destroyBlinkenlights() {
+ if (resizeHandler) {
+ window.removeEventListener('resize', resizeHandler)
+ resizeHandler = null
+ }
+ window.removeEventListener('phx:site-activity', handleSiteActivity)
+ window.removeEventListener('site:activity', handleSiteActivity)
+
+ if (container) {
+ container.innerHTML = ''
+ container.hidden = true
+ lights = []
+ container = null
+ }
+ document.body.classList.remove('has-blinkenlights')
+}
+
+export function checkBlinkenlights() {
+ const path = window.location.pathname
+ const showOnPages = ['/']
+
+ if (showOnPages.some(p => path === p || path.startsWith(p + '/'))) {
+ if (!container) initBlinkenlights()
+ } else {
+ destroyBlinkenlights()
+ }
+}
+
+// Expose API for manual control
+window.blinkenlights = {
+ flash: (count = 5, type = 'pulse') => flashLights(count, type),
+ setActivity: (level) => setActivityLevel(level),
+ setPopularity: (level) => { sitePopularity = Math.min(100, Math.max(0, level)); updateActivityLights() },
+ finding: () => flashLights(8, 'finding'),
+ action: () => flashLights(3, 'action'),
+ simulate: (users) => handleSiteActivity({ detail: { active_users: users, recent_actions: Math.floor(users / 2) }})
+}
diff --git a/apps/elektrine/assets/js/cursor_glow.js b/apps/elektrine/assets/js/cursor_glow.js
new file mode 100644
index 0000000..a1f1490
--- /dev/null
+++ b/apps/elektrine/assets/js/cursor_glow.js
@@ -0,0 +1,209 @@
+/**
+ * Cursor Glow Effect
+ *
+ * Subtly brightens grid lines near the cursor position.
+ * Uses smooth interpolation and fade to avoid abrupt snapping.
+ */
+
+let rafId = null
+let overlay = null
+let initialized = false
+
+const OFFSCREEN = -1000
+const BASE_RADIUS = 300
+const RADIUS_BOOST = 110
+
+const state = {
+ targetX: OFFSCREEN,
+ targetY: OFFSCREEN,
+ currentX: OFFSCREEN,
+ currentY: OFFSCREEN,
+ velocity: 0,
+ radius: BASE_RADIUS,
+ opacity: 0,
+ hovering: false
+}
+
+function clamp(value, min, max) {
+ return Math.min(max, Math.max(min, value))
+}
+
+function resetState() {
+ state.targetX = OFFSCREEN
+ state.targetY = OFFSCREEN
+ state.currentX = OFFSCREEN
+ state.currentY = OFFSCREEN
+ state.velocity = 0
+ state.radius = BASE_RADIUS
+ state.opacity = 0
+ state.hovering = false
+}
+
+function applyOverlayState() {
+ if (!overlay) return
+ overlay.style.setProperty('--cursor-x', `${state.currentX.toFixed(2)}px`)
+ overlay.style.setProperty('--cursor-y', `${state.currentY.toFixed(2)}px`)
+ overlay.style.setProperty('--cursor-radius', `${state.radius.toFixed(2)}px`)
+ overlay.style.setProperty('--cursor-opacity', state.opacity.toFixed(3))
+}
+
+function tick() {
+ if (!overlay) {
+ rafId = null
+ return
+ }
+
+ const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches
+ const radiusLerp = reducedMotion ? 1 : 0.18
+ const opacityLerp = reducedMotion ? 1 : state.hovering ? 0.28 : 0.16
+
+ // Keep the glow anchored to the pointer to avoid a trailing/laggy feel.
+ if (state.targetX !== OFFSCREEN && state.targetY !== OFFSCREEN) {
+ state.currentX = state.targetX
+ state.currentY = state.targetY
+ }
+
+ const targetRadius = BASE_RADIUS + clamp(state.velocity * 2, 0, RADIUS_BOOST)
+ state.radius += (targetRadius - state.radius) * radiusLerp
+ state.velocity *= reducedMotion ? 0 : 0.82
+
+ const targetOpacity = state.hovering ? 0.85 : 0
+ state.opacity += (targetOpacity - state.opacity) * opacityLerp
+
+ applyOverlayState()
+
+ const isSettled =
+ Math.abs(state.targetX - state.currentX) < 0.01 &&
+ Math.abs(state.targetY - state.currentY) < 0.01 &&
+ Math.abs(targetOpacity - state.opacity) < 0.01 &&
+ Math.abs(targetRadius - state.radius) < 0.5
+
+ if (isSettled) {
+ if (!state.hovering) {
+ state.currentX = OFFSCREEN
+ state.currentY = OFFSCREEN
+ state.targetX = OFFSCREEN
+ state.targetY = OFFSCREEN
+ state.velocity = 0
+ state.radius = BASE_RADIUS
+ state.opacity = 0
+ applyOverlayState()
+ }
+
+ rafId = null
+ return
+ }
+
+ rafId = requestAnimationFrame(tick)
+}
+
+function ensureAnimationFrame() {
+ if (!rafId) {
+ rafId = requestAnimationFrame(tick)
+ }
+}
+
+function activateAt(x, y, velocity = 0) {
+ if (!overlay) return
+
+ if (state.targetX !== OFFSCREEN && state.targetY !== OFFSCREEN) {
+ state.velocity = Math.max(velocity, Math.hypot(x - state.targetX, y - state.targetY))
+ } else {
+ state.velocity = velocity
+ }
+
+ state.targetX = x
+ state.targetY = y
+ state.hovering = true
+
+ // Sync immediately so the glow never appears one frame behind the cursor.
+ state.currentX = state.targetX
+ state.currentY = state.targetY
+ applyOverlayState()
+
+ ensureAnimationFrame()
+}
+
+function updateCursorPosition(e) {
+ if (!overlay) return
+
+ const velocity =
+ state.targetX !== OFFSCREEN && state.targetY !== OFFSCREEN
+ ? Math.hypot(e.clientX - state.targetX, e.clientY - state.targetY)
+ : 0
+
+ activateAt(e.clientX, e.clientY, velocity)
+}
+
+function handleMouseLeave() {
+ state.hovering = false
+ ensureAnimationFrame()
+}
+
+function handleMouseEnter(e) {
+ activateAt(e.clientX, e.clientY, 0)
+}
+
+function handleMouseDown(e) {
+ // Keep glow active while clicking controls even if no move event follows.
+ activateAt(e.clientX, e.clientY, 8)
+}
+
+function handleWindowBlur() {
+ state.hovering = false
+ ensureAnimationFrame()
+}
+
+function handleWindowFocus() {
+ if (state.targetX !== OFFSCREEN && state.targetY !== OFFSCREEN) {
+ activateAt(state.targetX, state.targetY, 0)
+ }
+}
+
+export function initCursorGlow() {
+ if (!window.matchMedia('(pointer: fine)').matches) return
+
+ const nextOverlay = document.getElementById('cursor-glow-overlay')
+ if (!nextOverlay) {
+ destroyCursorGlow()
+ return
+ }
+
+ overlay = nextOverlay
+
+ if (initialized) {
+ resetState()
+ applyOverlayState()
+ return
+ }
+
+ resetState()
+ applyOverlayState()
+ document.addEventListener('mousemove', updateCursorPosition, { passive: true })
+ document.addEventListener('mouseleave', handleMouseLeave, { passive: true })
+ document.addEventListener('mouseenter', handleMouseEnter, { passive: true })
+ document.addEventListener('mousedown', handleMouseDown, { passive: true })
+ window.addEventListener('blur', handleWindowBlur, { passive: true })
+ window.addEventListener('focus', handleWindowFocus, { passive: true })
+ initialized = true
+}
+
+export function destroyCursorGlow() {
+ if (!initialized) return
+
+ document.removeEventListener('mousemove', updateCursorPosition)
+ document.removeEventListener('mouseleave', handleMouseLeave)
+ document.removeEventListener('mouseenter', handleMouseEnter)
+ document.removeEventListener('mousedown', handleMouseDown)
+ window.removeEventListener('blur', handleWindowBlur)
+ window.removeEventListener('focus', handleWindowFocus)
+
+ overlay = null
+ if (rafId) {
+ cancelAnimationFrame(rafId)
+ rafId = null
+ }
+
+ resetState()
+ initialized = false
+}
diff --git a/apps/elektrine/assets/js/e_nav.js b/apps/elektrine/assets/js/e_nav.js
new file mode 100644
index 0000000..6f53c60
--- /dev/null
+++ b/apps/elektrine/assets/js/e_nav.js
@@ -0,0 +1,57 @@
+/**
+ * E-nav (product modes toolbar) scroll behavior.
+ *
+ * The primary tabs scroll horizontally with the scrollbar hidden, so on
+ * narrow screens two things need JS help:
+ * - the active tab is centered into view on load (it can otherwise sit
+ * offscreen when it lives far down the list), and
+ * - edge fade classes signal that more tabs exist past either edge.
+ */
+
+const SCROLLER_SELECTOR = ".e-nav .e-nav-scroll"
+
+// Small tolerance so sub-pixel scroll positions don't flicker the fades.
+const EDGE_TOLERANCE = 4
+
+let resizeBound = false
+
+function updateOverflowFades(scroller) {
+ const maxScroll = scroller.scrollWidth - scroller.clientWidth
+ scroller.classList.toggle("e-nav-fade-start", scroller.scrollLeft > EDGE_TOLERANCE)
+ scroller.classList.toggle(
+ "e-nav-fade-end",
+ scroller.scrollLeft < maxScroll - EDGE_TOLERANCE
+ )
+}
+
+function centerActiveTab(scroller) {
+ const active = scroller.querySelector('[aria-current="page"]')
+ if (!active) return
+
+ const scrollerRect = scroller.getBoundingClientRect()
+ const activeRect = active.getBoundingClientRect()
+ const activeLeft = activeRect.left - scrollerRect.left + scroller.scrollLeft
+
+ scroller.scrollLeft = activeLeft - (scroller.clientWidth - activeRect.width) / 2
+}
+
+export function initENav() {
+ document.querySelectorAll(SCROLLER_SELECTOR).forEach((scroller) => {
+ if (scroller.dataset.eNavInit !== "true") {
+ scroller.dataset.eNavInit = "true"
+ scroller.addEventListener("scroll", () => updateOverflowFades(scroller), {
+ passive: true
+ })
+ centerActiveTab(scroller)
+ }
+
+ updateOverflowFades(scroller)
+ })
+
+ if (!resizeBound) {
+ resizeBound = true
+ window.addEventListener("resize", () => {
+ document.querySelectorAll(SCROLLER_SELECTOR).forEach(updateOverflowFades)
+ })
+ }
+}
diff --git a/apps/elektrine/assets/js/email_raw.js b/apps/elektrine/assets/js/email_raw.js
new file mode 100644
index 0000000..d82047a
--- /dev/null
+++ b/apps/elektrine/assets/js/email_raw.js
@@ -0,0 +1,43 @@
+// Email raw view functions
+export function initEmailRaw() {
+ // Handle download original email
+ document.addEventListener('click', (e) => {
+ if (e.target.closest('[data-action="download-original"]')) {
+ e.preventDefault();
+ const rawContent = document.getElementById('raw-email');
+ if (rawContent) {
+ const blob = new Blob([rawContent.textContent], { type: 'message/rfc822' });
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement('a');
+ a.href = url;
+ a.download = 'email.eml';
+ document.body.appendChild(a);
+ a.click();
+ document.body.removeChild(a);
+ URL.revokeObjectURL(url);
+ }
+ }
+
+ // Handle copy raw email
+ if (e.target.closest('[data-action="copy-raw-email"]')) {
+ e.preventDefault();
+ const rawContent = document.getElementById('raw-email');
+ if (rawContent) {
+ navigator.clipboard.writeText(rawContent.textContent).then(() => {
+ const btn = e.target.closest('button');
+ const originalText = btn.innerHTML;
+ btn.innerHTML = 'Copied! ';
+ setTimeout(() => {
+ btn.innerHTML = originalText;
+ }, 2000);
+ });
+ }
+ }
+
+ // Handle print
+ if (e.target.closest('[data-action="print"]')) {
+ e.preventDefault();
+ window.print();
+ }
+ });
+}
\ No newline at end of file
diff --git a/apps/elektrine/assets/js/error_page.js b/apps/elektrine/assets/js/error_page.js
new file mode 100644
index 0000000..e04ca23
--- /dev/null
+++ b/apps/elektrine/assets/js/error_page.js
@@ -0,0 +1,110 @@
+/**
+ * Error Page Bundle
+ * Lightweight JS for standalone error pages (404, 403, 413, 500).
+ * These pages don't use LiveView, so they need a separate entry point.
+ */
+
+const GRID_SPACING = 40
+const LIGHT_DENSITY = 0.5
+const BASE_ACTIVE_RATIO = 0.02
+const LIGHT_OFFSET = -1
+
+let resizeHandler = null
+
+// Deterministic hash for consistent "random" pattern.
+function hash(x, y) {
+ const n = Math.sin(x * 12.9898 + y * 78.233) * 43758.5453
+ return n - Math.floor(n)
+}
+
+function getPageHeight() {
+ return window.innerHeight + 100
+}
+
+function createLights(container) {
+ const width = window.innerWidth
+ const height = getPageHeight()
+ const cols = Math.ceil(width / GRID_SPACING) + 1
+ const rows = Math.ceil(height / GRID_SPACING) + 1
+ const cacheKey = `error_blinkenlights_${cols}_${rows}`
+
+ container.innerHTML = ''
+
+ const fragment = document.createDocumentFragment()
+ let positions = []
+
+ try {
+ const cached = localStorage.getItem(cacheKey)
+ if (cached) {
+ positions = JSON.parse(cached)
+ }
+ } catch (_) {
+ positions = []
+ }
+
+ if (!positions.length) {
+ const generatedPositions = []
+ for (let row = 0; row < rows; row++) {
+ for (let col = 0; col < cols; col++) {
+ if (hash(col, row) > LIGHT_DENSITY) continue
+ generatedPositions.push([col * GRID_SPACING + LIGHT_OFFSET, row * GRID_SPACING + LIGHT_OFFSET])
+ }
+ }
+ positions = generatedPositions
+ try {
+ localStorage.setItem(cacheKey, JSON.stringify(generatedPositions))
+ } catch (_) {
+ // Ignore storage errors (private mode/storage quotas).
+ }
+ }
+
+ positions.forEach(([x, y], i) => {
+ const light = document.createElement('div')
+ light.className = 'blinkenlight'
+ light.style.left = `${x}px`
+ light.style.top = `${y}px`
+
+ if (hash(i, i * 7) < BASE_ACTIVE_RATIO) {
+ light.classList.add('active')
+ const baseTime = hash(i * 3, i * 5)
+ const duration = 2 + (baseTime * 4)
+ light.style.setProperty('--blinken-duration', `${duration}s`)
+ light.style.setProperty('--blinken-delay', `${hash(i * 11, i * 13) * 2}s`)
+ }
+
+ fragment.appendChild(light)
+ })
+
+ container.appendChild(fragment)
+}
+
+function initErrorBlinkenlights() {
+ const container = document.getElementById('blinkenlights-container')
+ if (!container) return
+ if (!window.matchMedia('(pointer: fine)').matches) return
+
+ document.body.classList.add('has-blinkenlights')
+ createLights(container)
+
+ if (!resizeHandler) {
+ let resizeTimeout
+ resizeHandler = () => {
+ clearTimeout(resizeTimeout)
+ resizeTimeout = setTimeout(() => createLights(container), 300)
+ }
+ window.addEventListener('resize', resizeHandler)
+ }
+}
+
+// Button action handlers
+function initErrorActions() {
+ const goBack = document.querySelector('[data-action="go-back"]')
+ if (goBack) goBack.addEventListener('click', () => history.back())
+
+ const reload = document.querySelector('[data-action="reload-page"]')
+ if (reload) reload.addEventListener('click', () => location.reload())
+}
+
+// Initialize
+initErrorBlinkenlights()
+initErrorActions()
diff --git a/apps/elektrine/assets/js/form_helpers.js b/apps/elektrine/assets/js/form_helpers.js
new file mode 100644
index 0000000..57b14c3
--- /dev/null
+++ b/apps/elektrine/assets/js/form_helpers.js
@@ -0,0 +1,105 @@
+import { submitFormPreservingEvents } from './utils/form_submission'
+import { copyToClipboard } from './utils/clipboard'
+
+// Form helper functions for various UI interactions
+export function initFormHelpers() {
+ // Handle copy to clipboard
+ document.addEventListener('click', (e) => {
+ const copyBtn = e.target.closest('[data-copy-to-clipboard]');
+ if (copyBtn) {
+ e.preventDefault();
+ const text = copyBtn.dataset.copyToClipboard;
+ copyToClipboard(text).then((copied) => {
+ if (!copied) return;
+
+ const originalText = copyBtn.textContent;
+ copyBtn.textContent = 'Copied!';
+ setTimeout(() => {
+ copyBtn.textContent = originalText;
+ }, 2000);
+ });
+ }
+ });
+
+ // Handle select all checkboxes
+ document.addEventListener('change', (e) => {
+ if (e.target.matches('[data-select-all]')) {
+ const targetClass = e.target.dataset.selectAll;
+ const checkboxes = document.querySelectorAll(`.${targetClass}`);
+ checkboxes.forEach(cb => cb.checked = e.target.checked);
+ }
+ });
+
+ // Handle form submit confirmations
+ document.addEventListener('submit', (e) => {
+ const form = e.target;
+ const confirmMessage = form.dataset.submitConfirm;
+ if (confirmMessage && !confirm(confirmMessage)) {
+ e.preventDefault();
+ }
+ });
+
+ // Handle toggle visibility
+ document.addEventListener('click', (e) => {
+ const toggleBtn = e.target.closest('[data-toggle-visibility]');
+ if (toggleBtn) {
+ e.preventDefault();
+ const targetId = toggleBtn.dataset.toggleVisibility;
+ const target = document.getElementById(targetId);
+ if (target) {
+ target.classList.toggle('hidden');
+ }
+ }
+ });
+
+ // Handle auto-submit toggles
+ document.addEventListener('change', (e) => {
+ if (e.target.matches('[data-auto-submit]')) {
+ const targetId = e.target.dataset.autoSubmit;
+ const targetInput = document.getElementById(targetId);
+ if (targetInput) {
+ targetInput.value = e.target.checked ? 'true' : 'false';
+ }
+ // Submit the form
+ const form = e.target.closest('form');
+ if (form) {
+ submitFormPreservingEvents(form);
+ }
+ }
+ });
+
+ // Handle auto-submit selects (replaces onchange="this.form.submit()")
+ document.addEventListener('change', (e) => {
+ if (e.target.matches('select[data-submit-on-change]')) {
+ const form = e.target.closest('form');
+ if (form) {
+ submitFormPreservingEvents(form);
+ }
+ }
+ });
+
+ // Handle dialog modal open/close (replaces onclick="document.getElementById('x').showModal()")
+ document.addEventListener('click', (e) => {
+ const openBtn = e.target.closest('[data-open-modal]');
+ if (openBtn) {
+ const modal = document.getElementById(openBtn.dataset.openModal);
+ if (modal) modal.showModal();
+ }
+
+ const closeBtn = e.target.closest('[data-close-modal]');
+ if (closeBtn) {
+ const modal = document.getElementById(closeBtn.dataset.closeModal);
+ if (modal) modal.close();
+ }
+ });
+
+ // Handle external links
+ document.addEventListener('click', (e) => {
+ const externalLink = e.target.closest('[data-external-link]');
+ if (externalLink) {
+ e.preventDefault();
+ const url = externalLink.dataset.externalLink;
+ window.open(url, '_blank');
+ }
+ });
+}
diff --git a/apps/elektrine/assets/js/hashtag_links.js b/apps/elektrine/assets/js/hashtag_links.js
new file mode 100644
index 0000000..09301c2
--- /dev/null
+++ b/apps/elektrine/assets/js/hashtag_links.js
@@ -0,0 +1,77 @@
+// Convert hashtags to clickable links without affecting spacing
+export function initHashtagLinks() {
+ if (window.__elektrineHashtagObserver) {
+ window.__elektrineHashtagObserver.disconnect();
+ window.__elektrineHashtagObserver = null;
+ }
+
+ // Function to convert hashtags in text content
+ function makeHashtagsClickable() {
+ // Find all post content elements
+ const postContents = document.querySelectorAll('[data-hashtag-content]');
+
+ postContents.forEach(element => {
+ if (element.dataset.hashtagProcessed) return; // Skip if already processed
+
+ const originalText = element.textContent;
+ const hashtagRegex = /#(\w+)/g;
+ const fragment = document.createDocumentFragment();
+ let lastIndex = 0;
+ let foundHashtag = false;
+
+ originalText.replace(hashtagRegex, (match, hashtag, offset) => {
+ foundHashtag = true;
+ fragment.appendChild(document.createTextNode(originalText.slice(lastIndex, offset)));
+
+ const normalizedHashtag = hashtag.toLowerCase();
+ const link = document.createElement("a");
+ link.href = `/hashtag/${encodeURIComponent(normalizedHashtag)}`;
+ link.className = "text-primary hover:underline font-medium";
+ link.textContent = match;
+ fragment.appendChild(link);
+ lastIndex = offset + match.length;
+
+ return match;
+ });
+
+ if (foundHashtag) {
+ fragment.appendChild(document.createTextNode(originalText.slice(lastIndex)));
+ element.replaceChildren(fragment);
+ element.dataset.hashtagProcessed = 'true';
+ }
+ });
+ }
+
+ // Process hashtags on page load
+ makeHashtagsClickable();
+
+ // Process hashtags when new content is added (LiveView updates)
+ const observer = new MutationObserver((mutations) => {
+ let shouldProcess = false;
+ mutations.forEach((mutation) => {
+ if (mutation.type === 'childList' && mutation.addedNodes.length > 0) {
+ shouldProcess = true;
+ }
+ });
+
+ if (shouldProcess) {
+ setTimeout(makeHashtagsClickable, 10); // Small delay to ensure DOM is ready
+ }
+ });
+
+ // Observe the main timeline for changes
+ const timelineContainer = document.querySelector('[data-timeline-container]');
+ if (timelineContainer) {
+ observer.observe(timelineContainer, {
+ childList: true,
+ subtree: true
+ });
+ window.__elektrineHashtagObserver = observer;
+ }
+}
+
+// Auto-initialize when DOM is ready
+document.addEventListener('DOMContentLoaded', initHashtagLinks);
+
+// Also initialize on LiveView page loads
+document.addEventListener('phx:page-loading-stop', initHashtagLinks);
diff --git a/apps/elektrine/assets/js/hooks/analytics_hooks.js b/apps/elektrine/assets/js/hooks/analytics_hooks.js
new file mode 100644
index 0000000..a1a4c02
--- /dev/null
+++ b/apps/elektrine/assets/js/hooks/analytics_hooks.js
@@ -0,0 +1,231 @@
+import uPlot from "uplot";
+
+const COMPACT_NUMBER = new Intl.NumberFormat(undefined, {
+ notation: "compact",
+ maximumFractionDigits: 1,
+});
+
+const FULL_NUMBER = new Intl.NumberFormat(undefined, {
+ maximumFractionDigits: 1,
+});
+
+const ACCENTS = {
+ primary: { stroke: "#8a7cc2", fill: "rgba(138, 124, 194, 0.16)" },
+ secondary: { stroke: "#c7796b", fill: "rgba(199, 121, 107, 0.15)" },
+ info: { stroke: "#5f87b8", fill: "rgba(95, 135, 184, 0.15)" },
+ success: { stroke: "#34d399", fill: "rgba(52, 211, 153, 0.13)" },
+};
+
+function parsePayload(el) {
+ try {
+ return JSON.parse(el.dataset.chart || "{}");
+ } catch (_error) {
+ return {};
+ }
+}
+
+function cssVar(name, fallback) {
+ const value = getComputedStyle(document.documentElement).getPropertyValue(name).trim();
+ return value || fallback;
+}
+
+function chartWidth(el) {
+ const style = getComputedStyle(el);
+ const padding = parseFloat(style.paddingLeft || 0) + parseFloat(style.paddingRight || 0);
+ const width = (el.getBoundingClientRect().width || el.clientWidth || 640) - padding;
+
+ return Math.max(Math.floor(width), 320);
+}
+
+function formatNumber(value) {
+ if (!Number.isFinite(value)) return "0";
+ return Math.abs(value) >= 1000 ? COMPACT_NUMBER.format(value) : FULL_NUMBER.format(value);
+}
+
+function formatTime(value, granularity) {
+ const date = new Date(value * 1000);
+
+ if (granularity === "hour") {
+ return date.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" });
+ }
+
+ return date.toLocaleDateString(undefined, { month: "short", day: "numeric" });
+}
+
+function periodLabel(granularity) {
+ return granularity === "hour" ? "current hour" : "current day";
+}
+
+function chartData(points) {
+ const rows = Array.isArray(points) ? points : [];
+
+ const normalized = rows
+ .map((point) => ({
+ x: Number(point.x),
+ y: Number(point.y),
+ partial: point.partial === true,
+ }))
+ .filter((point) => Number.isFinite(point.x) && Number.isFinite(point.y))
+ .sort((a, b) => a.x - b.x);
+
+ const hasPartial = normalized.some((point) => point.partial);
+
+ if (!hasPartial) {
+ return {
+ hasPartial,
+ data: [
+ normalized.map((point) => point.x),
+ normalized.map((point) => point.y),
+ ],
+ };
+ }
+
+ const partialValues = new Array(normalized.length).fill(null);
+
+ normalized.forEach((point, index) => {
+ if (!point.partial) return;
+
+ partialValues[index] = point.y;
+
+ for (let previousIndex = index - 1; previousIndex >= 0; previousIndex--) {
+ if (!normalized[previousIndex].partial) {
+ partialValues[previousIndex] = normalized[previousIndex].y;
+ break;
+ }
+ }
+ });
+
+ return {
+ hasPartial,
+ data: [
+ normalized.map((point) => point.x),
+ normalized.map((point) => (point.partial ? null : point.y)),
+ partialValues,
+ ],
+ };
+}
+
+function chartOptions(el, payload, hasPartial) {
+ const accent = ACCENTS[payload.accent] || ACCENTS.primary;
+ const axisColor = cssVar("--color-base-content", "#e4e7eb");
+ const gridColor = "rgba(228, 231, 235, 0.11)";
+ const unit = payload.unit || "value";
+ const granularity = payload.granularity || "day";
+ const series = [
+ {
+ label: "Time",
+ value: (_u, value) => (value == null ? "" : formatTime(value, granularity)),
+ },
+ {
+ label: unit,
+ stroke: accent.stroke,
+ fill: accent.fill,
+ width: 2.5,
+ points: { size: 5, width: 2, stroke: accent.stroke, fill: cssVar("--color-base-100", "#101419") },
+ value: (_u, value) => (value == null ? "" : `${formatNumber(value)} ${unit}`),
+ },
+ ];
+
+ if (hasPartial) {
+ series.push({
+ label: `${unit} (${periodLabel(granularity)})`,
+ stroke: accent.stroke,
+ width: 2.5,
+ dash: [6, 4],
+ points: { size: 6, width: 2.5, stroke: accent.stroke, fill: accent.fill },
+ value: (_u, value) => (value == null ? "" : `${formatNumber(value)} ${unit}`),
+ });
+ }
+
+ return {
+ width: chartWidth(el),
+ height: Number(payload.height) || 260,
+ padding: [10, 10, 2, 2],
+ cursor: {
+ drag: { x: false, y: false },
+ points: { size: 6, width: 2 },
+ },
+ legend: {
+ show: true,
+ live: true,
+ },
+ scales: {
+ x: { time: true },
+ y: {
+ range: (_u, _min, max) => [0, Math.max(1, max * 1.16)],
+ },
+ },
+ axes: [
+ {
+ stroke: axisColor,
+ grid: { stroke: gridColor, width: 1 },
+ ticks: { stroke: gridColor, width: 1 },
+ values: (_u, values) => values.map((value) => formatTime(value, granularity)),
+ },
+ {
+ stroke: axisColor,
+ grid: { stroke: gridColor, width: 1 },
+ ticks: { stroke: gridColor, width: 1 },
+ values: (_u, values) => values.map(formatNumber),
+ },
+ ],
+ series,
+ };
+}
+
+export const UPlotChart = {
+ mounted() {
+ this.rawPayload = null;
+ this.chart = null;
+ this.chartHeight = 260;
+ this.resizeFrame = null;
+ this.renderChart();
+
+ this.resizeObserver = new ResizeObserver(() => {
+ if (this.resizeFrame) cancelAnimationFrame(this.resizeFrame);
+
+ this.resizeFrame = requestAnimationFrame(() => {
+ if (this.chart) {
+ this.chart.setSize({ width: chartWidth(this.el), height: this.chartHeight });
+ }
+ });
+ });
+
+ this.resizeObserver.observe(this.el);
+ },
+
+ updated() {
+ this.renderChart();
+ },
+
+ destroyed() {
+ if (this.resizeFrame) cancelAnimationFrame(this.resizeFrame);
+ if (this.resizeObserver) this.resizeObserver.disconnect();
+ if (this.chart) this.chart.destroy();
+ },
+
+ renderChart() {
+ const rawPayload = this.el.dataset.chart || "{}";
+
+ if (rawPayload === this.rawPayload && this.chart) return;
+
+ this.rawPayload = rawPayload;
+
+ if (this.chart) {
+ this.chart.destroy();
+ this.chart = null;
+ }
+
+ this.el.innerHTML = "";
+
+ const payload = parsePayload(this.el);
+ const preparedData = chartData(payload.points);
+ const data = preparedData.data;
+
+ if (data[0].length === 0) return;
+
+ const options = chartOptions(this.el, payload, preparedData.hasPartial);
+ this.chartHeight = options.height;
+ this.chart = new uPlot(options, data, this.el);
+ },
+};
diff --git a/apps/elektrine/assets/js/hooks/backup_codes_printer.js b/apps/elektrine/assets/js/hooks/backup_codes_printer.js
new file mode 100644
index 0000000..5dc388c
--- /dev/null
+++ b/apps/elektrine/assets/js/hooks/backup_codes_printer.js
@@ -0,0 +1,69 @@
+export function initBackupCodesPrinter(element) {
+ if (!element || element.dataset.backupCodesPrinterInitialized === 'true') return
+
+ const printButton = element.querySelector('[data-action="print"]')
+ if (!printButton) return
+
+ printButton.addEventListener('click', () => {
+ const codes = JSON.parse(element.dataset.codes || '[]')
+ printBackupCodes(codes)
+ })
+
+ element.dataset.backupCodesPrinterInitialized = 'true'
+}
+
+export function initBackupCodesPrinters(rootCandidate = document) {
+ const root =
+ rootCandidate && typeof rootCandidate.querySelectorAll === 'function' ? rootCandidate : document
+
+ root.querySelectorAll('[data-backup-codes-printer]').forEach((element) => {
+ initBackupCodesPrinter(element)
+ })
+}
+
+function printBackupCodes(codes) {
+ const printWindow = window.open('', '_blank')
+
+ const printContent = `
+
+
+ Elektrine Backup Codes
+
+
+
+
+
+
+ Important: Keep these codes safe and secure. Each code can only be used once to access your account if you lose your authenticator device.
+
+
+
+ ${codes.map(code => `
${escapeHtml(String(code))}
`).join('')}
+
+
+
+ `
+
+ printWindow.document.write(printContent)
+ printWindow.document.close()
+ printWindow.print()
+}
+
+function escapeHtml(value) {
+ return value
+ .replaceAll('&', '&')
+ .replaceAll('<', '<')
+ .replaceAll('>', '>')
+ .replaceAll('"', '"')
+ .replaceAll("'", ''')
+}
diff --git a/apps/elektrine/assets/js/hooks/call_hooks.js b/apps/elektrine/assets/js/hooks/call_hooks.js
new file mode 100644
index 0000000..e88a428
--- /dev/null
+++ b/apps/elektrine/assets/js/hooks/call_hooks.js
@@ -0,0 +1,415 @@
+/**
+ * LiveView hooks for WebRTC calling functionality.
+ */
+
+import { WebRTCClient } from '../webrtc_client'
+import { Socket } from 'phoenix'
+import { globalRingtone } from '../ringtone_player'
+
+function createSocket(userToken) {
+ const socket = new Socket("/socket", { params: { token: userToken } })
+ socket.connect()
+ return socket
+}
+
+function mapCallError(error) {
+ if (!error) {
+ return "Unable to start call."
+ }
+
+ const message = error.message || "Unable to start call."
+
+ if (error.name === "NotAllowedError" || message.includes("Permission denied")) {
+ return "Microphone/camera access denied. Please allow access in your browser settings and try again."
+ }
+
+ if (error.name === "NotFoundError") {
+ return "No microphone or camera found. Please connect a device and try again."
+ }
+
+ if (message.includes("secure origin")) {
+ return "Calls require HTTPS. Please use a secure connection."
+ }
+
+ return message
+}
+
+function setMediaElementsFromStream(stream) {
+ if (!stream) {
+ return
+ }
+
+ const hasVideo = stream.getVideoTracks().length > 0
+
+ if (hasVideo) {
+ const videoElement = document.getElementById("remote-video")
+ if (videoElement) {
+ videoElement.srcObject = stream
+ videoElement.play().catch(() => {})
+ }
+ return
+ }
+
+ const audioElement = document.getElementById("remote-audio")
+ if (audioElement) {
+ audioElement.srcObject = stream
+ audioElement.play().catch(() => {})
+ }
+}
+
+/**
+ * Hook for initiating and managing outgoing calls.
+ */
+export const CallInitiator = {
+ mounted() {
+ this.handleEvent("start_call", ({ call_id, call_type, ice_servers, transport, user_token, user_id }) => {
+ this.startCall(call_id, call_type, ice_servers, transport, user_token, user_id)
+ })
+
+ this.handleEvent("resume_call", ({ call_id, call_type, ice_servers, transport, user_token, user_id, initiator }) => {
+ this.resumeCall(call_id, call_type, ice_servers, transport, user_token, user_id, initiator)
+ })
+ },
+
+ async startCall(callId, callType, iceServers, transport, userToken, userId) {
+ this.cleanup()
+
+ try {
+ globalRingtone.playOutgoing()
+
+ const socket = createSocket(userToken)
+ const client = new WebRTCClient(socket, callId, userId, iceServers, transport)
+
+ this.attachClient(client)
+
+ await client.initialize()
+ await client.startCall(callType)
+ this.setLocalStream(client.getLocalStream())
+
+ this.pushEvent("call_started", {})
+ } catch (error) {
+ globalRingtone.stop()
+ this.pushEvent("call_error", { error: mapCallError(error) })
+
+ if (this.client) {
+ this.client.endCall()
+ }
+ }
+ },
+
+ async resumeCall(callId, callType, iceServers, transport, userToken, userId, initiator) {
+ this.cleanup()
+
+ try {
+ const socket = createSocket(userToken)
+ const client = new WebRTCClient(socket, callId, userId, iceServers, transport)
+
+ this.attachClient(client)
+
+ await client.initialize()
+ await client.resumeCall(callType, Boolean(initiator))
+ this.setLocalStream(client.getLocalStream())
+ } catch (error) {
+ this.pushEvent("call_error", { error: mapCallError(error) })
+
+ if (this.client) {
+ this.client.endCall()
+ }
+ }
+ },
+
+ setLocalStream(stream) {
+ const videoElement = document.getElementById("local-video")
+ if (videoElement) {
+ videoElement.srcObject = stream
+ videoElement.play().catch(() => {})
+ }
+ },
+
+ setRemoteStream(stream) {
+ setMediaElementsFromStream(stream)
+ },
+
+ attachClient(client) {
+ this.client = client
+ window.activeCallClient = client
+
+ client.onRemoteStream((stream) => {
+ this.pushEvent("remote_stream_ready", {})
+ this.setRemoteStream(stream)
+ })
+
+ client.onCallEnded((reason) => {
+ if (reason !== "rejected") {
+ globalRingtone.playEnded()
+ } else {
+ globalRingtone.stop()
+ }
+
+ this.pushEvent("call_ended", { reason })
+ this.cleanup()
+ })
+
+ client.onConnectionEstablished(() => {
+ globalRingtone.stop()
+ globalRingtone.playConnected()
+ this.pushEvent("call_connected", {})
+ })
+ },
+
+ cleanup() {
+ globalRingtone.stop()
+
+ if (this.client) {
+ this.client.cleanup()
+ this.client = null
+ }
+
+ window.activeCallClient = null
+ },
+
+ destroyed() {
+ this.cleanup()
+ }
+}
+
+/**
+ * Hook for receiving and answering incoming calls.
+ */
+export const CallReceiver = {
+ mounted() {
+ this.handleEvent("answer_call", ({ call_id, ice_servers, transport, user_token, user_id }) => {
+ this.answerCall(call_id, ice_servers, transport, user_token, user_id)
+ })
+
+ this.handleEvent("reject_call", ({ call_id, user_token }) => {
+ this.rejectCall(call_id, user_token)
+ })
+
+ this.handleEvent("play_incoming_ringtone", () => {
+ globalRingtone.playIncoming()
+ })
+
+ this.handleEvent("stop_ringtone", () => {
+ globalRingtone.stop()
+ })
+ },
+
+ async answerCall(callId, iceServers, transport, userToken, userId) {
+ this.cleanup()
+
+ try {
+ globalRingtone.stop()
+
+ const socket = createSocket(userToken)
+ const client = new WebRTCClient(socket, callId, userId, iceServers, transport)
+
+ this.client = client
+ window.activeCallClient = client
+
+ client.onRemoteStream((stream) => {
+ this.pushEvent("remote_stream_ready", {})
+ this.setRemoteStream(stream)
+ })
+
+ client.onCallEnded((reason) => {
+ if (reason !== "rejected") {
+ globalRingtone.playEnded()
+ } else {
+ globalRingtone.stop()
+ }
+
+ this.pushEvent("call_ended", { reason })
+ this.cleanup()
+ })
+
+ client.onConnectionEstablished(() => {
+ globalRingtone.playConnected()
+ this.pushEvent("call_connected", {})
+ })
+
+ await client.initialize()
+ client.channel.push("ready_to_receive", {})
+ this.pushEvent("call_answered", {})
+ } catch (error) {
+ globalRingtone.stop()
+ this.pushEvent("call_error", { error: mapCallError(error) })
+
+ if (this.client) {
+ this.client.endCall()
+ }
+ }
+ },
+
+ async rejectCall(callId, userToken) {
+ this.cleanup()
+
+ try {
+ globalRingtone.stop()
+
+ const socket = createSocket(userToken)
+ const client = new WebRTCClient(socket, callId, null, [], null)
+
+ await client.initialize()
+ client.rejectCall()
+ this.pushEvent("call_rejected", {})
+ } catch (_error) {
+ globalRingtone.stop()
+ }
+ },
+
+ setRemoteStream(stream) {
+ setMediaElementsFromStream(stream)
+ },
+
+ cleanup() {
+ globalRingtone.stop()
+
+ if (this.client) {
+ this.client.cleanup()
+ this.client = null
+ }
+
+ window.activeCallClient = null
+ },
+
+ destroyed() {
+ this.cleanup()
+ }
+}
+
+/**
+ * Hook for managing active call controls.
+ */
+export const CallControls = {
+ mounted() {
+ this.handleToggleAudio = (e) => {
+ e.preventDefault()
+ const client = window.activeCallClient
+
+ if (client) {
+ const enabled = client.toggleAudio()
+ this.pushEvent("audio_toggled", { enabled })
+ }
+ }
+
+ this.handleToggleVideo = (e) => {
+ e.preventDefault()
+ const client = window.activeCallClient
+
+ if (client) {
+ const enabled = client.toggleVideo()
+ this.pushEvent("video_toggled", { enabled })
+ }
+ }
+
+ this.handleEndCall = (e) => {
+ e.preventDefault()
+ const client = window.activeCallClient
+
+ if (client) {
+ client.endCall()
+ }
+
+ this.pushEvent("call_ended_by_user", {})
+ }
+
+ this.audioButton = this.el.querySelector('[data-action="toggle-audio"]')
+ this.videoButton = this.el.querySelector('[data-action="toggle-video"]')
+ this.endButton = this.el.querySelector('[data-action="end-call"]')
+
+ this.audioButton?.addEventListener("click", this.handleToggleAudio)
+ this.videoButton?.addEventListener("click", this.handleToggleVideo)
+ this.endButton?.addEventListener("click", this.handleEndCall)
+ },
+
+ destroyed() {
+ this.audioButton?.removeEventListener("click", this.handleToggleAudio)
+ this.videoButton?.removeEventListener("click", this.handleToggleVideo)
+ this.endButton?.removeEventListener("click", this.handleEndCall)
+ }
+}
+
+/**
+ * Hook for displaying video streams.
+ */
+export const VideoDisplay = {
+ mounted() {
+ const localVideo = this.el.querySelector("#local-video")
+ const remoteVideo = this.el.querySelector("#remote-video")
+
+ if (localVideo) {
+ localVideo.muted = true
+ localVideo.autoplay = true
+ localVideo.playsInline = true
+ }
+
+ if (remoteVideo) {
+ remoteVideo.autoplay = true
+ remoteVideo.playsInline = true
+ }
+
+ window.videoDisplay = this.el
+ },
+
+ destroyed() {
+ window.videoDisplay = null
+ }
+}
+
+/**
+ * Hook for displaying call duration timer.
+ */
+export const CallTimer = {
+ mounted() {
+ this.startTime = null
+ this.timerInterval = null
+
+ const status = this.el.dataset.status
+
+ if (status === "connected") {
+ this.startTimer()
+ }
+ },
+
+ updated() {
+ const status = this.el.dataset.status
+
+ if (status === "connected" && !this.timerInterval) {
+ this.startTimer()
+ } else if (status !== "connected" && this.timerInterval) {
+ this.stopTimer()
+ }
+ },
+
+ startTimer() {
+ this.startTime = Date.now()
+ this.timerInterval = setInterval(() => {
+ const elapsed = Math.floor((Date.now() - this.startTime) / 1000)
+ this.el.textContent = this.formatDuration(elapsed)
+ }, 1000)
+ },
+
+ stopTimer() {
+ if (this.timerInterval) {
+ clearInterval(this.timerInterval)
+ this.timerInterval = null
+ }
+ },
+
+ formatDuration(seconds) {
+ const hours = Math.floor(seconds / 3600)
+ const minutes = Math.floor((seconds % 3600) / 60)
+ const secs = seconds % 60
+
+ if (hours > 0) {
+ return `${hours}:${String(minutes).padStart(2, '0')}:${String(secs).padStart(2, '0')}`
+ }
+
+ return `${minutes}:${String(secs).padStart(2, '0')}`
+ },
+
+ destroyed() {
+ this.stopTimer()
+ }
+}
diff --git a/apps/elektrine/assets/js/hooks/chat_context_menu_hooks.js b/apps/elektrine/assets/js/hooks/chat_context_menu_hooks.js
new file mode 100644
index 0000000..df3caed
--- /dev/null
+++ b/apps/elektrine/assets/js/hooks/chat_context_menu_hooks.js
@@ -0,0 +1,199 @@
+import { copyToClipboard } from '../utils/clipboard'
+
+function selectedTextWithin(element) {
+ const selection = window.getSelection()
+
+ if (!selection || selection.rangeCount === 0 || selection.isCollapsed) return ''
+
+ const text = selection.toString().trim()
+ if (!text) return ''
+
+ for (let index = 0; index < selection.rangeCount; index++) {
+ const range = selection.getRangeAt(index)
+ const container = range.commonAncestorContainer
+ const node = container.nodeType === Node.TEXT_NODE ? container.parentElement : container
+
+ if (node && element.contains(node)) {
+ return text
+ }
+ }
+
+ return ''
+}
+
+function contextMenuPosition(x, y) {
+ const margin = 8
+ const estimatedWidth = 224
+ const estimatedHeight = 360
+ const maxX = Math.max(margin, window.innerWidth - estimatedWidth - margin)
+ const maxY = Math.max(margin, window.innerHeight - estimatedHeight - margin)
+
+ return {
+ x: Math.min(Math.max(x, margin), maxX),
+ y: Math.min(Math.max(y, margin), maxY)
+ }
+}
+
+export const ContextMenu = {
+ mounted() {
+ const conversationId = this.el.dataset.conversationId
+
+ this.contextMenuHandler = (e) => {
+ e.preventDefault()
+ const position = contextMenuPosition(e.clientX, e.clientY)
+ this.pushEvent("hide_message_context_menu", {})
+ this.pushEvent("show_context_menu", {
+ conversation_id: parseInt(conversationId),
+ x: position.x,
+ y: position.y
+ })
+ }
+ this.el.addEventListener("contextmenu", this.contextMenuHandler)
+
+ this.customEventHandler = (e) => {
+ const { conversation_id, x, y } = e.detail
+ const position = contextMenuPosition(x, y)
+ this.pushEvent("hide_message_context_menu", {})
+ this.pushEvent("show_context_menu", {
+ conversation_id: conversation_id,
+ x: position.x,
+ y: position.y
+ })
+ }
+ this.el.addEventListener("phx:show_context_menu", this.customEventHandler)
+
+ this.clickHandler = (e) => {
+ const contextMenu = document.querySelector('[phx-click-away="hide_context_menu"]')
+ if (contextMenu && !contextMenu.contains(e.target)) {
+ this.pushEvent("hide_context_menu", {})
+ }
+ }
+
+ document.addEventListener("click", this.clickHandler)
+
+ this.scrollHandler = () => {
+ this.pushEvent("hide_context_menu", {})
+ }
+ this.el.addEventListener("scroll", this.scrollHandler)
+ },
+
+ destroyed() {
+ this.el.removeEventListener("contextmenu", this.contextMenuHandler)
+ this.el.removeEventListener("phx:show_context_menu", this.customEventHandler)
+ document.removeEventListener("click", this.clickHandler)
+ this.el.removeEventListener("scroll", this.scrollHandler)
+ }
+}
+
+export const MessageContextMenu = {
+ mounted() {
+ const messageId = this.el.dataset.messageId
+ const senderId = this.el.dataset.senderId
+
+ this.contextMenuHandler = (e) => {
+ e.preventDefault()
+ const selectedText = selectedTextWithin(this.el)
+ const position = contextMenuPosition(e.clientX, e.clientY)
+ this.pushEvent("hide_context_menu", {})
+ this.pushEvent("show_message_context_menu", {
+ message_id: parseInt(messageId),
+ sender_id: parseInt(senderId),
+ selected_text: selectedText,
+ x: position.x,
+ y: position.y
+ })
+ }
+ this.el.addEventListener("contextmenu", this.contextMenuHandler)
+
+ this.customEventHandler = (e) => {
+ const { message_id, sender_id, x, y } = e.detail
+ const position = contextMenuPosition(x, y)
+ this.pushEvent("hide_context_menu", {})
+ this.pushEvent("show_message_context_menu", {
+ message_id: message_id,
+ sender_id: sender_id,
+ x: position.x,
+ y: position.y
+ })
+ }
+ this.el.addEventListener("phx:show_message_context_menu", this.customEventHandler)
+
+ this.clickHandler = (e) => {
+ const contextMenu = document.querySelector('[phx-click-away="hide_message_context_menu"]')
+ if (contextMenu && !contextMenu.contains(e.target)) {
+ this.pushEvent("hide_message_context_menu", {})
+ }
+ }
+
+ document.addEventListener("click", this.clickHandler)
+
+ this.scrollHandler = () => {
+ this.pushEvent("hide_message_context_menu", {})
+ }
+ this.el.addEventListener("scroll", this.scrollHandler)
+
+ this.keyHandler = (e) => {
+ if (e.key === "Escape") {
+ this.pushEvent("hide_message_context_menu", {})
+ }
+ }
+ document.addEventListener("keydown", this.keyHandler)
+ },
+
+ destroyed() {
+ this.el.removeEventListener("contextmenu", this.contextMenuHandler)
+ this.el.removeEventListener("phx:show_message_context_menu", this.customEventHandler)
+ document.removeEventListener("click", this.clickHandler)
+ document.removeEventListener("keydown", this.keyHandler)
+ this.el.removeEventListener("scroll", this.scrollHandler)
+ }
+}
+
+// Opens the message context menu from an explicit button (hover action bar /
+// touch), positioned at the button rather than the cursor. Reuses the same
+// custom DOM event the right-click handler dispatches.
+export const MessageMenuButton = {
+ mounted() {
+ this.clickHandler = (e) => {
+ e.preventDefault()
+ e.stopPropagation()
+ const target = document.getElementById(this.el.dataset.menuTarget)
+ if (!target) return
+ const rect = this.el.getBoundingClientRect()
+ target.dispatchEvent(new CustomEvent("phx:show_message_context_menu", {
+ detail: {
+ message_id: parseInt(this.el.dataset.messageId),
+ sender_id: parseInt(this.el.dataset.senderId),
+ x: rect.right,
+ y: rect.bottom
+ }
+ }))
+ }
+ this.el.addEventListener("click", this.clickHandler)
+ },
+
+ destroyed() {
+ this.el.removeEventListener("click", this.clickHandler)
+ }
+}
+
+export const CopyChatMessage = {
+ mounted() {
+ this.copyHandler = () => {
+ const text = this.el.dataset.copyContent || ''
+ const type = this.el.dataset.copyType || 'message'
+ copyToClipboard(text, type)
+
+ const hideEvent = this.el.dataset.hideEvent
+ if (hideEvent) {
+ this.pushEvent(hideEvent, {})
+ }
+ }
+
+ this.el.addEventListener('click', this.copyHandler)
+ },
+
+ destroyed() {
+ this.el.removeEventListener('click', this.copyHandler)
+ }
+}
diff --git a/apps/elektrine/assets/js/hooks/chat_e2ee_crypto.js b/apps/elektrine/assets/js/hooks/chat_e2ee_crypto.js
new file mode 100644
index 0000000..24027a9
--- /dev/null
+++ b/apps/elektrine/assets/js/hooks/chat_e2ee_crypto.js
@@ -0,0 +1,312 @@
+export const CHAT_E2EE_STORAGE_PREFIX = 'elektrine:chat-e2ee:v1'
+export const CHAT_E2EE_MAX_DEVICES = 64
+export const textEncoder = new TextEncoder()
+export const textDecoder = new TextDecoder()
+
+const CHAT_E2EE_DB_NAME = 'elektrine-chat-e2ee'
+const CHAT_E2EE_DB_VERSION = 1
+const CHAT_E2EE_STORE_NAME = 'secrets'
+let chatE2EEDatabasePromise = null
+
+export function cryptoAvailable() {
+ return Boolean(window.crypto?.subtle && window.crypto?.getRandomValues)
+}
+
+export function parseJson(value, fallback) {
+ if (!value) return fallback
+
+ try {
+ return JSON.parse(value)
+ } catch (_err) {
+ return fallback
+ }
+}
+
+export function stableJson(value) {
+ if (Array.isArray(value)) return JSON.stringify(value.map(item => stableJsonValue(item)))
+ if (!value || typeof value !== 'object') return JSON.stringify(value)
+
+ return JSON.stringify(stableJsonValue(value))
+}
+
+function stableJsonValue(value) {
+ if (Array.isArray(value)) return value.map(item => stableJsonValue(item))
+ if (!value || typeof value !== 'object') return value
+
+ return Object.keys(value)
+ .sort()
+ .reduce((acc, key) => {
+ acc[key] = stableJsonValue(value[key])
+ return acc
+ }, {})
+}
+
+function openChatE2EEDatabase() {
+ if (chatE2EEDatabasePromise) return chatE2EEDatabasePromise
+
+ chatE2EEDatabasePromise = new Promise((resolve, reject) => {
+ const request = indexedDB.open(CHAT_E2EE_DB_NAME, CHAT_E2EE_DB_VERSION)
+
+ request.onupgradeneeded = () => {
+ const db = request.result
+ if (!db.objectStoreNames.contains(CHAT_E2EE_STORE_NAME)) {
+ db.createObjectStore(CHAT_E2EE_STORE_NAME)
+ }
+ }
+
+ request.onsuccess = () => resolve(request.result)
+ request.onerror = () => reject(request.error || new Error('Could not open chat key storage'))
+ })
+
+ return chatE2EEDatabasePromise
+}
+
+export async function secureStorageGet(key) {
+ const db = await openChatE2EEDatabase()
+
+ return new Promise((resolve, reject) => {
+ const transaction = db.transaction(CHAT_E2EE_STORE_NAME, 'readonly')
+ const request = transaction.objectStore(CHAT_E2EE_STORE_NAME).get(key)
+ request.onsuccess = () => resolve(request.result ?? null)
+ request.onerror = () => reject(request.error || new Error('Could not read chat key storage'))
+ })
+}
+
+export async function secureStorageSet(key, value) {
+ const db = await openChatE2EEDatabase()
+
+ return new Promise((resolve, reject) => {
+ const transaction = db.transaction(CHAT_E2EE_STORE_NAME, 'readwrite')
+ const request = transaction.objectStore(CHAT_E2EE_STORE_NAME).put(value, key)
+ request.onsuccess = () => resolve()
+ request.onerror = () => reject(request.error || new Error('Could not write chat key storage'))
+ })
+}
+
+export async function secureStorageDelete(key) {
+ const db = await openChatE2EEDatabase()
+
+ return new Promise((resolve, reject) => {
+ const transaction = db.transaction(CHAT_E2EE_STORE_NAME, 'readwrite')
+ const request = transaction.objectStore(CHAT_E2EE_STORE_NAME).delete(key)
+ request.onsuccess = () => resolve()
+ request.onerror = () => reject(request.error || new Error('Could not delete chat key storage'))
+ })
+}
+
+export function bytesToBase64(bytes) {
+ const array = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes)
+ let binary = ''
+
+ for (let offset = 0; offset < array.length; offset += 0x8000) {
+ binary += String.fromCharCode(...array.subarray(offset, offset + 0x8000))
+ }
+
+ return btoa(binary)
+}
+
+export function base64ToBytes(value) {
+ const binary = atob(value)
+ const bytes = new Uint8Array(binary.length)
+
+ for (let index = 0; index < binary.length; index++) {
+ bytes[index] = binary.charCodeAt(index)
+ }
+
+ return bytes
+}
+
+function bytesToBase64Url(bytes) {
+ return bytesToBase64(bytes).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '')
+}
+
+export function randomBytes(length) {
+ const bytes = new Uint8Array(length)
+ window.crypto.getRandomValues(bytes)
+ return bytes
+}
+
+export function randomId(prefix) {
+ if (window.crypto?.randomUUID) {
+ return `${prefix}${window.crypto.randomUUID()}`
+ }
+
+ return `${prefix}${bytesToBase64Url(randomBytes(18))}`
+}
+
+export function arrayBufferFromBytes(bytes) {
+ return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength)
+}
+
+export async function sha256Base64Url(value) {
+ const digest = await window.crypto.subtle.digest('SHA-256', textEncoder.encode(value))
+ return bytesToBase64Url(digest)
+}
+
+export function extractSearchKeywords(text) {
+ const stopWords = new Set([
+ 'the', 'and', 'for', 'are', 'but', 'not', 'you', 'all', 'can', 'had', 'her', 'was',
+ 'one', 'our', 'out', 'day', 'get', 'has', 'him', 'his', 'how', 'man', 'new', 'now',
+ 'old', 'see', 'two', 'way', 'who', 'boy', 'did', 'its', 'let', 'put', 'say', 'she',
+ 'too', 'use'
+ ])
+
+ const lower = text.toLowerCase()
+ const hashtags = lower.match(/#[a-z0-9_]+/g) || []
+ const words = lower
+ .replace(/[^a-z0-9_\s#]/g, ' ')
+ .split(/\s+/)
+ .filter(word => word.length >= 3 && !stopWords.has(word))
+
+ return Array.from(new Set([...hashtags, ...words]))
+}
+
+export function stableDevices(devices) {
+ return [...devices]
+ .map(device => ({
+ user_id: Number.isInteger(Number(device.user_id)) ? Number(device.user_id) : null,
+ recipient_handle: device.recipient_handle || null,
+ origin_domain: device.origin_domain || null,
+ device_id: String(device.device_id || ''),
+ public_key: device.public_key || {},
+ fingerprint: device.fingerprint || null,
+ signing_public_key: device.signing_public_key || null,
+ device_signature: device.device_signature || null
+ }))
+ .sort((left, right) => {
+ const leftOwner = left.recipient_handle || String(left.user_id)
+ const rightOwner = right.recipient_handle || String(right.user_id)
+ if (leftOwner !== rightOwner) return leftOwner.localeCompare(rightOwner)
+ return left.device_id.localeCompare(right.device_id)
+ })
+}
+
+export async function devicesHash(devices) {
+ return sha256Base64Url(JSON.stringify(stableDevices(devices)))
+}
+
+export async function importRsaPublicKey(publicKeyPayload) {
+ const key = publicKeyPayload?.key
+
+ if (!key || publicKeyPayload.algorithm !== 'RSA-OAEP-SHA256') {
+ throw new Error('Invalid chat public key')
+ }
+
+ return window.crypto.subtle.importKey(
+ 'spki',
+ arrayBufferFromBytes(base64ToBytes(key)),
+ { name: 'RSA-OAEP', hash: 'SHA-256' },
+ false,
+ ['encrypt']
+ )
+}
+
+export async function importRsaPrivateKey(privateKeyBase64) {
+ return window.crypto.subtle.importKey(
+ 'pkcs8',
+ arrayBufferFromBytes(base64ToBytes(privateKeyBase64)),
+ { name: 'RSA-OAEP', hash: 'SHA-256' },
+ false,
+ ['decrypt']
+ )
+}
+
+export async function importStoredRsaPrivateKey(privateKeyBase64) {
+ return window.crypto.subtle.importKey(
+ 'pkcs8',
+ arrayBufferFromBytes(base64ToBytes(privateKeyBase64)),
+ { name: 'RSA-OAEP', hash: 'SHA-256' },
+ false,
+ ['decrypt']
+ )
+}
+
+export async function importEcdsaPublicKey(publicKeyPayload) {
+ const key = publicKeyPayload?.key
+
+ if (!key || publicKeyPayload.algorithm !== 'ECDSA-P256-SHA256') {
+ throw new Error('Invalid chat signing key')
+ }
+
+ return window.crypto.subtle.importKey(
+ 'spki',
+ arrayBufferFromBytes(base64ToBytes(key)),
+ { name: 'ECDSA', namedCurve: 'P-256' },
+ false,
+ ['verify']
+ )
+}
+
+export async function importEcdsaPrivateKey(privateKeyBase64) {
+ return window.crypto.subtle.importKey(
+ 'pkcs8',
+ arrayBufferFromBytes(base64ToBytes(privateKeyBase64)),
+ { name: 'ECDSA', namedCurve: 'P-256' },
+ false,
+ ['sign']
+ )
+}
+
+export async function importStoredEcdsaPrivateKey(privateKeyBase64) {
+ return window.crypto.subtle.importKey(
+ 'pkcs8',
+ arrayBufferFromBytes(base64ToBytes(privateKeyBase64)),
+ { name: 'ECDSA', namedCurve: 'P-256' },
+ false,
+ ['sign']
+ )
+}
+
+export async function importAesKey(rawKeyBytes, usages) {
+ return window.crypto.subtle.importKey(
+ 'raw',
+ arrayBufferFromBytes(rawKeyBytes),
+ { name: 'AES-GCM' },
+ false,
+ usages
+ )
+}
+
+export async function importHmacKey(rawKeyBytes) {
+ return window.crypto.subtle.importKey(
+ 'raw',
+ arrayBufferFromBytes(rawKeyBytes),
+ { name: 'HMAC', hash: 'SHA-256' },
+ false,
+ ['sign']
+ )
+}
+
+export function signingPublicKeyPayload(device) {
+ if (device.signing_public_key?.key) return device.signing_public_key
+ if (device.signing_public_key) {
+ return { version: 1, algorithm: 'ECDSA-P256-SHA256', key: device.signing_public_key }
+ }
+ return null
+}
+
+function devicePublicKeyPayload(device) {
+ if (device.public_key?.key) return device.public_key
+ if (device.public_key) {
+ return { version: 1, algorithm: 'RSA-OAEP-SHA256', key: device.public_key }
+ }
+ return null
+}
+
+export function deviceFingerprintPayload(device) {
+ return {
+ purpose: 'elektrine-chat-e2ee-device',
+ version: 1,
+ device_id: String(device.device_id || ''),
+ key_algorithm: device.key_algorithm || 'RSA-OAEP-SHA256',
+ public_key: devicePublicKeyPayload(device),
+ signing_public_key: signingPublicKeyPayload(device)
+ }
+}
+
+export function deviceSignaturePayload(device, fingerprint) {
+ return {
+ ...deviceFingerprintPayload(device),
+ fingerprint
+ }
+}
diff --git a/apps/elektrine/assets/js/hooks/chat_e2ee_hook.js b/apps/elektrine/assets/js/hooks/chat_e2ee_hook.js
new file mode 100644
index 0000000..de4a11c
--- /dev/null
+++ b/apps/elektrine/assets/js/hooks/chat_e2ee_hook.js
@@ -0,0 +1,1298 @@
+// Chat-specific LiveView hook
+
+import {
+ CHAT_E2EE_MAX_DEVICES,
+ CHAT_E2EE_STORAGE_PREFIX,
+ arrayBufferFromBytes,
+ base64ToBytes,
+ bytesToBase64,
+ cryptoAvailable,
+ deviceFingerprintPayload,
+ deviceSignaturePayload,
+ devicesHash,
+ extractSearchKeywords,
+ importAesKey,
+ importEcdsaPrivateKey,
+ importEcdsaPublicKey,
+ importHmacKey,
+ importRsaPrivateKey,
+ importRsaPublicKey,
+ importStoredEcdsaPrivateKey,
+ importStoredRsaPrivateKey,
+ parseJson,
+ randomBytes,
+ randomId,
+ secureStorageDelete,
+ secureStorageGet,
+ secureStorageSet,
+ sha256Base64Url,
+ signingPublicKeyPayload,
+ stableDevices,
+ stableJson,
+ textDecoder,
+ textEncoder
+} from './chat_e2ee_crypto'
+import {
+ chatE2EEUnavailableLabel,
+ chatE2EEUnavailableTitle,
+ conversationKeyPreparationMessage,
+ encryptedSendFailureMessage,
+ encryptedSubmitBlockedMessage,
+ waitingForMemberKeysMessage
+} from './chat_e2ee_messages'
+
+export const ChatE2EE = {
+ mounted() {
+ this.devicePromise = null
+ this.lastTypingAt = 0
+ this.lastDeviceRegistrationAt = 0
+ this.searchTimer = null
+ this.unavailableConversationKeys = new Set()
+ this.conversationKeyRequests = new Map()
+ this.sentPlaintexts = new Map()
+ this.preparedConversationKey = null
+ this.prepareConversationKeyPromise = null
+ this.preparingDeviceCount = 0
+ this.cachedDevice = null
+ this.currentConversationId = this.conversationId()
+
+ this.submitHandler = event => this.handleSubmit(event)
+ this.inputHandler = event => this.handleInput(event)
+ this.toggleHandler = event => this.handleToggle(event)
+
+ this.el.addEventListener('submit', this.submitHandler, true)
+ this.el.addEventListener('input', this.inputHandler, true)
+ this.el.addEventListener('click', this.toggleHandler, true)
+
+ this.observer = new MutationObserver(() => this.decryptVisibleMessages())
+ this.observer.observe(this.el, { childList: true, subtree: true })
+
+ this.ensureDeviceRegistered()
+ this.updateInputMode()
+ this.updateToggleState()
+ this.updateStatusMessage()
+ this.syncEncryptedModeAfterPatch()
+ this.maybePrepareEnabledConversationKey()
+ this.decryptVisibleMessages()
+ },
+
+ updated() {
+ this.resetConversationKeyCacheIfChanged()
+ this.ensureDeviceRegistered()
+ this.updateInputMode()
+ this.updateToggleState()
+ this.updateStatusMessage()
+ this.syncEncryptedModeAfterPatch()
+ this.maybePrepareEnabledConversationKey()
+ this.decryptVisibleMessages()
+ },
+
+ destroyed() {
+ this.el.removeEventListener('submit', this.submitHandler, true)
+ this.el.removeEventListener('input', this.inputHandler, true)
+ this.el.removeEventListener('click', this.toggleHandler, true)
+
+ if (this.observer) {
+ this.observer.disconnect()
+ }
+
+ if (this.searchTimer) {
+ clearTimeout(this.searchTimer)
+ }
+ },
+
+ userId() {
+ const userId = Number(this.el.dataset.userId)
+ return Number.isInteger(userId) && userId > 0 ? userId : null
+ },
+
+ conversationId() {
+ const conversationId = Number(this.el.dataset.conversationId)
+ return Number.isInteger(conversationId) && conversationId > 0 ? conversationId : null
+ },
+
+ resetConversationKeyCacheIfChanged() {
+ const conversationId = this.conversationId()
+
+ if (this.currentConversationId === conversationId) return
+
+ this.currentConversationId = conversationId
+ this.preparedConversationKey = null
+ this.prepareConversationKeyPromise = null
+ this.preparingDeviceCount = 0
+ },
+
+ devices() {
+ return parseJson(this.el.dataset.chatE2eeDevices, [])
+ },
+
+ serverE2EEReady() {
+ return this.el.dataset.chatE2eeReady === 'true'
+ },
+
+ localDevice() {
+ if (!this.userId()) return null
+ return this.cachedDevice
+ },
+
+ localDeviceAdvertised() {
+ const deviceId = this.localDevice()?.device_id
+ const userId = this.userId()
+
+ if (!deviceId || !userId) return false
+
+ return this.devices().some(device => Number(device.user_id) === userId && device.device_id === deviceId)
+ },
+
+ localDeviceSetupRequired() {
+ return cryptoAvailable() && Boolean(this.userId()) && Boolean(this.conversationId()) && !this.localDeviceAdvertised()
+ },
+
+ e2eeStatus() {
+ return this.el.dataset.chatE2eeStatus || 'unknown'
+ },
+
+ memberIds() {
+ return parseJson(this.el.dataset.chatE2eeMemberIds, [])
+ .map(id => Number(id))
+ .filter(id => Number.isInteger(id) && id > 0)
+ },
+
+ e2eeReady() {
+ return this.e2eeCapable() && this.e2eeEnabled()
+ },
+
+ e2eeCapable() {
+ if (!cryptoAvailable() || !this.serverE2EEReady() || !this.conversationId()) {
+ return false
+ }
+
+ const devices = this.devices()
+ return devices.length > 0 && devices.length <= CHAT_E2EE_MAX_DEVICES && this.localDeviceAdvertised()
+ },
+
+ e2eeEnabled() {
+ const conversationId = this.conversationId()
+ if (!conversationId || !this.userId()) return false
+
+ return localStorage.getItem(this.enabledStorageKey(conversationId)) === 'true'
+ },
+
+ setE2EEEnabled(enabled) {
+ const conversationId = this.conversationId()
+ if (!conversationId || !this.userId()) return
+
+ localStorage.setItem(this.enabledStorageKey(conversationId), enabled ? 'true' : 'false')
+
+ if (!enabled) {
+ this.preparedConversationKey = null
+ this.preparingDeviceCount = 0
+ }
+ },
+
+ updateInputMode() {
+ const form = this.messageForm()
+ const textarea = this.messageInput()
+ const encryptedMode = this.e2eeReady()
+
+ if (!form || !textarea) return
+
+ if (encryptedMode) {
+ form.removeAttribute('phx-change')
+ textarea.removeAttribute('phx-change')
+ } else {
+ form.setAttribute('phx-change', 'validate_upload')
+ textarea.setAttribute('phx-change', 'update_message')
+ }
+
+ this.el.querySelectorAll('input[type="file"]').forEach(input => {
+ input.disabled = encryptedMode
+ })
+
+ const submitButton = form.querySelector('button[type="submit"]')
+ if (encryptedMode && submitButton) {
+ submitButton.disabled = false
+ }
+
+ this.syncEncryptedSubmitButton()
+ },
+
+ syncEncryptedSubmitButton() {
+ const form = this.messageForm()
+ const textarea = this.messageInput()
+ const submitButton = form?.querySelector('button[type="submit"]')
+
+ if (!form || !textarea || !submitButton) return
+
+ const fileInput = form.querySelector('input[type="file"]')
+ const hasUploads = form.dataset.hasUploads === 'true' || (fileInput && fileInput.files.length > 0)
+ const loading = form.dataset.messageLoading === 'true'
+
+ if (loading) {
+ submitButton.disabled = true
+ return
+ }
+
+ if (this.e2eeReady() && this.prepareConversationKeyPromise) {
+ submitButton.disabled = true
+ return
+ }
+
+ if (!this.e2eeCapable()) {
+ submitButton.disabled = !textarea.value.trim() && !hasUploads
+ return
+ }
+
+ if (!this.e2eeReady()) {
+ submitButton.disabled = !textarea.value.trim() && !hasUploads
+ return
+ }
+
+ submitButton.disabled = !textarea.value.trim() || hasUploads
+ },
+
+ syncEncryptedModeAfterPatch() {
+ const sync = () => {
+ this.updateInputMode()
+ this.updateToggleState()
+ this.updateStatusMessage()
+ }
+
+ if (typeof window.requestAnimationFrame === 'function') {
+ window.requestAnimationFrame(() => window.requestAnimationFrame(sync))
+ } else {
+ setTimeout(sync, 0)
+ }
+ },
+
+ updateToggleState() {
+ const toggle = this.el.querySelector('[data-chat-e2ee-toggle="true"]')
+ if (!toggle) return
+
+ const capable = this.e2eeCapable()
+ const enabled = capable && this.e2eeEnabled()
+ const label = toggle.querySelector('[data-chat-e2ee-toggle-label]')
+
+ toggle.disabled = !capable
+ toggle.classList.toggle('btn-secondary', enabled)
+ toggle.classList.toggle('btn-ghost', !enabled)
+ toggle.title = capable
+ ? (enabled
+ ? 'Encrypted chat is ready and enabled for this browser.'
+ : 'Encrypted chat is ready. Click to turn it on for this browser.')
+ : chatE2EEUnavailableTitle(this.e2eeStatus(), this.localDeviceSetupRequired())
+
+ if (label) {
+ label.textContent = capable
+ ? (enabled ? 'Encrypted chat on' : 'Encrypted chat ready')
+ : chatE2EEUnavailableLabel(this.e2eeStatus(), this.localDeviceSetupRequired())
+ }
+ },
+
+ statusElement() {
+ return this.el.querySelector('[data-chat-e2ee-status-message="true"]')
+ },
+
+ setE2EEStatusMessage(message) {
+ const element = this.statusElement()
+ if (!element) return
+
+ if (!message) {
+ element.textContent = ''
+ element.classList.add('hidden')
+ return
+ }
+
+ element.textContent = message
+ element.classList.remove('hidden')
+ },
+
+ updateStatusMessage() {
+ if (!this.conversationId() || !this.userId()) {
+ this.setE2EEStatusMessage(null)
+ return
+ }
+
+ if (!cryptoAvailable()) {
+ this.setE2EEStatusMessage('Encrypted chat is unavailable because this browser does not support the required crypto APIs.')
+ return
+ }
+
+ if (this.e2eeCapable()) {
+ if (this.e2eeEnabled() && this.prepareConversationKeyPromise) {
+ this.setE2EEStatusMessage(this.conversationKeyPreparationMessage())
+ return
+ }
+
+ this.setE2EEStatusMessage(
+ this.e2eeEnabled()
+ ? 'Ready: encrypted messages will be encrypted before they leave this browser.'
+ : 'Ready: click the encrypted chat button to turn encrypted sending on for this browser.'
+ )
+ return
+ }
+
+ if (this.localDeviceSetupRequired()) {
+ this.setE2EEStatusMessage(
+ this.localDevice()?.device_id
+ ? 'Registering this browser for encrypted chat. Encrypted sending will unlock automatically.'
+ : 'Generating this browser\'s encrypted chat keys. Encrypted sending will unlock automatically.'
+ )
+ return
+ }
+
+ switch (this.e2eeStatus()) {
+ case 'registering_device':
+ this.setE2EEStatusMessage('Generating and registering this browser\'s chat encryption keys...')
+ break
+ case 'waiting_for_remote_keys':
+ this.setE2EEStatusMessage('Waiting for the other side to register encryption keys. Ask them to open this chat, then try again.')
+ break
+ case 'waiting_for_member_keys':
+ this.setE2EEStatusMessage(waitingForMemberKeysMessage(this.memberIds(), this.devices()))
+ break
+ case 'too_many_devices':
+ this.setE2EEStatusMessage('Encrypted chat is unavailable because this conversation has too many active devices.')
+ break
+ default:
+ this.setE2EEStatusMessage(null)
+ break
+ }
+ },
+
+ notifyE2EE(message, type = 'info') {
+ if (typeof window.showNotification !== 'function') return
+
+ window.showNotification(message, type, {
+ title: 'Encrypted chat',
+ duration: type === 'error' ? 7000 : 5000
+ })
+ },
+
+ handleToggle(event) {
+ const emojiButton = event.target.closest('[phx-click="insert_emoji"]')
+ if (emojiButton && this.el.contains(emojiButton) && this.e2eeReady()) {
+ this.handleEncryptedEmojiInsert(event, emojiButton)
+ return
+ }
+
+ const toggle = event.target.closest('[data-chat-e2ee-toggle="true"]')
+ if (!toggle || !this.el.contains(toggle)) return
+
+ event.preventDefault()
+ event.stopPropagation()
+
+ if (!this.e2eeCapable()) {
+ this.updateStatusMessage()
+ this.notifyE2EE(chatE2EEUnavailableTitle(this.e2eeStatus(), this.localDeviceSetupRequired()), 'warning')
+ return
+ }
+
+ const enabled = !this.e2eeEnabled()
+
+ this.setE2EEEnabled(enabled)
+ this.updateInputMode()
+ this.updateToggleState()
+ this.updateStatusMessage()
+
+ if (enabled) {
+ this.prepareConversationKeyPackages()
+ }
+ },
+
+ handleEncryptedEmojiInsert(event, button) {
+ const emoji = button.getAttribute('phx-value-emoji')
+ const textarea = this.messageInput()
+
+ if (!emoji || !textarea) return
+
+ event.preventDefault()
+ event.stopImmediatePropagation()
+
+ const start = textarea.selectionStart ?? textarea.value.length
+ const end = textarea.selectionEnd ?? start
+ const nextValue = `${textarea.value.slice(0, start)}${emoji}${textarea.value.slice(end)}`
+ const cursorPosition = start + emoji.length
+
+ textarea.value = nextValue
+ textarea.focus()
+ textarea.setSelectionRange(cursorPosition, cursorPosition)
+ textarea.dispatchEvent(new Event('input', { bubbles: true }))
+ this.syncEncryptedSubmitButton()
+
+ this.pushEvent('toggle_emoji_picker', {})
+ },
+
+ messageForm() {
+ const input = this.messageInput()
+ return input?.closest('form') || null
+ },
+
+ messageInput() {
+ return this.el.querySelector('#message-input')
+ },
+
+ async ensureDeviceRegistered() {
+ if (!cryptoAvailable() || !this.userId() || this.devicePromise) return this.devicePromise
+ if (Date.now() - this.lastDeviceRegistrationAt < 300000) return null
+
+ this.devicePromise = this.loadOrCreateDevice()
+ .then(device => new Promise(resolve => {
+ this.pushEvent('register_chat_encryption_device', {
+ device_id: device.device_id,
+ public_key: {
+ version: 1,
+ algorithm: 'RSA-OAEP-SHA256',
+ key: device.public_key
+ },
+ key_algorithm: 'RSA-OAEP-SHA256',
+ fingerprint: device.fingerprint,
+ signing_public_key: {
+ version: 1,
+ algorithm: 'ECDSA-P256-SHA256',
+ key: device.signing_public_key
+ },
+ device_signature: {
+ version: 1,
+ algorithm: 'ECDSA-P256-SHA256',
+ signature: device.device_signature
+ },
+ label: device.label
+ }, reply => {
+ if (reply?.ok) {
+ this.lastDeviceRegistrationAt = Date.now()
+ this.updateStatusMessage()
+ } else {
+ const message = 'Could not register this browser for encrypted chat. Try refreshing the chat.'
+ this.setE2EEStatusMessage(message)
+ this.notifyE2EE(message, 'error')
+ }
+
+ resolve({ device, reply })
+ })
+ }))
+ .catch(error => {
+ const message = 'Could not set up encrypted chat keys in this browser. Try refreshing the chat.'
+ console.warn('Chat E2EE device registration failed', error)
+ this.setE2EEStatusMessage(message)
+ this.notifyE2EE(message, 'error')
+ return null
+ })
+ .finally(() => {
+ this.devicePromise = null
+ })
+
+ return this.devicePromise
+ },
+
+ async loadOrCreateDevice() {
+ const key = this.deviceStorageKey()
+ const stored = await this.loadSecureJson(key)
+
+ if (stored?.device_id && (stored?.private_key_crypto || stored?.private_key) && stored?.public_key) {
+ const upgraded = await this.upgradeDeviceTrustMaterial(stored)
+ this.cachedDevice = upgraded
+ return upgraded
+ }
+
+ const keyPair = await window.crypto.subtle.generateKey(
+ {
+ name: 'RSA-OAEP',
+ modulusLength: 2048,
+ publicExponent: new Uint8Array([1, 0, 1]),
+ hash: 'SHA-256'
+ },
+ true,
+ ['encrypt', 'decrypt']
+ )
+
+ const [publicKey, privateKey] = await Promise.all([
+ window.crypto.subtle.exportKey('spki', keyPair.publicKey),
+ window.crypto.subtle.exportKey('pkcs8', keyPair.privateKey)
+ ])
+
+ const device = await this.upgradeDeviceTrustMaterial({
+ device_id: randomId('web-'),
+ public_key: bytesToBase64(publicKey),
+ private_key_crypto: await importStoredRsaPrivateKey(bytesToBase64(privateKey)),
+ key_algorithm: 'RSA-OAEP-SHA256',
+ label: this.deviceLabel()
+ })
+
+ await secureStorageSet(key, device)
+ this.cachedDevice = device
+ return device
+ },
+
+ async loadSecureJson(key) {
+ try {
+ const stored = await secureStorageGet(key)
+ if (stored) return stored
+ } catch (_error) {
+ // Fall back to localStorage once so existing browsers can migrate out of it.
+ }
+
+ const storedJson = parseJson(localStorage.getItem(key), null)
+ if (storedJson) {
+ await secureStorageSet(key, storedJson)
+ localStorage.removeItem(key)
+ }
+
+ return storedJson
+ },
+
+ async upgradeDeviceTrustMaterial(device) {
+ device = await this.migrateDevicePrivateKeys(device)
+
+ if (device.signing_private_key_crypto && device.signing_public_key && device.fingerprint && device.device_signature) {
+ return device
+ }
+
+ const signingKeyPair = await window.crypto.subtle.generateKey(
+ { name: 'ECDSA', namedCurve: 'P-256' },
+ true,
+ ['sign', 'verify']
+ )
+
+ const [signingPublicKey, signingPrivateKey] = await Promise.all([
+ window.crypto.subtle.exportKey('spki', signingKeyPair.publicKey),
+ window.crypto.subtle.exportKey('pkcs8', signingKeyPair.privateKey)
+ ])
+
+ const upgraded = {
+ ...device,
+ signing_public_key: bytesToBase64(signingPublicKey),
+ signing_private_key_crypto: await importStoredEcdsaPrivateKey(bytesToBase64(signingPrivateKey))
+ }
+
+ upgraded.fingerprint = await this.deviceFingerprint(upgraded)
+ upgraded.device_signature = await this.signDeviceFingerprint(upgraded, upgraded.fingerprint)
+
+ await secureStorageSet(this.deviceStorageKey(), upgraded)
+ localStorage.removeItem(this.deviceStorageKey())
+ return upgraded
+ },
+
+ async migrateDevicePrivateKeys(device) {
+ let migrated = { ...device }
+ let changed = false
+
+ if (!migrated.private_key_crypto && migrated.private_key) {
+ migrated.private_key_crypto = await importStoredRsaPrivateKey(migrated.private_key)
+ delete migrated.private_key
+ changed = true
+ }
+
+ if (!migrated.signing_private_key_crypto && migrated.signing_private_key) {
+ migrated.signing_private_key_crypto = await importStoredEcdsaPrivateKey(migrated.signing_private_key)
+ delete migrated.signing_private_key
+ changed = true
+ }
+
+ if (changed) {
+ await secureStorageSet(this.deviceStorageKey(), migrated)
+ localStorage.removeItem(this.deviceStorageKey())
+ }
+
+ return migrated
+ },
+
+ async deviceFingerprint(device) {
+ return sha256Base64Url(stableJson(deviceFingerprintPayload(device)))
+ },
+
+ async signDeviceFingerprint(device, fingerprint) {
+ const signingKey = device.signing_private_key_crypto || await importEcdsaPrivateKey(device.signing_private_key)
+ const signature = await window.crypto.subtle.sign(
+ { name: 'ECDSA', hash: 'SHA-256' },
+ signingKey,
+ textEncoder.encode(stableJson(deviceSignaturePayload(device, fingerprint)))
+ )
+
+ return bytesToBase64(signature)
+ },
+
+ deviceStorageKey() {
+ return `${CHAT_E2EE_STORAGE_PREFIX}:user:${this.userId()}:device`
+ },
+
+ deviceLabel() {
+ const userAgent = navigator.userAgent || 'browser'
+ return userAgent.length > 80 ? userAgent.slice(0, 80) : userAgent
+ },
+
+ async ensureDevicesTrusted(devices) {
+ await Promise.all(stableDevices(devices).map(device => this.ensureDeviceTrusted(device)))
+ },
+
+ untrustedDeviceMessage() {
+ return 'Encrypted chat was paused because a participant device key changed. Turn encrypted chat off and verify the participant before continuing.'
+ },
+
+ async ensureDeviceTrusted(device) {
+ const fingerprint = await this.verifiedDeviceFingerprint(device)
+ const key = this.deviceTrustStorageKey(device)
+ const trusted = await this.loadSecureJson(key)
+
+ if (trusted?.fingerprint && trusted.fingerprint !== fingerprint) {
+ throw new Error('untrusted_device')
+ }
+
+ if (!trusted?.fingerprint) {
+ await secureStorageSet(key, {
+ fingerprint,
+ device_id: device.device_id,
+ trusted_at: new Date().toISOString()
+ })
+ localStorage.removeItem(key)
+ }
+ },
+
+ async verifiedDeviceFingerprint(device) {
+ const computed = await this.deviceFingerprint(device)
+ const advertised = device.fingerprint || computed
+
+ if (advertised !== computed) {
+ throw new Error('untrusted_device')
+ }
+
+ const signingKeyPayload = signingPublicKeyPayload(device)
+ const signaturePayload = device.device_signature
+ const signature = signaturePayload?.signature || signaturePayload
+
+ if (signingKeyPayload && signature) {
+ const signingKey = await importEcdsaPublicKey(signingKeyPayload)
+ const valid = await window.crypto.subtle.verify(
+ { name: 'ECDSA', hash: 'SHA-256' },
+ signingKey,
+ base64ToBytes(signature),
+ textEncoder.encode(stableJson(deviceSignaturePayload(device, advertised)))
+ )
+
+ if (!valid) {
+ throw new Error('untrusted_device')
+ }
+ }
+
+ return advertised
+ },
+
+ deviceTrustStorageKey(device) {
+ const owner = device.recipient_handle
+ ? `remote:${device.origin_domain || ''}:${device.recipient_handle}`
+ : `local:${device.user_id || ''}`
+
+ return `${CHAT_E2EE_STORAGE_PREFIX}:trust:${owner}:device:${device.device_id}`
+ },
+
+ handleInput(event) {
+ if (event.target === this.messageInput()) {
+ this.syncEncryptedSubmitButton()
+ }
+
+ if (event.target?.name === 'query' && event.target.closest('#message-search-form')) {
+ this.scheduleEncryptedSearch(event.target.value)
+ }
+ },
+
+ pushTypingEvent(value) {
+ if (!value.trim()) return
+
+ const now = Date.now()
+ if (now - this.lastTypingAt < 2000) return
+
+ this.lastTypingAt = now
+ this.pushEvent('chat_typing', {})
+ },
+
+ scheduleEncryptedSearch(query) {
+ if (this.searchTimer) {
+ clearTimeout(this.searchTimer)
+ }
+
+ this.searchTimer = setTimeout(async () => {
+ if (!this.conversationId() || query.trim().length < 2) return
+
+ const tokens = await this.searchTokensForKnownKeys(query)
+ if (tokens.length === 0) return
+
+ this.pushEvent('search_messages', { query, search_tokens: tokens })
+ }, 150)
+ },
+
+ async handleSubmit(event) {
+ const form = event.target
+ const textarea = this.messageInput()
+
+ if (!form || textarea?.closest('form') !== form || !this.e2eeReady()) {
+ return
+ }
+
+ event.preventDefault()
+ event.stopImmediatePropagation()
+
+ const content = textarea.value.trim()
+ const submitButton = form.querySelector('button[type="submit"]')
+ const fileInput = form.querySelector('input[type="file"]')
+ const hasUploads = form.dataset.hasUploads === 'true' || (fileInput && fileInput.files.length > 0)
+
+ if (submitButton) {
+ submitButton.disabled = true
+ }
+
+ if (!this.e2eeReady()) {
+ const message = this.localDeviceSetupRequired()
+ ? 'Encrypted chat is still setting up this browser. Your message was not sent yet; try again when it says ready.'
+ : chatE2EEUnavailableTitle(this.e2eeStatus(), this.localDeviceSetupRequired())
+
+ this.setE2EEStatusMessage(message)
+ this.notifyE2EE(message, 'warning')
+
+ if (submitButton) {
+ submitButton.disabled = false
+ }
+
+ return
+ }
+
+ if (!content || content.startsWith('/') || hasUploads) {
+ const message = encryptedSubmitBlockedMessage(content, hasUploads)
+ console.warn(message)
+ this.setE2EEStatusMessage(message)
+ this.notifyE2EE(message, 'warning')
+
+ if (submitButton) {
+ submitButton.disabled = false
+ }
+
+ return
+ }
+
+ try {
+ this.setE2EEStatusMessage('Encrypting and sending message...')
+ const encryptedMessage = await this.encryptMessage(content)
+ this.cacheSentPlaintext(encryptedMessage.payload.encrypted_payload, content)
+
+ let sendSettled = false
+ const sendTimeout = setTimeout(() => {
+ if (sendSettled) return
+
+ sendSettled = true
+ this.forgetSentPlaintext(encryptedMessage.payload.encrypted_payload)
+ const message = 'Encrypted chat send timed out. Check your connection and try again.'
+ this.setE2EEStatusMessage(message)
+ this.notifyE2EE(message, 'error')
+
+ if (submitButton) {
+ submitButton.disabled = false
+ }
+ }, 15000)
+
+ this.pushEvent('send_client_encrypted_message', encryptedMessage.payload, reply => {
+ if (sendSettled) return
+
+ sendSettled = true
+ clearTimeout(sendTimeout)
+
+ if (reply?.ok) {
+ if (encryptedMessage.devicesHash) {
+ this.markPackagesSent(encryptedMessage.keyUid, encryptedMessage.devicesHash)
+ }
+
+ textarea.value = ''
+ textarea.dispatchEvent(new Event('input', { bubbles: true }))
+ this.syncEncryptedSubmitButton()
+ this.updateStatusMessage()
+ } else {
+ this.forgetSentPlaintext(encryptedMessage.payload.encrypted_payload)
+ const message = encryptedSendFailureMessage(reply?.error)
+ console.warn('Encrypted chat send failed', reply)
+ this.setE2EEStatusMessage(message)
+ this.notifyE2EE(message, 'error')
+ }
+
+ if (submitButton && !reply?.ok) {
+ submitButton.disabled = false
+ }
+ })
+ } catch (error) {
+ const message = error?.message === 'untrusted_device'
+ ? this.untrustedDeviceMessage()
+ : 'Could not encrypt this message. Make sure everyone has opened this chat so their keys can register, then try again.'
+ console.warn('Could not encrypt chat message', error)
+ this.setE2EEStatusMessage(message)
+ this.notifyE2EE(message, 'error')
+ if (submitButton) {
+ submitButton.disabled = false
+ }
+ }
+ },
+
+ conversationKeyPreparationMessage() {
+ const deviceCount = this.preparingDeviceCount || this.devices().length
+ return conversationKeyPreparationMessage(deviceCount)
+ },
+
+ maybePrepareEnabledConversationKey() {
+ if (!this.e2eeReady() || this.preparedConversationKey || this.prepareConversationKeyPromise) return
+
+ this.prepareConversationKeyPackages()
+ },
+
+ async prepareConversationKeyPackages() {
+ if (!this.e2eeReady()) return null
+
+ if (this.prepareConversationKeyPromise) {
+ return this.prepareConversationKeyPromise
+ }
+
+ const devices = this.devices()
+
+ this.prepareConversationKeyPromise = (async () => {
+ await this.ensureDevicesTrusted(devices)
+ const { keyUid, rawKeyBytes, packagesNeeded, hash } = await this.conversationKeyForDevices(devices)
+
+ if (!packagesNeeded) {
+ this.preparedConversationKey = { keyUid, hash, keyPackages: [] }
+ return this.preparedConversationKey
+ }
+
+ if (this.preparedConversationKey?.keyUid === keyUid && this.preparedConversationKey?.hash === hash) {
+ return this.preparedConversationKey
+ }
+
+ this.preparingDeviceCount = devices.length
+ this.setE2EEStatusMessage(this.conversationKeyPreparationMessage())
+ this.syncEncryptedSubmitButton()
+
+ const keyPackages = await this.wrapConversationKey(rawKeyBytes, devices)
+ this.preparedConversationKey = { keyUid, hash, keyPackages }
+
+ return this.preparedConversationKey
+ })()
+ .catch(error => {
+ const message = error?.message === 'untrusted_device'
+ ? this.untrustedDeviceMessage()
+ : 'Could not prepare encrypted chat keys. Try turning encrypted chat off and on again.'
+ console.warn('Could not prepare encrypted chat keys', error)
+ this.setE2EEStatusMessage(message)
+ this.notifyE2EE(message, 'error')
+ return null
+ })
+ .finally(() => {
+ this.prepareConversationKeyPromise = null
+ this.preparingDeviceCount = 0
+ this.syncEncryptedSubmitButton()
+ this.updateStatusMessage()
+ })
+
+ return this.prepareConversationKeyPromise
+ },
+
+ async encryptMessage(content) {
+ const devices = this.devices()
+ await this.ensureDevicesTrusted(devices)
+ const { keyUid, rawKeyBytes, packagesNeeded, hash } = await this.conversationKeyForDevices(devices)
+ const aesKey = await importAesKey(rawKeyBytes, ['encrypt'])
+ const iv = randomBytes(12)
+ const ciphertext = await window.crypto.subtle.encrypt(
+ { name: 'AES-GCM', iv },
+ aesKey,
+ textEncoder.encode(content)
+ )
+
+ let keyPackages = []
+
+ if (packagesNeeded) {
+ let prepared = this.preparedConversationKey
+
+ if (this.prepareConversationKeyPromise) {
+ prepared = await this.prepareConversationKeyPromise
+ }
+
+ if (prepared?.keyUid === keyUid && prepared?.hash === hash) {
+ keyPackages = prepared.keyPackages
+ } else {
+ this.preparingDeviceCount = devices.length
+ this.setE2EEStatusMessage(this.conversationKeyPreparationMessage())
+
+ try {
+ keyPackages = await this.wrapConversationKey(rawKeyBytes, devices)
+ this.preparedConversationKey = { keyUid, hash, keyPackages }
+ } finally {
+ this.preparingDeviceCount = 0
+ }
+ }
+ }
+
+ const localKeyPackages = keyPackages.filter(keyPackage => Number.isInteger(keyPackage.user_id))
+ const federatedKeyPackages = keyPackages.filter(keyPackage => keyPackage.recipient_handle)
+
+ const payload = {
+ version: 1,
+ content_algorithm: 'AES-256-GCM',
+ key_uid: keyUid,
+ iv: bytesToBase64(iv),
+ ciphertext: bytesToBase64(ciphertext)
+ }
+
+ if (federatedKeyPackages.length > 0) {
+ payload.federated_key_packages = federatedKeyPackages
+ }
+
+ if (localKeyPackages.length > 0) {
+ payload.local_key_packages = localKeyPackages
+ }
+
+ return {
+ keyUid,
+ devicesHash: packagesNeeded ? hash : null,
+ payload: {
+ encrypted_payload: payload,
+ key_packages: localKeyPackages,
+ search_index: await this.searchTokensForKey(rawKeyBytes, content)
+ }
+ }
+ },
+
+ async conversationKeyForDevices(devices) {
+ const conversationId = this.conversationId()
+ const hash = await devicesHash(devices)
+ const activeKey = await this.loadSecureJson(this.activeKeyStorageKey(conversationId))
+
+ if (activeKey?.key_uid && activeKey.devices_hash === hash) {
+ const rawKeyBytes = await this.loadRawConversationKey(conversationId, activeKey.key_uid)
+
+ if (rawKeyBytes) {
+ return {
+ keyUid: activeKey.key_uid,
+ rawKeyBytes,
+ packagesNeeded: activeKey.packages_sent_hash !== hash,
+ hash
+ }
+ }
+ }
+
+ const keyUid = randomId('key-')
+ const rawKeyBytes = randomBytes(32)
+ await this.storeRawConversationKey(conversationId, keyUid, rawKeyBytes)
+ await this.storeActiveConversationKey(conversationId, keyUid, hash, null)
+
+ return { keyUid, rawKeyBytes, packagesNeeded: true, hash }
+ },
+
+ async wrapConversationKey(rawKeyBytes, devices) {
+ return Promise.all(stableDevices(devices).map(async device => {
+ const publicKey = await importRsaPublicKey(device.public_key)
+ const encryptedKey = await window.crypto.subtle.encrypt(
+ { name: 'RSA-OAEP' },
+ publicKey,
+ arrayBufferFromBytes(rawKeyBytes)
+ )
+
+ return {
+ ...(Number.isInteger(device.user_id) ? { user_id: device.user_id } : {}),
+ ...(device.recipient_handle ? { recipient_handle: device.recipient_handle } : {}),
+ ...(device.origin_domain ? { origin_domain: device.origin_domain } : {}),
+ device_id: device.device_id,
+ wrapped_key: {
+ version: 1,
+ key_algorithm: 'RSA-OAEP-SHA256',
+ encrypted_key: bytesToBase64(encryptedKey)
+ }
+ }
+ }))
+ },
+
+ async searchTokensForKnownKeys(query) {
+ const conversationId = this.conversationId()
+ if (!conversationId) return []
+
+ const keyUids = (await this.loadSecureJson(this.keyListStorageKey(conversationId))) || []
+ const tokenSets = await Promise.all(keyUids.map(async keyUid => {
+ const rawKeyBytes = await this.loadRawConversationKey(conversationId, keyUid)
+ return rawKeyBytes ? this.searchTokensForKey(rawKeyBytes, query) : []
+ }))
+
+ return Array.from(new Set(tokenSets.flat()))
+ },
+
+ async searchTokensForKey(rawKeyBytes, text) {
+ const keywords = extractSearchKeywords(text)
+ if (keywords.length === 0) return []
+
+ const hmacKey = await importHmacKey(rawKeyBytes)
+ const tokens = await Promise.all(keywords.map(async keyword => {
+ const digest = await window.crypto.subtle.sign(
+ 'HMAC',
+ hmacKey,
+ textEncoder.encode(`chat-search:v1:${keyword}`)
+ )
+
+ return bytesToBase64(digest)
+ }))
+
+ return Array.from(new Set(tokens))
+ },
+
+ decryptVisibleMessages() {
+ if (!cryptoAvailable() || !this.conversationId()) return
+
+ this.el
+ .querySelectorAll('[data-chat-encrypted-message="true"], [data-chat-encrypted-preview="true"]')
+ .forEach(element => {
+ if (element.dataset.decrypted === 'true' || element.dataset.decrypted === 'failed') return
+ if (element.dataset.decrypting === 'true') return
+
+ element.dataset.decrypting = 'true'
+ this.decryptMessageElement(element).finally(() => {
+ delete element.dataset.decrypting
+ })
+ })
+ },
+
+ async decryptMessageElement(element) {
+ const payload = parseJson(element.dataset.payload, null)
+ const conversationId = Number(element.dataset.conversationId || this.conversationId())
+ const keyUid = payload?.key_uid || element.dataset.keyUid
+
+ const sentPlaintext = this.sentPlaintextForPayload(payload)
+ if (sentPlaintext) {
+ element.textContent = sentPlaintext
+ this.showEncryptedMessageElement(element)
+ element.dataset.decrypted = 'true'
+ return
+ }
+
+ if (!payload || !conversationId || !keyUid) {
+ this.showEncryptedMessageElement(element)
+ return
+ }
+
+ try {
+ const rawKeyBytes = await this.rawConversationKey(conversationId, keyUid, payload)
+ const aesKey = await importAesKey(rawKeyBytes, ['decrypt'])
+ const plaintext = await window.crypto.subtle.decrypt(
+ { name: 'AES-GCM', iv: base64ToBytes(payload.iv) },
+ aesKey,
+ arrayBufferFromBytes(base64ToBytes(payload.ciphertext))
+ )
+
+ element.textContent = textDecoder.decode(plaintext)
+ this.showEncryptedMessageElement(element)
+ element.dataset.decrypted = 'true'
+ } catch (error) {
+ if (error?.code !== 'key_not_found' && error?.message !== 'key_not_found') {
+ console.warn('Could not decrypt chat message', error)
+ }
+
+ element.textContent = 'Encrypted message'
+ this.showEncryptedMessageElement(element)
+ element.dataset.decrypted = 'failed'
+ }
+ },
+
+ showEncryptedMessageElement(element) {
+ element.classList.remove('opacity-0', 'opacity-80')
+ },
+
+ sentPlaintextCacheKey(payload) {
+ if (!payload?.key_uid || !payload?.ciphertext) return null
+ return `${payload.key_uid}:${payload.ciphertext}`
+ },
+
+ cacheSentPlaintext(payload, plaintext) {
+ const key = this.sentPlaintextCacheKey(payload)
+ if (!key || !plaintext) return
+
+ this.sentPlaintexts.set(key, plaintext)
+
+ if (this.sentPlaintexts.size > 50) {
+ this.sentPlaintexts.delete(this.sentPlaintexts.keys().next().value)
+ }
+
+ setTimeout(() => this.sentPlaintexts.delete(key), 5 * 60 * 1000)
+ },
+
+ forgetSentPlaintext(payload) {
+ const key = this.sentPlaintextCacheKey(payload)
+ if (key) this.sentPlaintexts.delete(key)
+ },
+
+ sentPlaintextForPayload(payload) {
+ const key = this.sentPlaintextCacheKey(payload)
+ return key ? this.sentPlaintexts.get(key) : null
+ },
+
+ async rawConversationKey(conversationId, keyUid, payload = null) {
+ const stored = await this.loadRawConversationKey(conversationId, keyUid)
+ if (stored) return stored
+
+ const cacheKey = `${conversationId}:${keyUid}`
+ if (this.unavailableConversationKeys.has(cacheKey)) {
+ throw new Error('key_not_found')
+ }
+
+ const pendingRequest = this.conversationKeyRequests.get(cacheKey)
+ if (pendingRequest) return pendingRequest
+
+ const request = (async () => {
+ const device = await this.loadOrCreateDevice()
+ const inlinePackage = this.inlineKeyPackageForDevice(payload, device.device_id)
+
+ if (inlinePackage?.wrapped_key?.encrypted_key) {
+ const rawKeyBytes = await this.decryptWrappedConversationKey(device, inlinePackage.wrapped_key)
+ await this.storeRawConversationKey(conversationId, keyUid, rawKeyBytes)
+ return rawKeyBytes
+ }
+
+ const wrappedKey = await new Promise((resolve, reject) => {
+ this.pushEvent(
+ 'chat_e2ee_key',
+ {
+ conversation_id: conversationId,
+ device_id: device.device_id,
+ key_uid: keyUid
+ },
+ reply => {
+ if (reply?.ok && reply.wrapped_key) {
+ resolve(reply.wrapped_key)
+ } else {
+ const error = new Error(reply?.error || 'Wrapped key not found')
+ error.code = reply?.error
+ reject(error)
+ }
+ }
+ )
+ })
+
+ const rawKeyBytes = await this.decryptWrappedConversationKey(device, wrappedKey)
+
+ await this.storeRawConversationKey(conversationId, keyUid, rawKeyBytes)
+
+ const hash = await devicesHash(this.devices())
+ await this.storeActiveConversationKey(conversationId, keyUid, hash, hash)
+
+ return rawKeyBytes
+ })()
+ .catch(error => {
+ if (error?.code === 'key_not_found' || error?.message === 'key_not_found') {
+ this.unavailableConversationKeys.add(cacheKey)
+ }
+
+ throw error
+ })
+ .finally(() => {
+ this.conversationKeyRequests.delete(cacheKey)
+ })
+
+ this.conversationKeyRequests.set(cacheKey, request)
+ return request
+ },
+
+ inlineKeyPackageForDevice(payload, deviceId) {
+ const packages = [
+ ...(payload?.local_key_packages || payload?.localKeyPackages || []),
+ ...(payload?.key_packages || payload?.keyPackages || []),
+ ...(payload?.federated_key_packages || payload?.federatedKeyPackages || [])
+ ]
+
+ return packages.find(keyPackage => keyPackage?.device_id === deviceId)
+ },
+
+ async decryptWrappedConversationKey(device, wrappedKey) {
+ const privateKey = device.private_key_crypto || await importRsaPrivateKey(device.private_key)
+ const rawKey = await window.crypto.subtle.decrypt(
+ { name: 'RSA-OAEP' },
+ privateKey,
+ arrayBufferFromBytes(base64ToBytes(wrappedKey.encrypted_key))
+ )
+
+ return new Uint8Array(rawKey)
+ },
+
+ async loadRawConversationKey(conversationId, keyUid) {
+ const storageKey = this.rawKeyStorageKey(conversationId, keyUid)
+ const cacheKey = this.rawConversationKeyCacheKey(conversationId, keyUid)
+
+ if (!this.rawConversationKeyCache) this.rawConversationKeyCache = new Map()
+ if (this.rawConversationKeyCache.has(cacheKey)) return this.rawConversationKeyCache.get(cacheKey)
+
+ let key = null
+
+ try {
+ key = await secureStorageGet(storageKey)
+ } catch (_error) {
+ key = null
+ }
+
+ if (key) {
+ const bytes = base64ToBytes(key)
+ this.rawConversationKeyCache.set(cacheKey, bytes)
+ await secureStorageDelete(storageKey)
+ return bytes
+ }
+
+ const storedKey = localStorage.getItem(storageKey)
+ if (storedKey) {
+ const bytes = base64ToBytes(storedKey)
+ localStorage.removeItem(storageKey)
+ this.rawConversationKeyCache.set(cacheKey, bytes)
+ return bytes
+ }
+
+ return null
+ },
+
+ async storeRawConversationKey(conversationId, keyUid, rawKeyBytes) {
+ if (!this.rawConversationKeyCache) this.rawConversationKeyCache = new Map()
+ this.rawConversationKeyCache.set(this.rawConversationKeyCacheKey(conversationId, keyUid), rawKeyBytes)
+
+ await secureStorageDelete(this.rawKeyStorageKey(conversationId, keyUid))
+ localStorage.removeItem(this.rawKeyStorageKey(conversationId, keyUid))
+
+ const listKey = this.keyListStorageKey(conversationId)
+ const keyUids = (await this.loadSecureJson(listKey)) || []
+ if (!keyUids.includes(keyUid)) {
+ await secureStorageSet(listKey, [...keyUids, keyUid])
+ localStorage.removeItem(listKey)
+ }
+ },
+
+ async storeActiveConversationKey(conversationId, keyUid, devicesHashValue, packagesSentHash) {
+ const key = this.activeKeyStorageKey(conversationId)
+ await secureStorageSet(key, {
+ key_uid: keyUid,
+ devices_hash: devicesHashValue,
+ packages_sent_hash: packagesSentHash
+ })
+ localStorage.removeItem(key)
+ },
+
+ markPackagesSent(keyUid, devicesHashValue) {
+ const conversationId = this.conversationId()
+ if (!conversationId) return
+
+ this.storeActiveConversationKey(conversationId, keyUid, devicesHashValue, devicesHashValue).catch(() => null)
+ },
+
+ rawKeyStorageKey(conversationId, keyUid) {
+ return `${CHAT_E2EE_STORAGE_PREFIX}:user:${this.userId()}:conversation:${conversationId}:key:${keyUid}`
+ },
+
+ rawConversationKeyCacheKey(conversationId, keyUid) {
+ return `${conversationId}:${keyUid}`
+ },
+
+ activeKeyStorageKey(conversationId) {
+ return `${CHAT_E2EE_STORAGE_PREFIX}:user:${this.userId()}:conversation:${conversationId}:active-key`
+ },
+
+ keyListStorageKey(conversationId) {
+ return `${CHAT_E2EE_STORAGE_PREFIX}:user:${this.userId()}:conversation:${conversationId}:keys`
+ },
+
+ enabledStorageKey(conversationId) {
+ return `${CHAT_E2EE_STORAGE_PREFIX}:user:${this.userId()}:conversation:${conversationId}:enabled`
+ }
+}
diff --git a/apps/elektrine/assets/js/hooks/chat_e2ee_messages.js b/apps/elektrine/assets/js/hooks/chat_e2ee_messages.js
new file mode 100644
index 0000000..d71656c
--- /dev/null
+++ b/apps/elektrine/assets/js/hooks/chat_e2ee_messages.js
@@ -0,0 +1,92 @@
+export function waitingForMemberKeysMessage(memberIds, devices) {
+ const deviceUserIds = new Set(
+ devices
+ .map(device => Number(device.user_id))
+ .filter(id => Number.isInteger(id) && id > 0)
+ )
+ const missingCount = memberIds.filter(id => !deviceUserIds.has(id)).length
+
+ if (missingCount <= 1) {
+ return 'Waiting for one active member to register encryption keys. They need to open this chat once.'
+ }
+
+ return `Waiting for ${missingCount} active members to register encryption keys. They need to open this chat once.`
+}
+
+export function chatE2EEUnavailableLabel(status, setupRequired) {
+ if (setupRequired) {
+ return 'Setting up this browser'
+ }
+
+ switch (status) {
+ case 'registering_device':
+ return 'Registering this browser'
+ case 'waiting_for_remote_keys':
+ return 'Waiting for remote keys'
+ case 'waiting_for_member_keys':
+ return 'Waiting for member keys'
+ case 'too_many_devices':
+ return 'Too many devices'
+ case 'not_applicable':
+ return 'Encrypted chat not supported here'
+ default:
+ return 'Encrypted chat not ready'
+ }
+}
+
+export function chatE2EEUnavailableTitle(status, setupRequired) {
+ if (setupRequired) {
+ return 'This browser is still registering its own encryption keys. Encrypted sending will unlock automatically.'
+ }
+
+ switch (status) {
+ case 'registering_device':
+ return 'This browser is registering an encryption device. Try again in a moment.'
+ case 'waiting_for_remote_keys':
+ return 'The remote participant has not advertised compatible chat encryption keys yet.'
+ case 'waiting_for_member_keys':
+ return 'Every active member needs at least one registered chat encryption device.'
+ case 'too_many_devices':
+ return 'This conversation has too many devices for the simple E2EE mode.'
+ case 'not_applicable':
+ return 'Optional client-side E2EE is currently supported for DMs and groups.'
+ default:
+ return 'Encrypted chat is not ready for this conversation yet.'
+ }
+}
+
+export function encryptedSubmitBlockedMessage(content, hasUploads) {
+ if (!content) {
+ return 'Type a message before sending encrypted chat.'
+ }
+
+ if (hasUploads) {
+ return 'Encrypted chat only supports plain text right now. Remove attachments or turn encrypted chat off.'
+ }
+
+ return 'Encrypted chat only supports plain text right now. Turn it off to use commands.'
+}
+
+export function encryptedSendFailureMessage(error) {
+ switch (error) {
+ case 'missing_key_packages':
+ return 'Encrypted chat keys were not ready. Wait for the chat to finish key setup, then try again.'
+ case 'invalid_key_recipient':
+ return 'Encrypted chat keys changed while sending. Wait for the chat to resync, then try again.'
+ case 'invalid_key_package':
+ case 'invalid_encrypted_payload':
+ return 'This encrypted message could not be sent because its encrypted payload was invalid.'
+ case 'no_conversation':
+ return 'Select a chat before sending an encrypted message.'
+ case 'blocked':
+ return 'This message was not sent because one of you has blocked the other.'
+ case 'privacy_restricted':
+ return 'This message was not sent because this user is not accepting direct messages.'
+ default:
+ return 'Encrypted message could not be sent. Make sure everyone has opened this chat so their keys can register, then try again.'
+ }
+}
+
+export function conversationKeyPreparationMessage(deviceCount) {
+ return `Preparing encryption keys for ${deviceCount} device${deviceCount === 1 ? '' : 's'}...`
+}
diff --git a/apps/elektrine/assets/js/hooks/chat_hooks.js b/apps/elektrine/assets/js/hooks/chat_hooks.js
new file mode 100644
index 0000000..740b524
--- /dev/null
+++ b/apps/elektrine/assets/js/hooks/chat_hooks.js
@@ -0,0 +1,625 @@
+// Chat-specific LiveView hooks
+
+import { submitFormPreservingEvents } from "../utils/form_submission"
+
+// Enter-to-submit for a composer textarea.
+//
+// Sizing is not here: `.textarea-autosize` (field-sizing: content) grows the
+// element, so nothing in this hook measures or writes a height.
+export const AutoExpandTextarea = {
+ mounted() {
+ this.timeouts = []
+ this.sendingMessage = false
+ this.lastSentContent = ""
+ this.lastSentTime = 0
+ this.lastLiveUpdateValue = null
+ this.liveUpdateEvent = this.el.dataset.liveUpdateEvent
+ this.submitOnEnter = this.el.dataset.submitOnEnter !== 'false'
+
+ const queueTimeout = (callback, delay) => {
+ const timer = setTimeout(() => {
+ this.timeouts = this.timeouts.filter((timeout) => timeout !== timer)
+ callback()
+ }, delay)
+
+ this.timeouts.push(timer)
+ return timer
+ }
+
+ this.queueTimeout = queueTimeout
+
+ const handleInput = () => {
+ if (this.liveUpdateEvent && this.el.value !== this.lastLiveUpdateValue) {
+ this.lastLiveUpdateValue = this.el.value
+ this.pushEvent(this.liveUpdateEvent, { value: this.el.value })
+ }
+ }
+
+ this.handleInput = handleInput
+
+ this.el.addEventListener('input', handleInput)
+ this.el.addEventListener('change', handleInput)
+ this.onPaste = () => queueTimeout(handleInput, 10)
+ this.el.addEventListener('paste', this.onPaste)
+
+ // Handle keyboard shortcuts
+ this.onKeyDown = (e) => {
+ // Shift+Enter inserts a newline, and Enter does too where the composer
+ // opted out of submit-on-enter. Both are the browser's default.
+ if (e.key !== "Enter" || e.shiftKey || !this.submitOnEnter) return
+
+ // Send message on Enter (without Shift)
+ if (e.key === "Enter" && !e.shiftKey) {
+ e.preventDefault()
+
+ const content = this.el.value.trim()
+ const now = Date.now()
+
+ // Enhanced duplicate prevention
+ if (this.sendingMessage ||
+ !content ||
+ (content === this.lastSentContent && (now - this.lastSentTime) < 500)) {
+ return
+ }
+
+ const form = this.el.closest("form")
+ if (form) {
+ this.sendingMessage = true
+ this.lastSentContent = content
+ this.lastSentTime = now
+
+ const submitBtn = form.querySelector('button[type="submit"]')
+ submitFormPreservingEvents(form, submitBtn?.disabled ? null : submitBtn)
+
+ // Disable the submit button temporarily after LiveView receives the submit.
+ if (submitBtn) {
+ submitBtn.disabled = true
+ }
+
+ // Reset after submit
+ queueTimeout(() => {
+ this.el.value = ""
+ this.sendingMessage = false
+ if (submitBtn) {
+ submitBtn.disabled = false
+ }
+ }, 100)
+ }
+ }
+ }
+
+ this.el.addEventListener("keydown", this.onKeyDown)
+
+ const reenableSubmit = () => {
+ const submitBtn = this.el
+ .closest("form")
+ ?.querySelector('button[type="submit"]')
+
+ if (submitBtn) submitBtn.disabled = false
+ }
+
+ // Handle clear message input event
+ this.handleEvent("clear_message_input", () => {
+ this.sendingMessage = false
+ this.el.value = ""
+ this.el.focus()
+ reenableSubmit()
+ })
+
+ // Handle reset textarea event (for explicit resets)
+ this.handleEvent("reset_textarea", ({id}) => {
+ if (this.el.id !== id) return
+
+ this.sendingMessage = false
+ this.el.value = ""
+ reenableSubmit()
+ })
+
+ // Listen for form submit to clear the textarea
+ const form = this.el.closest("form")
+ if (form) {
+ this.form = form
+ this.onFormSubmit = () => {
+ queueTimeout(() => {
+ this.el.value = ""
+ }, 100)
+ }
+
+ form.addEventListener('submit', this.onFormSubmit)
+ }
+ },
+
+ updated() {
+ // Reset sending state
+ this.sendingMessage = false
+ },
+
+ destroyed() {
+ if (this.handleInput) {
+ this.el.removeEventListener('input', this.handleInput)
+ this.el.removeEventListener('change', this.handleInput)
+ }
+ if (this.onPaste) this.el.removeEventListener('paste', this.onPaste)
+ if (this.onKeyDown) this.el.removeEventListener('keydown', this.onKeyDown)
+ if (this.form && this.onFormSubmit) this.form.removeEventListener('submit', this.onFormSubmit)
+
+ ;(this.timeouts || []).forEach((timer) => clearTimeout(timer))
+ this.timeouts = []
+ }
+}
+
+// Enter-to-submit and draft preservation for the chat composer.
+//
+// Sizing is `.textarea-autosize` (field-sizing: content), so this hook no
+// longer measures scrollHeight or writes a height on every keystroke.
+export const SimpleChatInput = {
+ mounted() {
+ this.form = this.el.closest("form")
+ this.awaitingSubmitClear = false
+ this.valueBeforeUpdate = ""
+ this.wasFocusedBeforeUpdate = false
+
+ this.el.rows = 1
+
+ this.handleInput = () => {
+ if (this.awaitingSubmitClear) {
+ this.awaitingSubmitClear = false
+ }
+ }
+
+ this.el.addEventListener("input", this.handleInput)
+
+ // Enter key handling
+ this.handleKeydown = (e) => {
+ if (e.key === "Enter" && !e.shiftKey) {
+ e.preventDefault()
+ if (this.form && this.el.value.trim()) {
+ this.awaitingSubmitClear = true
+ const submitter = this.form.querySelector('button[type="submit"]')
+ submitFormPreservingEvents(this.form, submitter?.disabled ? null : submitter)
+ }
+ }
+ }
+
+ this.el.addEventListener("keydown", this.handleKeydown)
+
+ if (this.form) {
+ this.handleFormSubmit = () => {
+ this.awaitingSubmitClear = true
+ }
+
+ this.form.addEventListener("submit", this.handleFormSubmit)
+ }
+
+ this.handleEvent("clear_message_input", () => {
+ this.awaitingSubmitClear = false
+ this.el.value = ""
+ this.el.focus()
+ })
+
+ this.focusComposer = () => {
+ const activeElement = document.activeElement
+ const activeTag = activeElement?.tagName
+ const activeIsTypingTarget = activeTag === "INPUT" || activeTag === "TEXTAREA" || activeTag === "SELECT"
+ const overlayActive =
+ document.getElementById("chat-keyboard-shortcuts")?.dataset.activeOverlay === "true"
+
+ if (
+ window.matchMedia("(min-width: 640px)").matches &&
+ !activeIsTypingTarget &&
+ !overlayActive
+ ) {
+ this.el.focus({ preventScroll: true })
+ }
+ }
+
+ setTimeout(this.focusComposer, 0)
+ },
+
+ beforeUpdate() {
+ this.valueBeforeUpdate = this.el.value
+ this.wasFocusedBeforeUpdate = document.activeElement === this.el
+ },
+
+ updated() {
+ if (this.awaitingSubmitClear) {
+ if (this.el.value === "") {
+ this.awaitingSubmitClear = false
+ }
+
+ return
+ }
+
+ // Keep local draft if an unrelated patch tries to shorten it while typing.
+ if (
+ this.wasFocusedBeforeUpdate &&
+ this.valueBeforeUpdate &&
+ this.el.value.length < this.valueBeforeUpdate.length
+ ) {
+ const cursorPosition = this.el.selectionStart
+ this.el.value = this.valueBeforeUpdate
+
+ if (cursorPosition !== null) {
+ const safePosition = Math.min(cursorPosition, this.el.value.length)
+ this.el.setSelectionRange(safePosition, safePosition)
+ }
+ }
+ },
+
+ destroyed() {
+ if (this.handleInput) {
+ this.el.removeEventListener("input", this.handleInput)
+ }
+
+ if (this.handleKeydown) {
+ this.el.removeEventListener("keydown", this.handleKeydown)
+ }
+
+ if (this.form && this.handleFormSubmit) {
+ this.form.removeEventListener("submit", this.handleFormSubmit)
+ }
+ }
+}
+
+export const ChatKeyboardShortcuts = {
+ mounted() {
+ this.handleKeydown = (event) => {
+ if (event.key !== "Escape" || event.defaultPrevented) return
+ if (this.el.dataset.activeOverlay !== "true") return
+
+ event.preventDefault()
+ this.pushEvent("close_chat_overlay", {})
+ }
+
+ document.addEventListener("keydown", this.handleKeydown)
+ },
+
+ destroyed() {
+ if (this.handleKeydown) {
+ document.removeEventListener("keydown", this.handleKeydown)
+ }
+ }
+}
+
+export const MessageList = {
+ mounted() {
+ const container = this.el
+ this.isLoadingOlder = false
+ this.initialScrollDone = false
+ this.isRestoringScroll = false
+ this.currentConversationId = container.dataset.conversationId
+ this.scrollPositions = window.elektrineChatScrollPositions || new Map()
+ window.elektrineChatScrollPositions = this.scrollPositions
+
+ // Check if user is near bottom (within 150px)
+ const isNearBottom = () => {
+ return container.scrollHeight - container.scrollTop - container.clientHeight < 150
+ }
+
+ const maxScrollTop = () => Math.max(0, container.scrollHeight - container.clientHeight)
+
+ const saveCurrentScrollPosition = () => {
+ if (!this.currentConversationId) return
+ if (this.isRestoringScroll) return
+
+ this.scrollPositions.set(String(this.currentConversationId), {
+ top: container.scrollTop,
+ atBottom: isNearBottom()
+ })
+ }
+
+ // Smoothly scroll to bottom
+ const scrollToBottom = (behavior = 'smooth') => {
+ requestAnimationFrame(() => {
+ container.scrollTo({
+ top: container.scrollHeight,
+ behavior: behavior
+ })
+ saveCurrentScrollPosition()
+ })
+ }
+
+ const scrollElementInContainer = (element, block = 'center', behavior = 'smooth') => {
+ const containerRect = container.getBoundingClientRect()
+ const elementRect = element.getBoundingClientRect()
+ const elementTop = elementRect.top - containerRect.top + container.scrollTop
+ let targetTop
+
+ if (block === 'top-third') {
+ targetTop = elementTop - container.clientHeight / 3
+ } else if (block === 'start') {
+ targetTop = elementTop
+ } else {
+ targetTop = elementTop - container.clientHeight / 2 + elementRect.height / 2
+ }
+
+ container.scrollTo({
+ top: Math.max(0, Math.min(targetTop, maxScrollTop())),
+ behavior
+ })
+ saveCurrentScrollPosition()
+ }
+
+ this.restoreConversationScroll = (conversationId = this.currentConversationId) => {
+ const savedPosition = conversationId && this.scrollPositions.get(String(conversationId))
+ const restoreToken = Symbol('restore-scroll')
+ this.restoreToken = restoreToken
+ this.isRestoringScroll = true
+
+ const restore = () => {
+ if (savedPosition && !savedPosition.atBottom) {
+ container.scrollTo({
+ top: Math.min(savedPosition.top, maxScrollTop()),
+ behavior: 'auto'
+ })
+ saveCurrentScrollPosition()
+ } else {
+ scrollToBottom('auto')
+ }
+ }
+
+ restore()
+
+ ;[50, 120, 250, 500].forEach(delay => {
+ setTimeout(restore, delay)
+ })
+
+ setTimeout(() => {
+ if (this.restoreToken !== restoreToken) return
+
+ this.isRestoringScroll = false
+ this.initialScrollDone = true
+ saveCurrentScrollPosition()
+ }, 600)
+ }
+
+ // Show/hide "jump to bottom" button when scrolled up
+ const updateJumpButton = () => {
+ const hasScrolledUp = !isNearBottom()
+ const jumpBtn = document.getElementById('jump-to-bottom')
+ if (jumpBtn) {
+ if (hasScrolledUp && this.initialScrollDone) {
+ jumpBtn.classList.remove('hidden')
+ } else {
+ jumpBtn.classList.add('hidden')
+ }
+ }
+ }
+
+ // Function to check scroll position and load more messages
+ const checkScrollPosition = () => {
+ const scrollTop = container.scrollTop
+
+ // Update jump button visibility
+ updateJumpButton()
+
+ // Load older messages when scrolled near top (within 100px)
+ if (scrollTop < 100 && !this.isLoadingOlder) {
+ this.isLoadingOlder = true
+ this.pushEvent("load_older_messages", {})
+ }
+
+ // Reset loading flag when scroll position changes
+ if (scrollTop > 200) {
+ this.isLoadingOlder = false
+ }
+ }
+
+ // Add scroll event listener with debouncing
+ let scrollTimeout
+ container.addEventListener('scroll', () => {
+ saveCurrentScrollPosition()
+ clearTimeout(scrollTimeout)
+ scrollTimeout = setTimeout(checkScrollPosition, 100)
+ })
+
+ // Handle maintaining scroll position after loading older messages
+ this.handleEvent("maintain_scroll_position", () => {
+ const prevHeight = container.scrollHeight
+ requestAnimationFrame(() => {
+ const newHeight = container.scrollHeight
+ const heightDiff = newHeight - prevHeight
+ container.scrollTop += heightDiff
+ this.isLoadingOlder = false
+ })
+ })
+
+ // Handle scroll to specific message
+ this.handleEvent("scroll_to_message", ({message_id}) => {
+ const messageEl = document.getElementById(`message-${message_id}`)
+ if (messageEl) {
+ scrollElementInContainer(messageEl, 'center', 'smooth')
+ messageEl.classList.add('highlight-message')
+ setTimeout(() => messageEl.classList.remove('highlight-message'), 2000)
+ this.initialScrollDone = true
+ }
+ })
+
+ // Handle image loading - re-scroll if user is at bottom
+ let pendingImageLoads = 0
+
+ const handleImageLoad = () => {
+ pendingImageLoads = Math.max(0, pendingImageLoads - 1)
+
+ // Only re-scroll if user is still near bottom and initial scroll is done
+ if (this.initialScrollDone && isNearBottom()) {
+ scrollToBottom('auto')
+
+ // Do one more scroll after a delay to catch any dimension changes
+ setTimeout(() => {
+ if (isNearBottom()) {
+ scrollToBottom('auto')
+ }
+ }, 100)
+ }
+ }
+
+ const imagesFromNode = (node) => {
+ if (node.nodeType !== Node.ELEMENT_NODE) return []
+
+ const images = node.tagName === 'IMG' ? [node] : []
+ return images.concat(Array.from(node.querySelectorAll?.('img') || []))
+ }
+
+ // Add listeners only within the changed subtree instead of rescanning the full chat.
+ const addImageListeners = (root = container) => {
+ const images = root === container ? container.querySelectorAll('img') : imagesFromNode(root)
+ images.forEach(img => {
+ if (!img.dataset.listenerAdded) {
+ img.dataset.listenerAdded = 'true'
+
+ if (!img.complete) {
+ pendingImageLoads++
+ img.addEventListener('load', handleImageLoad, { once: true })
+ img.addEventListener('error', handleImageLoad, { once: true })
+ }
+ }
+ })
+ }
+
+ // Initial image listener setup
+ addImageListeners()
+
+ // Scroll to the latest messages on initial load.
+ // Server-driven unread scrolling can still override this afterward.
+ if (this.currentConversationId) {
+ this.restoreConversationScroll()
+ }
+
+ // Add image listeners when new content is added
+ const imageObserver = new MutationObserver((mutations) => {
+ mutations.forEach(mutation => {
+ mutation.addedNodes.forEach(node => addImageListeners(node))
+ })
+ })
+
+ imageObserver.observe(container, {
+ childList: true,
+ subtree: true
+ })
+
+ this.imageObserver = imageObserver
+
+ this.handleEvent("restore_conversation_scroll", ({conversation_id}) => {
+ if (String(conversation_id) === String(this.currentConversationId)) {
+ this.initialScrollDone = false
+ this.restoreConversationScroll(conversation_id)
+ }
+ })
+
+ // Handle scroll to bottom (server controls initial scroll)
+ this.handleEvent("scroll_to_bottom", () => {
+ // Aggressive initial scroll to handle images
+ const doScroll = () => {
+ const wasNearBottom = isNearBottom()
+ scrollToBottom('auto')
+ return wasNearBottom
+ }
+
+ // Immediate scroll
+ doScroll()
+
+ // Re-scroll multiple times to catch images loading
+ // This is necessary because images load asynchronously
+ const scrollAttempts = [50, 100, 200, 300, 500, 800, 1200, 1800]
+ scrollAttempts.forEach(delay => {
+ setTimeout(() => {
+ // Only keep scrolling if we were at/near bottom
+ // This prevents fighting with user if they scrolled up
+ if (isNearBottom()) {
+ scrollToBottom('auto')
+ }
+ }, delay)
+ })
+
+ // Mark done after all attempts
+ setTimeout(() => {
+ this.initialScrollDone = true
+ }, 2000)
+ })
+
+ // Handle scroll to element (for unread indicator)
+ this.handleEvent("scroll_to_element", ({element_id, position}) => {
+ setTimeout(() => {
+ const element = document.getElementById(element_id)
+ if (element) {
+ const block = position === 'top-third' ? 'center' : 'start'
+ scrollElementInContainer(element, position === 'top-third' ? 'top-third' : block, 'smooth')
+ setTimeout(() => {
+ this.initialScrollDone = true
+ }, 500)
+ }
+ }, 100)
+ })
+
+ // Watch for new messages being added (after initial scroll)
+ const observer = new MutationObserver((mutations) => {
+ // Only process after initial scroll is complete
+ if (!this.initialScrollDone) return
+
+ // Check if new messages were added (not just timestamp updates)
+ const hasNewMessages = mutations.some(mutation => {
+ return Array.from(mutation.addedNodes).some(node => {
+ if (node.nodeType !== 1) return false
+
+ // Check if this is an actual message element
+ if (node.id?.startsWith('message-') && !node.id?.includes('bubble') && !node.id?.includes('local-time')) {
+ return true
+ }
+
+ // Check for containers with message children
+ const hasMessageChild = node.querySelector?.('[id^="message-"]:not([id*="bubble"]):not([id*="local-time"])')
+ return hasMessageChild ? true : false
+ })
+ })
+
+ if (hasNewMessages) {
+ // Add image listeners to any new images
+ mutations.forEach(mutation => {
+ mutation.addedNodes.forEach(node => addImageListeners(node))
+ })
+
+ // Only auto-scroll if user is near bottom (standard messenger UX)
+ if (isNearBottom()) {
+ requestAnimationFrame(() => {
+ scrollToBottom('smooth')
+ })
+ } else {
+ // User scrolled up - show jump button instead
+ updateJumpButton()
+ }
+ }
+ })
+
+ observer.observe(container, {
+ childList: true,
+ subtree: true
+ })
+
+ this.observer = observer
+ },
+
+ updated() {
+ // When conversation changes (detected by data-conversation-id change)
+ const newConversationId = this.el.dataset.conversationId
+ if (newConversationId && newConversationId !== this.currentConversationId) {
+ // Conversation switched - reset state
+ this.currentConversationId = newConversationId
+ this.initialScrollDone = false
+ this.isLoadingOlder = false
+
+ // The imageObserver will automatically catch new images in the new conversation
+ // No need to manually call addImageListeners - MutationObserver handles it
+ this.restoreConversationScroll(newConversationId)
+ }
+ },
+
+ destroyed() {
+ if (this.observer) {
+ this.observer.disconnect()
+ }
+ if (this.imageObserver) {
+ this.imageObserver.disconnect()
+ }
+ }
+}
diff --git a/apps/elektrine/assets/js/hooks/chat_voice_recorder_hook.js b/apps/elektrine/assets/js/hooks/chat_voice_recorder_hook.js
new file mode 100644
index 0000000..6b3e4f2
--- /dev/null
+++ b/apps/elektrine/assets/js/hooks/chat_voice_recorder_hook.js
@@ -0,0 +1,167 @@
+export const VoiceRecorder = {
+ mounted() {
+ this.mediaRecorder = null
+ this.audioChunks = []
+ this.isRecording = false
+ this.recordingTimer = null
+ this.recordingSeconds = 0
+ this.maxDuration = 120
+
+ const recordBtn = this.el
+ const timerEl = document.getElementById('voice-timer')
+ const cancelBtn = document.getElementById('voice-cancel')
+ const sendBtn = document.getElementById('voice-send')
+ const recordingIndicator = document.getElementById('voice-recording-indicator')
+ this.cancelBtn = cancelBtn
+ this.sendBtn = sendBtn
+
+ const updateUI = (recording) => {
+ if (recordingIndicator) {
+ recordingIndicator.classList.toggle('hidden', !recording)
+ }
+ recordBtn.classList.toggle('text-error', recording)
+ recordBtn.classList.toggle('animate-pulse', recording)
+ }
+
+ const formatTime = (seconds) => {
+ const mins = Math.floor(seconds / 60)
+ const secs = seconds % 60
+ return `${mins}:${secs.toString().padStart(2, '0')}`
+ }
+
+ const startRecording = async () => {
+ try {
+ const stream = await navigator.mediaDevices.getUserMedia({ audio: true })
+ this.audioChunks = []
+
+ const mimeType = MediaRecorder.isTypeSupported('audio/webm') ? 'audio/webm' : 'audio/mp4'
+ this.mediaRecorder = new MediaRecorder(stream, { mimeType })
+
+ this.mediaRecorder.ondataavailable = (event) => {
+ if (event.data.size > 0) {
+ this.audioChunks.push(event.data)
+ }
+ }
+
+ this.mediaRecorder.onstop = () => {
+ stream.getTracks().forEach((track) => track.stop())
+ }
+
+ this.mediaRecorder.start(100)
+ this.isRecording = true
+ this.recordingSeconds = 0
+ updateUI(true)
+
+ this.recordingTimer = setInterval(() => {
+ this.recordingSeconds++
+ if (timerEl) {
+ timerEl.textContent = formatTime(this.recordingSeconds)
+ }
+
+ if (this.recordingSeconds >= this.maxDuration) {
+ sendRecording()
+ }
+ }, 1000)
+ } catch (_err) {
+ this.pushEvent('voice_recording_error', { error: 'Microphone access denied' })
+ }
+ }
+
+ const stopRecording = () => {
+ if (this.mediaRecorder && this.isRecording) {
+ this.mediaRecorder.stop()
+ this.isRecording = false
+ clearInterval(this.recordingTimer)
+ updateUI(false)
+ if (timerEl) timerEl.textContent = '0:00'
+ }
+ }
+
+ const cancelRecording = () => {
+ stopRecording()
+ this.audioChunks = []
+ this.recordingSeconds = 0
+ }
+
+ const sendRecording = async () => {
+ if (!this.isRecording && this.audioChunks.length === 0) return
+
+ if (this.isRecording) {
+ this.mediaRecorder.stop()
+ this.isRecording = false
+ clearInterval(this.recordingTimer)
+ updateUI(false)
+
+ await new Promise((resolve) => setTimeout(resolve, 100))
+ }
+
+ if (this.audioChunks.length === 0) return
+
+ const mimeType = this.mediaRecorder?.mimeType || 'audio/webm'
+ const audioBlob = new Blob(this.audioChunks, { type: mimeType })
+ const duration = this.recordingSeconds
+ const reader = new FileReader()
+
+ reader.onload = () => {
+ const base64 = reader.result.split(',')[1]
+ this.pushEvent('send_voice_message', {
+ audio_data: base64,
+ duration: duration,
+ mime_type: mimeType
+ })
+ }
+
+ reader.readAsDataURL(audioBlob)
+ this.audioChunks = []
+ this.recordingSeconds = 0
+ if (timerEl) timerEl.textContent = '0:00'
+ }
+
+ this.recordClickHandler = () => {
+ if (this.isRecording) {
+ sendRecording()
+ } else {
+ startRecording()
+ }
+ }
+ recordBtn.addEventListener('click', this.recordClickHandler)
+
+ if (cancelBtn) {
+ this.cancelClickHandler = cancelRecording
+ cancelBtn.addEventListener('click', this.cancelClickHandler)
+ }
+
+ if (sendBtn) {
+ this.sendClickHandler = sendRecording
+ sendBtn.addEventListener('click', this.sendClickHandler)
+ }
+
+ this.escapeKeyHandler = (event) => {
+ if (event.key === 'Escape' && this.isRecording) {
+ cancelRecording()
+ }
+ }
+ document.addEventListener('keydown', this.escapeKeyHandler)
+ },
+
+ destroyed() {
+ if (this.recordClickHandler) {
+ this.el.removeEventListener('click', this.recordClickHandler)
+ }
+ if (this.cancelBtn && this.cancelClickHandler) {
+ this.cancelBtn.removeEventListener('click', this.cancelClickHandler)
+ }
+ if (this.sendBtn && this.sendClickHandler) {
+ this.sendBtn.removeEventListener('click', this.sendClickHandler)
+ }
+ if (this.escapeKeyHandler) {
+ document.removeEventListener('keydown', this.escapeKeyHandler)
+ }
+ if (this.recordingTimer) {
+ clearInterval(this.recordingTimer)
+ }
+ if (this.mediaRecorder && this.isRecording) {
+ this.mediaRecorder.stop()
+ }
+ }
+}
diff --git a/apps/elektrine/assets/js/hooks/email_compose_shortcuts_hook.js b/apps/elektrine/assets/js/hooks/email_compose_shortcuts_hook.js
new file mode 100644
index 0000000..56dac7a
--- /dev/null
+++ b/apps/elektrine/assets/js/hooks/email_compose_shortcuts_hook.js
@@ -0,0 +1,264 @@
+import {
+ closeExistingShortcutModals,
+ isActiveEmailShortcutHelpOwner,
+ isEditableTarget,
+ registerEmailShortcutHelp,
+} from "./email_shortcut_helpers"
+
+// Keyboard shortcuts for email compose page
+export const EmailComposeKeyboardShortcuts = {
+ mounted() {
+ this.unregisterShortcutHelp = registerEmailShortcutHelp(this, () => this.showShortcutsHelp())
+
+ this.keyHandler = (e) => {
+ const key = e.key.toLowerCase()
+ const ctrl = e.ctrlKey || e.metaKey
+
+ // Don't interfere with dropdowns
+ if (e.target.closest('.dropdown.dropdown-open')) {
+ return
+ }
+
+ // Handle shortcuts
+ switch (key) {
+ case 'enter':
+ // Ctrl/Cmd + Enter to send email
+ if (ctrl) {
+ e.preventDefault()
+ const submitBtn = document.querySelector('button[type="submit"]')
+ if (submitBtn && !submitBtn.disabled) {
+ submitBtn.click()
+ }
+ }
+ break
+
+ case 'escape':
+ // Don't trigger if typing in input/textarea
+ if (isEditableTarget(e.target)) {
+ return
+ }
+ e.preventDefault()
+ // Go back
+ const backBtn = document.querySelector('a[href*="/email"]')
+ if (backBtn) {
+ window.location.href = backBtn.href
+ }
+ break
+
+ case 'g':
+ // Don't trigger if typing
+ if (isEditableTarget(e.target)) {
+ return
+ }
+ if (!ctrl) {
+ e.preventDefault()
+ this.showGotoMenu()
+ }
+ break
+
+ case '?':
+ // Don't trigger if typing
+ if (isEditableTarget(e.target)) {
+ return
+ }
+ e.preventDefault()
+ this.showShortcutsHelp()
+ break
+ }
+ }
+
+ document.addEventListener('keydown', this.keyHandler)
+
+ this.handleEvent("show-keyboard-shortcuts", () => {
+ if (isActiveEmailShortcutHelpOwner(this)) {
+ this.showShortcutsHelp()
+ }
+ })
+ },
+
+ destroyed() {
+ if (this.keyHandler) {
+ document.removeEventListener('keydown', this.keyHandler)
+ }
+ if (this.gotoMenuCleanup) {
+ this.gotoMenuCleanup()
+ }
+ if (this.shortcutsModalCleanup) {
+ this.shortcutsModalCleanup()
+ }
+ if (this.unregisterShortcutHelp) {
+ this.unregisterShortcutHelp()
+ }
+ },
+
+ showGotoMenu() {
+ if (this.gotoMenuCleanup) {
+ this.gotoMenuCleanup()
+ }
+
+ const menu = document.createElement('div')
+ menu.className = 'shortcut-floating fixed top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 rounded-lg p-6 z-50'
+ menu.innerHTML = `
+ Go to...
+
+
+ gi Inbox
+
+
+ gs Sent
+
+
+ gt Search
+
+
+ Press Escape to close
+ `
+
+ document.body.appendChild(menu)
+
+ const handleGotoKey = (e) => {
+ const key = e.key.toLowerCase()
+
+ if (['escape', 'i', 's', 't'].includes(key)) {
+ e.preventDefault()
+ e.stopImmediatePropagation()
+ }
+
+ if (key === 'escape') {
+ cleanup()
+ } else if (key === 'i') {
+ window.location.href = '/email?tab=inbox'
+ cleanup()
+ } else if (key === 's') {
+ window.location.href = '/email?tab=sent'
+ cleanup()
+ } else if (key === 't') {
+ window.location.href = '/email?tab=search'
+ cleanup()
+ }
+ }
+
+ let timeoutId = null
+ const cleanup = () => {
+ if (timeoutId) {
+ clearTimeout(timeoutId)
+ timeoutId = null
+ }
+ document.removeEventListener('keydown', handleGotoKey, true)
+ if (menu.parentNode) {
+ menu.parentNode.removeChild(menu)
+ }
+ if (this.gotoMenuCleanup === cleanup) {
+ this.gotoMenuCleanup = null
+ }
+ }
+
+ this.gotoMenuCleanup = cleanup
+
+ menu.addEventListener('click', (e) => {
+ const button = e.target.closest('[data-goto]')
+ if (button) {
+ const destination = button.dataset.goto
+ window.location.href = '/email?tab=' + destination
+ cleanup()
+ }
+ })
+
+ document.addEventListener('keydown', handleGotoKey, true)
+
+ timeoutId = setTimeout(cleanup, 5000)
+ },
+
+ showShortcutsHelp() {
+ closeExistingShortcutModals()
+
+ if (this.shortcutsModalCleanup) {
+ this.shortcutsModalCleanup()
+ }
+
+ const modal = document.createElement('div')
+ modal.dataset.emailShortcutsModal = 'true'
+ modal.className = 'shortcut-overlay fixed inset-0 z-50 flex items-center justify-center p-4'
+ modal.innerHTML = `
+
+
+
Keyboard Shortcuts
+
+
+
+
+
+
+
+
+
+
Navigation
+
+
+ Go to inbox
+ g i
+
+
+ Go to sent
+ g s
+
+
+ Go to search
+ g t
+
+
+ Cancel/Go back
+ Esc
+
+
+
+
+
+
Actions
+
+
+ Send email
+ Ctrl + Enter
+
+
+
+
+
+
+
+ Show this help
+ ?
+
+
+
+ `
+
+ document.body.appendChild(modal)
+
+ const escHandler = (e) => {
+ if (e.key === 'Escape') {
+ cleanup()
+ }
+ }
+
+ const cleanup = () => {
+ document.removeEventListener('keydown', escHandler)
+ if (modal.parentNode) {
+ modal.parentNode.removeChild(modal)
+ }
+ if (this.shortcutsModalCleanup === cleanup) {
+ this.shortcutsModalCleanup = null
+ }
+ }
+
+ this.shortcutsModalCleanup = cleanup
+
+ modal.addEventListener('click', (e) => {
+ if (e.target === modal || e.target.closest('[data-close]')) {
+ cleanup()
+ }
+ })
+
+ document.addEventListener('keydown', escHandler)
+ }
+}
diff --git a/apps/elektrine/assets/js/hooks/email_hooks.js b/apps/elektrine/assets/js/hooks/email_hooks.js
new file mode 100644
index 0000000..8ec0d59
--- /dev/null
+++ b/apps/elektrine/assets/js/hooks/email_hooks.js
@@ -0,0 +1,739 @@
+// Email-related LiveView hooks
+
+import {
+ closeExistingShortcutModals,
+ isActiveEmailShortcutHelpOwner,
+ isEditableTarget,
+ registerEmailShortcutHelp,
+} from "./email_shortcut_helpers"
+
+export const KeyboardShortcuts = {
+ mounted() {
+ this.setupKeyboardShortcuts()
+ this.unregisterShortcutHelp = registerEmailShortcutHelp(this, () => this.showShortcutsHelp())
+
+ // Listen for server event to show keyboard shortcuts
+ this.handleEvent("show-keyboard-shortcuts", () => {
+ if (isActiveEmailShortcutHelpOwner(this)) {
+ this.showShortcutsHelp()
+ }
+ })
+
+ // Listen for scroll-to-top event when navigating between tabs
+ this.handleEvent("scroll-to-top", () => {
+ window.scrollTo({
+ top: 0,
+ behavior: 'smooth'
+ })
+ })
+
+ this.handleEvent("focus-search-input", () => {
+ this.focusSearchInput()
+ })
+ },
+
+ setupKeyboardShortcuts() {
+ // Track selected message for keyboard navigation
+ this.selectedMessageIndex = -1
+ this.messages = []
+
+ // Update message list when DOM changes
+ this.updateMessageList()
+
+ // Store the bound handler so we can remove it later
+ this.keyHandler = (e) => {
+ // Don't interfere when typing in inputs, textareas, or contenteditable elements
+ if (isEditableTarget(e.target) || e.target.closest('.dropdown.dropdown-open')) {
+ return
+ }
+
+ // Handle shortcuts
+ this.handleKeyboardShortcut(e)
+ }
+
+ document.addEventListener('keydown', this.keyHandler)
+
+ // Update message list when new messages are added
+ const observer = new MutationObserver(() => {
+ this.updateMessageList()
+ })
+
+ // Observe the entire document for changes
+ const messageContainer = document.getElementById('message-list') || document.body
+ observer.observe(messageContainer, {
+ childList: true,
+ subtree: true
+ })
+
+ this.observer = observer
+ },
+
+ destroyed() {
+ if (this.observer) {
+ this.observer.disconnect()
+ }
+ if (this.keyHandler) {
+ document.removeEventListener('keydown', this.keyHandler)
+ }
+ if (this.gotoMenuCleanup) {
+ this.gotoMenuCleanup()
+ }
+ if (this.unregisterShortcutHelp) {
+ this.unregisterShortcutHelp()
+ }
+ },
+
+ updateMessageList() {
+ // Look for messages in the document, not just within this.el
+ this.messages = Array.from(document.querySelectorAll('[id^="message-"]'))
+ if (this.selectedMessageIndex >= this.messages.length) {
+ this.selectedMessageIndex = this.messages.length - 1
+ }
+ },
+
+ handleKeyboardShortcut(e) {
+ const key = e.key.toLowerCase()
+ const ctrl = e.ctrlKey || e.metaKey
+
+
+ // Gmail-style shortcuts
+ switch (key) {
+ case 'c':
+ if (!ctrl) {
+ e.preventDefault()
+ this.navigateToCompose()
+ }
+ break
+
+ case 'g':
+ if (!ctrl) {
+ e.preventDefault()
+ this.showGotoMenu()
+ }
+ break
+
+ case '/':
+ e.preventDefault()
+ this.focusSearch()
+ break
+
+ case '?':
+ e.preventDefault()
+ this.showShortcutsHelp()
+ break
+
+ case 'j':
+ if (!ctrl) {
+ e.preventDefault()
+ this.selectNextMessage()
+ }
+ break
+
+ case 'k':
+ if (!ctrl) {
+ e.preventDefault()
+ this.selectPrevMessage()
+ }
+ break
+
+ case 'enter':
+ if (this.selectedMessageIndex >= 0) {
+ e.preventDefault()
+ this.openSelectedMessage()
+ }
+ break
+
+ case 'e':
+ if (!ctrl && this.selectedMessageIndex >= 0) {
+ e.preventDefault()
+ this.archiveSelectedMessage()
+ }
+ break
+
+ case 'r':
+ if (!ctrl && this.selectedMessageIndex >= 0) {
+ e.preventDefault()
+ this.replyToSelectedMessage()
+ }
+ break
+
+ case 'f':
+ if (!ctrl && this.selectedMessageIndex >= 0) {
+ e.preventDefault()
+ this.forwardSelectedMessage()
+ }
+ break
+
+ case '#':
+ if (this.selectedMessageIndex >= 0) {
+ e.preventDefault()
+ this.deleteSelectedMessage()
+ }
+ break
+
+ case '!':
+ if (this.selectedMessageIndex >= 0) {
+ e.preventDefault()
+ this.markSpamSelectedMessage()
+ }
+ break
+ }
+ },
+
+ showGotoMenu() {
+ if (this.gotoMenuCleanup) {
+ this.gotoMenuCleanup()
+ }
+
+ // Show a temporary goto menu
+ const menu = document.createElement('div')
+ menu.className = 'shortcut-floating fixed top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 rounded-lg p-6 z-50'
+ menu.innerHTML = `
+ Go to...
+
+
+ gi Inbox
+
+
+ gs Sent
+
+
+ gt Search
+
+
+ ga Archive
+
+
+ gp Spam
+
+
+ Press Escape to close
+ `
+
+ document.body.appendChild(menu)
+
+ const handleGotoKey = (e) => {
+ const key = e.key.toLowerCase()
+
+ if (['escape', 'i', 's', 't', 'a', 'p'].includes(key)) {
+ e.preventDefault()
+ e.stopImmediatePropagation()
+ }
+
+ if (key === 'escape') {
+ cleanup()
+ } else if (key === 'i') {
+ window.location.href = '/email?tab=inbox'
+ cleanup()
+ } else if (key === 's') {
+ window.location.href = '/email?tab=sent'
+ cleanup()
+ } else if (key === 't') {
+ window.location.href = '/email?tab=search'
+ cleanup()
+ } else if (key === 'a') {
+ window.location.href = '/email?tab=archive'
+ cleanup()
+ } else if (key === 'p') {
+ window.location.href = '/email?tab=spam'
+ cleanup()
+ }
+ }
+
+ let timeoutId = null
+ const cleanup = () => {
+ if (timeoutId) {
+ clearTimeout(timeoutId)
+ timeoutId = null
+ }
+ document.removeEventListener('keydown', handleGotoKey, true)
+ if (menu.parentNode) {
+ menu.parentNode.removeChild(menu)
+ }
+ if (this.gotoMenuCleanup === cleanup) {
+ this.gotoMenuCleanup = null
+ }
+ }
+
+ this.gotoMenuCleanup = cleanup
+
+ // Handle goto navigation
+ menu.addEventListener('click', (e) => {
+ const button = e.target.closest('[data-goto]')
+ if (button) {
+ const destination = button.dataset.goto
+ this.navigateTo(destination)
+ cleanup()
+ }
+ })
+
+ document.addEventListener('keydown', handleGotoKey, true)
+
+ // Auto-close after 5 seconds
+ timeoutId = setTimeout(cleanup, 5000)
+ },
+
+ navigateTo(destination) {
+ // Use LiveView event to navigate to tabs
+ const tabMap = {
+ inbox: 'inbox',
+ sent: 'sent',
+ search: 'search',
+ archive: 'archive',
+ spam: 'spam'
+ }
+
+ if (tabMap[destination]) {
+ // Send event to LiveView to switch tabs
+ this.pushEvent('switch_tab', { tab: tabMap[destination] })
+ }
+ },
+
+ navigateToCompose() {
+ // Use LiveView event to navigate to compose
+ this.pushEvent('navigate_to_compose', {})
+ },
+
+ focusSearch() {
+ // Switch to search tab and focus input
+ this.pushEvent('switch_tab', { tab: 'search', focus_search: true })
+ },
+
+ focusSearchInput() {
+ const focusInput = () => {
+ const input =
+ document.getElementById('email-search-input') ||
+ document.getElementById('email-index-search-input') ||
+ document.querySelector('[data-search-input]')
+
+ if (input) {
+ input.focus()
+ input.select?.()
+ }
+ }
+
+ requestAnimationFrame(() => {
+ focusInput()
+ setTimeout(focusInput, 75)
+ })
+ },
+
+ selectNextMessage() {
+ if (this.messages.length === 0) return
+
+ // Clear previous selection
+ this.clearMessageSelection()
+
+ this.selectedMessageIndex = Math.min(this.selectedMessageIndex + 1, this.messages.length - 1)
+ this.highlightSelectedMessage()
+ },
+
+ selectPrevMessage() {
+ if (this.messages.length === 0) return
+
+ // Clear previous selection
+ this.clearMessageSelection()
+
+ this.selectedMessageIndex = Math.max(this.selectedMessageIndex - 1, 0)
+ this.highlightSelectedMessage()
+ },
+
+ clearMessageSelection() {
+ this.messages.forEach(msg => {
+ msg.classList.remove('ring-2', 'ring-primary', 'ring-offset-2')
+ })
+ },
+
+ highlightSelectedMessage() {
+ if (this.selectedMessageIndex >= 0 && this.selectedMessageIndex < this.messages.length) {
+ const selectedMsg = this.messages[this.selectedMessageIndex]
+ selectedMsg.classList.add('ring-2', 'ring-primary', 'ring-offset-2')
+ selectedMsg.scrollIntoView({ behavior: 'smooth', block: 'center' })
+ }
+ },
+
+ openSelectedMessage() {
+ if (this.selectedMessageIndex >= 0 && this.selectedMessageIndex < this.messages.length) {
+ const selectedMsg = this.messages[this.selectedMessageIndex]
+ const openLink = selectedMsg.querySelector('.message-open-link')
+ if (openLink) {
+ openLink.click()
+ } else {
+ selectedMsg.click()
+ }
+ }
+ },
+
+ archiveSelectedMessage() {
+ // Archive functionality - trigger archive event
+ if (this.selectedMessageIndex >= 0 && this.selectedMessageIndex < this.messages.length) {
+ const selectedMsg = this.messages[this.selectedMessageIndex]
+ const messageId = selectedMsg.id.replace('message-', '')
+ // Trigger archive event on the LiveView component
+ this.pushEvent('archive_message', { message_id: messageId })
+ }
+ },
+
+ replyToSelectedMessage() {
+ // Reply functionality - open compose with reply
+ if (this.selectedMessageIndex >= 0 && this.selectedMessageIndex < this.messages.length) {
+ const selectedMsg = this.messages[this.selectedMessageIndex]
+ const messageId = selectedMsg.id.replace('message-', '')
+ this.pushEvent('open_compose', { mode: 'reply', message_id: messageId })
+ }
+ },
+
+ forwardSelectedMessage() {
+ // Forward functionality - open compose with forward
+ if (this.selectedMessageIndex >= 0 && this.selectedMessageIndex < this.messages.length) {
+ const selectedMsg = this.messages[this.selectedMessageIndex]
+ const messageId = selectedMsg.id.replace('message-', '')
+ this.pushEvent('open_compose', { mode: 'forward', message_id: messageId })
+ }
+ },
+
+ deleteSelectedMessage() {
+ if (this.selectedMessageIndex >= 0 && this.selectedMessageIndex < this.messages.length) {
+ const selectedMsg = this.messages[this.selectedMessageIndex]
+ const messageId = selectedMsg.id.replace('message-', '')
+ this.pushEvent('delete_message', { message_id: messageId })
+ }
+ },
+
+ markSpamSelectedMessage() {
+ // Mark as spam functionality
+ if (this.selectedMessageIndex >= 0 && this.selectedMessageIndex < this.messages.length) {
+ const selectedMsg = this.messages[this.selectedMessageIndex]
+ const messageId = selectedMsg.id.replace('message-', '')
+ // Trigger spam event on the LiveView component
+ this.pushEvent('mark_spam', { message_id: messageId })
+ }
+ },
+
+ showShortcutsHelp() {
+ closeExistingShortcutModals()
+
+ if (window.showKeyboardShortcuts) {
+ window.showKeyboardShortcuts()
+ }
+ }
+}
+
+// Keyboard shortcuts for email show/view page
+export const EmailShowKeyboardShortcuts = {
+ mounted() {
+ this.unregisterShortcutHelp = registerEmailShortcutHelp(this, () => this.showShortcutsHelp())
+
+ this.keyHandler = (e) => {
+ // Don't interfere when typing in inputs, textareas, or contenteditable elements
+ if (isEditableTarget(e.target) || e.target.closest('.dropdown.dropdown-open')) {
+ return
+ }
+
+ const key = e.key.toLowerCase()
+ const ctrl = e.ctrlKey || e.metaKey
+
+ // Handle shortcuts
+ switch (key) {
+ case 'c':
+ if (!ctrl) {
+ e.preventDefault()
+ window.location.href = '/email/compose'
+ }
+ break
+
+ case 'g':
+ if (!ctrl) {
+ e.preventDefault()
+ this.showGotoMenu()
+ }
+ break
+
+ case 'r':
+ if (!ctrl) {
+ e.preventDefault()
+ const replyBtn = document.querySelector('button[phx-click="reply"]')
+ if (replyBtn) replyBtn.click()
+ }
+ break
+
+ case 'a':
+ if (!ctrl) {
+ e.preventDefault()
+ const replyAllBtn = document.querySelector('button[phx-click="reply_all"]')
+ if (replyAllBtn) replyAllBtn.click()
+ }
+ break
+
+ case 'f':
+ if (!ctrl) {
+ e.preventDefault()
+ const forwardBtn = document.querySelector('button[phx-click="forward"]')
+ if (forwardBtn) forwardBtn.click()
+ }
+ break
+
+ case 'v':
+ if (!ctrl) {
+ e.preventDefault()
+ const rawLink = document.querySelector('a[href*="/raw"]')
+ if (rawLink) {
+ window.location.href = rawLink.href
+ }
+ }
+ break
+
+ case 'l':
+ if (!ctrl) {
+ e.preventDefault()
+ const replyLaterBtn = document.querySelector('button[phx-click="show_reply_later_modal"]')
+ if (replyLaterBtn) replyLaterBtn.click()
+ }
+ break
+
+ case 'escape':
+ e.preventDefault()
+ window.location.href = '/email'
+ break
+
+ case '?':
+ e.preventDefault()
+ this.showShortcutsHelp()
+ break
+ }
+ }
+
+ document.addEventListener('keydown', this.keyHandler)
+
+ this.handleEvent("show-keyboard-shortcuts", () => {
+ if (isActiveEmailShortcutHelpOwner(this)) {
+ this.showShortcutsHelp()
+ }
+ })
+
+ // Also attach FileDownloader functionality
+ this.handleEvent("download-file", ({ url, filename }) => {
+ const link = document.createElement('a')
+ link.href = url
+ link.download = filename
+ document.body.appendChild(link)
+ link.click()
+ document.body.removeChild(link)
+ })
+ },
+
+ destroyed() {
+ if (this.keyHandler) {
+ document.removeEventListener('keydown', this.keyHandler)
+ }
+ if (this.gotoMenuCleanup) {
+ this.gotoMenuCleanup()
+ }
+ if (this.shortcutsModalCleanup) {
+ this.shortcutsModalCleanup()
+ }
+ if (this.unregisterShortcutHelp) {
+ this.unregisterShortcutHelp()
+ }
+ },
+
+ showShortcutsHelp() {
+ closeExistingShortcutModals()
+
+ if (this.shortcutsModalCleanup) {
+ this.shortcutsModalCleanup()
+ }
+
+ const modal = document.createElement('div')
+ modal.dataset.emailShortcutsModal = 'true'
+ modal.className = 'shortcut-overlay fixed inset-0 z-50 flex items-center justify-center p-4'
+ modal.innerHTML = `
+
+
+
Keyboard Shortcuts
+
+
+
+
+
+
+
+
+
+
Navigation
+
+
+ Compose new message
+ c
+
+
+ Go to menu
+ g
+
+
+ Back to inbox
+ Esc
+
+
+ View raw email
+ v
+
+
+
+
+
+
Actions
+
+
+ Reply
+ r
+
+
+ Reply all
+ a
+
+
+ Forward
+ f
+
+
+ Reply later
+ l
+
+
+
+
+
+
+
+ Show this help
+ ?
+
+
+
+ `
+
+ document.body.appendChild(modal)
+
+ const escHandler = (e) => {
+ if (e.key === 'Escape') {
+ cleanup()
+ }
+ }
+
+ const cleanup = () => {
+ document.removeEventListener('keydown', escHandler)
+ if (modal.parentNode) {
+ modal.parentNode.removeChild(modal)
+ }
+ if (this.shortcutsModalCleanup === cleanup) {
+ this.shortcutsModalCleanup = null
+ }
+ }
+
+ this.shortcutsModalCleanup = cleanup
+
+ // Close on click outside or close button
+ modal.addEventListener('click', (e) => {
+ if (e.target === modal || e.target.closest('[data-close]')) {
+ cleanup()
+ }
+ })
+
+ // Close on Escape key
+ document.addEventListener('keydown', escHandler)
+ },
+
+ showGotoMenu() {
+ if (this.gotoMenuCleanup) {
+ this.gotoMenuCleanup()
+ }
+
+ // Show a temporary goto menu
+ const menu = document.createElement('div')
+ menu.className = 'shortcut-floating fixed top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 rounded-lg p-6 z-50'
+ menu.innerHTML = `
+ Go to...
+
+
+ gi Inbox
+
+
+ gs Sent
+
+
+ gt Search
+
+
+ ga Archive
+
+
+ gp Spam
+
+
+ Press Escape to close
+ `
+
+ document.body.appendChild(menu)
+
+ const handleGotoKey = (e) => {
+ const key = e.key.toLowerCase()
+
+ if (['escape', 'i', 's', 't', 'a', 'p'].includes(key)) {
+ e.preventDefault()
+ e.stopImmediatePropagation()
+ }
+
+ if (key === 'escape') {
+ cleanup()
+ } else if (key === 'i') {
+ window.location.href = '/email?tab=inbox'
+ cleanup()
+ } else if (key === 's') {
+ window.location.href = '/email?tab=sent'
+ cleanup()
+ } else if (key === 't') {
+ window.location.href = '/email?tab=search'
+ cleanup()
+ } else if (key === 'a') {
+ window.location.href = '/email?tab=archive'
+ cleanup()
+ } else if (key === 'p') {
+ window.location.href = '/email?tab=spam'
+ cleanup()
+ }
+ }
+
+ let timeoutId = null
+ const cleanup = () => {
+ if (timeoutId) {
+ clearTimeout(timeoutId)
+ timeoutId = null
+ }
+ document.removeEventListener('keydown', handleGotoKey, true)
+ if (menu.parentNode) {
+ menu.parentNode.removeChild(menu)
+ }
+ if (this.gotoMenuCleanup === cleanup) {
+ this.gotoMenuCleanup = null
+ }
+ }
+
+ this.gotoMenuCleanup = cleanup
+
+ // Handle goto navigation
+ menu.addEventListener('click', (e) => {
+ const button = e.target.closest('[data-goto]')
+ if (button) {
+ const destination = button.dataset.goto
+ window.location.href = '/email?tab=' + destination
+ cleanup()
+ }
+ })
+
+ document.addEventListener('keydown', handleGotoKey, true)
+
+ // Auto-close after 5 seconds
+ timeoutId = setTimeout(cleanup, 5000)
+ }
+}
diff --git a/apps/elektrine/assets/js/hooks/email_iframe_resize_hook.js b/apps/elektrine/assets/js/hooks/email_iframe_resize_hook.js
new file mode 100644
index 0000000..cc17a96
--- /dev/null
+++ b/apps/elektrine/assets/js/hooks/email_iframe_resize_hook.js
@@ -0,0 +1,126 @@
+// Hook to automatically resize email content iframe based on content height
+
+export const EmailIframeResize = {
+ mounted() {
+ this.iframe = this.el
+ // Initialize maxHeight from current iframe height to prevent shrinking
+ this.maxHeight = this.iframe.offsetHeight || 600
+ this.resizeTimers = []
+ this.imageListeners = []
+ this.mutationObserver = null
+
+ // Resize when iframe loads
+ this.loadHandler = () => {
+ // Resize immediately
+ this.resizeIframe()
+ this.bindContentResizeWatchers()
+
+ // Check again after brief delays to catch images loading
+ this.scheduleResize(100)
+ this.scheduleResize(300)
+ this.scheduleResize(600)
+ this.scheduleResize(1000)
+ this.scheduleResize(2000)
+ }
+
+ this.iframe.addEventListener('load', this.loadHandler)
+ },
+
+ scheduleResize(delay) {
+ const timer = setTimeout(() => this.resizeIframe(), delay)
+ this.resizeTimers.push(timer)
+ },
+
+ bindContentResizeWatchers() {
+ this.cleanupContentResizeWatchers()
+
+ try {
+ const iframeDoc = this.iframe.contentDocument || this.iframe.contentWindow.document
+ if (!iframeDoc) return
+
+ iframeDoc.querySelectorAll('img').forEach((image) => {
+ const listener = () => this.resizeIframe()
+ image.addEventListener('load', listener, { once: true })
+ image.addEventListener('error', listener, { once: true })
+ this.imageListeners.push({ image, listener })
+ })
+
+ if (iframeDoc.fonts && typeof iframeDoc.fonts.ready?.then === 'function') {
+ iframeDoc.fonts.ready.then(() => this.resizeIframe()).catch(() => {})
+ }
+
+ this.mutationObserver = new MutationObserver(() => this.resizeIframe())
+ this.mutationObserver.observe(iframeDoc.documentElement, {
+ childList: true,
+ subtree: true,
+ attributes: true,
+ characterData: true,
+ })
+ } catch (_error) {
+ // Cross-origin restrictions - iframe will use scheduled height checks.
+ }
+ },
+
+ cleanupContentResizeWatchers() {
+ this.imageListeners.forEach(({ image, listener }) => {
+ image.removeEventListener('load', listener)
+ image.removeEventListener('error', listener)
+ })
+ this.imageListeners = []
+
+ if (this.mutationObserver) {
+ this.mutationObserver.disconnect()
+ this.mutationObserver = null
+ }
+ },
+
+ resizeIframe() {
+ try {
+ const iframeDoc = this.iframe.contentDocument || this.iframe.contentWindow.document
+
+ if (iframeDoc && iframeDoc.body) {
+ // Get the full content height including all elements
+ const body = iframeDoc.body
+ const html = iframeDoc.documentElement
+
+ const contentHeight = Math.max(
+ body.scrollHeight,
+ body.offsetHeight,
+ html.clientHeight,
+ html.scrollHeight,
+ html.offsetHeight
+ )
+
+ // Set a minimum height and add some padding
+ const minHeight = 200
+ const newHeight = Math.max(contentHeight, minHeight)
+
+ // Only grow, never shrink (prevents resize-on-scroll issues)
+ if (newHeight > this.maxHeight) {
+ this.maxHeight = newHeight
+ this.iframe.style.height = `${newHeight}px`
+ }
+ }
+ } catch (e) {
+ // Cross-origin restrictions - iframe will use default height
+ }
+ },
+
+ updated() {
+ // When LiveView updates the element, ensure we maintain the max height
+ // Re-apply the height in case LiveView reset it
+ if (this.maxHeight > 0) {
+ this.iframe.style.height = `${this.maxHeight}px`
+ }
+ },
+
+ destroyed() {
+ if (this.loadHandler) {
+ this.iframe.removeEventListener('load', this.loadHandler)
+ }
+
+ this.resizeTimers.forEach((timer) => clearTimeout(timer))
+ this.resizeTimers = []
+ this.cleanupContentResizeWatchers()
+ }
+}
diff --git a/apps/elektrine/assets/js/hooks/email_shortcut_helpers.js b/apps/elektrine/assets/js/hooks/email_shortcut_helpers.js
new file mode 100644
index 0000000..80c7371
--- /dev/null
+++ b/apps/elektrine/assets/js/hooks/email_shortcut_helpers.js
@@ -0,0 +1,37 @@
+export function isEditableTarget(target) {
+ if (!(target instanceof Element)) return false
+
+ if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.tagName === 'SELECT') {
+ return true
+ }
+
+ if (target.contentEditable === 'true' || target.isContentEditable) {
+ return true
+ }
+
+ return Boolean(
+ target.closest(
+ 'input, textarea, select, [contenteditable="true"], [contenteditable=""], [role="textbox"], .ProseMirror, .ql-editor'
+ )
+ )
+}
+
+const SHORTCUT_MODAL_SELECTOR = '[data-email-shortcuts-modal], #keyboard-shortcuts-modal'
+
+export function closeExistingShortcutModals() {
+ document.querySelectorAll(SHORTCUT_MODAL_SELECTOR).forEach((modal) => modal.remove())
+}
+
+export function registerEmailShortcutHelp(owner, callback) {
+ window.activeEmailShortcutHelp = { owner, callback }
+
+ return () => {
+ if (window.activeEmailShortcutHelp?.owner === owner) {
+ delete window.activeEmailShortcutHelp
+ }
+ }
+}
+
+export function isActiveEmailShortcutHelpOwner(owner) {
+ return window.activeEmailShortcutHelp?.owner === owner
+}
diff --git a/apps/elektrine/assets/js/hooks/file_explorer_hook.js b/apps/elektrine/assets/js/hooks/file_explorer_hook.js
new file mode 100644
index 0000000..b26c8bf
--- /dev/null
+++ b/apps/elektrine/assets/js/hooks/file_explorer_hook.js
@@ -0,0 +1,347 @@
+export const FileExplorer = {
+ mounted() {
+ this.searchInput = this.el.querySelector('#file-explorer-search')
+ this.interactiveSelector = 'a, button, input, select, textarea, summary, label, [data-row-ignore]'
+ this.treeLayout = this.el.querySelector('[data-tree-layout]')
+ this.treePane = this.el.querySelector('[data-tree-pane]')
+ this.treeResizer = this.el.querySelector('[data-tree-resizer]')
+ this.activeRowIndex = -1
+ this.anchorRowIndex = null
+ this.draggingTokens = null
+ this.draggingRow = null
+ this.lastDropTarget = null
+
+ this.applyTreeWidth = (width) => {
+ if (!this.treeLayout) return
+ const clamped = Math.max(224, Math.min(width, 480))
+ this.treeLayout.style.setProperty('--files-tree-width', `${clamped}px`)
+ window.localStorage?.setItem('files-tree-width', String(clamped))
+ }
+
+ this.restoreTreeWidth = () => {
+ const savedWidth = window.localStorage?.getItem('files-tree-width')
+ if (!savedWidth) return
+
+ const parsed = Number(savedWidth)
+ if (Number.isFinite(parsed)) this.applyTreeWidth(parsed)
+ }
+
+ this.refreshRows = () => {
+ this.rows = Array.from(this.el.querySelectorAll('[data-row-token]'))
+
+ this.rows.forEach((row, index) => {
+ row.dataset.rowIndex = String(index)
+ row.tabIndex = index === this.activeRowIndex ? 0 : -1
+ })
+
+ if (this.rows.length === 0) {
+ this.activeRowIndex = -1
+ this.anchorRowIndex = null
+ } else if (this.activeRowIndex < 0 || this.activeRowIndex >= this.rows.length) {
+ this.setActiveRow(0, false)
+ }
+ }
+
+ this.setActiveRow = (index, focus = true) => {
+ if (!this.rows || this.rows.length === 0) return
+
+ const boundedIndex = Math.max(0, Math.min(index, this.rows.length - 1))
+ this.activeRowIndex = boundedIndex
+
+ this.rows.forEach((row, rowIndex) => {
+ row.tabIndex = rowIndex === boundedIndex ? 0 : -1
+ })
+
+ if (focus) {
+ this.rows[boundedIndex].focus()
+ }
+ }
+
+ this.setSelection = (tokens) => {
+ this.pushEvent('set_selection', { tokens })
+ }
+
+ this.getSelectedTokens = () => {
+ return this.rows
+ .filter(row => row.dataset.rowSelected === 'true')
+ .map(row => row.dataset.rowToken)
+ }
+
+ this.openRow = (row) => {
+ if (!row || !row.dataset.rowOpenEvent) return
+
+ const payload = {}
+ if (row.dataset.rowOpenPath) payload.path = row.dataset.rowOpenPath
+ if (row.dataset.rowOpenId) payload.id = row.dataset.rowOpenId
+ this.pushEvent(row.dataset.rowOpenEvent, payload)
+ }
+
+ this.selectRangeTo = (targetIndex) => {
+ if (!this.rows || this.rows.length === 0) return
+
+ const anchor = this.anchorRowIndex == null ? this.activeRowIndex : this.anchorRowIndex
+ const start = Math.min(anchor, targetIndex)
+ const end = Math.max(anchor, targetIndex)
+ const tokens = this.rows.slice(start, end + 1).map(row => row.dataset.rowToken)
+ this.setSelection(tokens)
+ }
+
+ this.onClick = (event) => {
+ const row = event.target.closest('[data-row-token]')
+ if (!row || row.contains(event.target.closest(this.interactiveSelector))) return
+
+ const rowIndex = Number(row.dataset.rowIndex || 0)
+ this.setActiveRow(rowIndex)
+
+ if (event.shiftKey) {
+ this.selectRangeTo(rowIndex)
+ } else if (event.metaKey || event.ctrlKey) {
+ this.anchorRowIndex = rowIndex
+ this.pushEvent('toggle_select', { token: row.dataset.rowToken })
+ } else {
+ this.anchorRowIndex = rowIndex
+ this.setSelection([row.dataset.rowToken])
+ }
+ }
+
+ this.onDoubleClick = (event) => {
+ const row = event.target.closest('[data-row-token]')
+ if (!row || row.contains(event.target.closest(this.interactiveSelector))) return
+
+ this.openRow(row)
+ }
+
+ this.clearDropTarget = () => {
+ if (!this.lastDropTarget) return
+ this.lastDropTarget.classList.remove('bg-primary/15', 'ring-1', 'ring-primary/30')
+ this.lastDropTarget = null
+ }
+
+ this.onDragStart = (event) => {
+ const row = event.target.closest('[draggable="true"][data-row-token]')
+ if (!row || event.target.closest(this.interactiveSelector)) {
+ event.preventDefault()
+ return
+ }
+
+ const rowToken = row.dataset.rowToken
+ const selectedTokens = row.dataset.rowSelected === 'true' ? this.getSelectedTokens() : []
+ this.draggingTokens = selectedTokens.length > 0 ? selectedTokens : [rowToken]
+ this.draggingRow = row
+ row.classList.add('opacity-60')
+
+ if (event.dataTransfer) {
+ event.dataTransfer.effectAllowed = 'move'
+ event.dataTransfer.setData('text/plain', this.draggingTokens.join(','))
+ }
+ }
+
+ this.onDragEnd = () => {
+ if (this.draggingRow) this.draggingRow.classList.remove('opacity-60')
+ this.draggingTokens = null
+ this.draggingRow = null
+ this.clearDropTarget()
+ }
+
+ this.onDragOver = (event) => {
+ if (!this.draggingTokens || this.draggingTokens.length === 0) return
+ const target = event.target.closest('[data-tree-folder-drop-path]')
+ if (!target) return
+
+ event.preventDefault()
+ if (this.lastDropTarget && this.lastDropTarget !== target) this.clearDropTarget()
+ this.lastDropTarget = target
+ target.classList.add('bg-primary/15', 'ring-1', 'ring-primary/30')
+ }
+
+ this.onDragLeave = (event) => {
+ const target = event.target.closest('[data-tree-folder-drop-path]')
+ if (!target || target !== this.lastDropTarget) return
+
+ const related = event.relatedTarget
+ if (related instanceof HTMLElement && target.contains(related)) return
+ this.clearDropTarget()
+ }
+
+ this.onDrop = (event) => {
+ if (!this.draggingTokens || this.draggingTokens.length === 0) return
+ const target = event.target.closest('[data-tree-folder-drop-path]')
+ if (!target) return
+
+ event.preventDefault()
+ const folderPath = target.dataset.treeFolderDropPath || ''
+ this.pushEvent('drag_move_items', { tokens: this.draggingTokens, folder: folderPath })
+ this.onDragEnd()
+ }
+
+ this.onResizeStart = (event) => {
+ if (!this.treeLayout || !this.treeResizer) return
+
+ event.preventDefault()
+ const startX = event.clientX
+ const startWidth = this.treePane?.getBoundingClientRect().width || 288
+
+ const onPointerMove = moveEvent => {
+ const nextWidth = startWidth + (moveEvent.clientX - startX)
+ this.applyTreeWidth(nextWidth)
+ }
+
+ const onPointerUp = () => {
+ window.removeEventListener('pointermove', onPointerMove)
+ window.removeEventListener('pointerup', onPointerUp)
+ document.body.classList.remove('select-none', 'cursor-col-resize')
+ }
+
+ document.body.classList.add('select-none', 'cursor-col-resize')
+ window.addEventListener('pointermove', onPointerMove)
+ window.addEventListener('pointerup', onPointerUp)
+ }
+
+ this.onKeyDown = (event) => {
+ const target = event.target
+ const isTypingTarget =
+ target instanceof HTMLElement &&
+ (target.tagName === 'INPUT' ||
+ target.tagName === 'TEXTAREA' ||
+ target.tagName === 'SELECT' ||
+ target.isContentEditable)
+
+ if (event.key === '/' && !isTypingTarget && !event.metaKey && !event.ctrlKey && !event.altKey) {
+ event.preventDefault()
+ this.focusSearch()
+ return
+ }
+
+ if ((event.key.toLowerCase() === 'f' && (event.metaKey || event.ctrlKey)) && !event.altKey) {
+ event.preventDefault()
+ this.focusSearch()
+ return
+ }
+
+ if (event.key.toLowerCase() === 'n' && !isTypingTarget && !event.metaKey && !event.ctrlKey && !event.altKey) {
+ event.preventDefault()
+ this.pushEvent('toggle_new_folder', {})
+ return
+ }
+
+ if (event.key === 'Escape' && !isTypingTarget) {
+ this.pushEvent('cancel_new_folder', {})
+ return
+ }
+
+ if (isTypingTarget || !this.rows || this.rows.length === 0) return
+
+ if (event.key === 'ArrowDown') {
+ event.preventDefault()
+ const nextIndex = this.activeRowIndex < 0 ? 0 : this.activeRowIndex + 1
+
+ if (event.shiftKey) {
+ this.setActiveRow(nextIndex)
+ this.selectRangeTo(this.activeRowIndex)
+ } else {
+ this.anchorRowIndex = Math.max(0, Math.min(nextIndex, this.rows.length - 1))
+ this.setActiveRow(nextIndex)
+ }
+
+ return
+ }
+
+ if (event.key === 'ArrowUp') {
+ event.preventDefault()
+ const nextIndex = this.activeRowIndex < 0 ? 0 : this.activeRowIndex - 1
+
+ if (event.shiftKey) {
+ this.setActiveRow(nextIndex)
+ this.selectRangeTo(this.activeRowIndex)
+ } else {
+ this.anchorRowIndex = Math.max(0, nextIndex)
+ this.setActiveRow(nextIndex)
+ }
+
+ return
+ }
+
+ if (event.key === 'Home') {
+ event.preventDefault()
+ this.anchorRowIndex = 0
+ this.setActiveRow(0)
+ return
+ }
+
+ if (event.key === 'End') {
+ event.preventDefault()
+ this.anchorRowIndex = this.rows.length - 1
+ this.setActiveRow(this.rows.length - 1)
+ return
+ }
+
+ if (event.key === 'Enter') {
+ event.preventDefault()
+ this.openRow(this.rows[this.activeRowIndex])
+ return
+ }
+
+ if (event.key === 'F2') {
+ event.preventDefault()
+ const row = this.rows[this.activeRowIndex]
+ if (!row) return
+
+ if (row.dataset.rowKind === 'folder') {
+ this.pushEvent('open_manage_folder', { path: row.dataset.rowOpenPath })
+ this.pushEvent('start_rename', { type: 'folder', path: row.dataset.rowOpenPath })
+ } else {
+ this.pushEvent('open_manage_file', { id: row.dataset.rowOpenId })
+ this.pushEvent('start_rename', { type: 'file', id: row.dataset.rowOpenId })
+ }
+
+ return
+ }
+
+ if (event.key === ' ') {
+ event.preventDefault()
+ const row = this.rows[this.activeRowIndex]
+ if (row) {
+ this.anchorRowIndex = this.activeRowIndex
+ this.pushEvent('toggle_select', { token: row.dataset.rowToken })
+ }
+ }
+ }
+
+ this.el.addEventListener('click', this.onClick)
+ this.el.addEventListener('dblclick', this.onDoubleClick)
+ this.el.addEventListener('dragstart', this.onDragStart)
+ this.el.addEventListener('dragend', this.onDragEnd)
+ this.el.addEventListener('dragover', this.onDragOver)
+ this.el.addEventListener('dragleave', this.onDragLeave)
+ this.el.addEventListener('drop', this.onDrop)
+ if (this.treeResizer) this.treeResizer.addEventListener('pointerdown', this.onResizeStart)
+ window.addEventListener('keydown', this.onKeyDown)
+ this.restoreTreeWidth()
+ this.refreshRows()
+ },
+
+ updated() {
+ this.searchInput = this.el.querySelector('#file-explorer-search')
+ this.refreshRows()
+ },
+
+ destroyed() {
+ if (this.onClick) this.el.removeEventListener('click', this.onClick)
+ if (this.onDoubleClick) this.el.removeEventListener('dblclick', this.onDoubleClick)
+ if (this.onDragStart) this.el.removeEventListener('dragstart', this.onDragStart)
+ if (this.onDragEnd) this.el.removeEventListener('dragend', this.onDragEnd)
+ if (this.onDragOver) this.el.removeEventListener('dragover', this.onDragOver)
+ if (this.onDragLeave) this.el.removeEventListener('dragleave', this.onDragLeave)
+ if (this.onDrop) this.el.removeEventListener('drop', this.onDrop)
+ if (this.treeResizer && this.onResizeStart) this.treeResizer.removeEventListener('pointerdown', this.onResizeStart)
+ if (this.onKeyDown) window.removeEventListener('keydown', this.onKeyDown)
+ },
+
+ focusSearch() {
+ if (!this.searchInput) return
+
+ this.searchInput.focus()
+ const length = this.searchInput.value.length
+ this.searchInput.setSelectionRange(length, length)
+ }
+}
diff --git a/apps/elektrine/assets/js/hooks/form_hooks.js b/apps/elektrine/assets/js/hooks/form_hooks.js
new file mode 100644
index 0000000..a13bf37
--- /dev/null
+++ b/apps/elektrine/assets/js/hooks/form_hooks.js
@@ -0,0 +1,488 @@
+/**
+ * Form Hooks
+ * Hooks for form inputs, tag inputs, and autocomplete functionality.
+ */
+import { spinnerSvg } from '../utils/spinner'
+
+const ATOMINE_MIN_DIFFICULTY = 0
+const ATOMINE_MAX_DIFFICULTY = 30
+const ATOMINE_PROGRESS_INTERVAL = 1000
+
+/**
+ * FormSubmit Hook
+ * Shows loading spinner on form submit buttons when the form is submitted.
+ * Attach to forms with regular (non-LiveView) submissions.
+ */
+export const FormSubmit = {
+ mounted() {
+ this.submitResetTimer = null
+ this.onSubmit = (e) => {
+ if (e.defaultPrevented) return
+
+ const submitBtn = this.el.querySelector('button[type="submit"], button:not([type])')
+ if (submitBtn) {
+ // Store original content
+ const originalContent = submitBtn.innerHTML
+
+ // Get loading text from data attribute or use original
+ const loadingText = submitBtn.dataset.loadingText || submitBtn.textContent.trim()
+
+ // Replace with spinner and loading text without injecting text as HTML.
+ submitBtn.innerHTML = spinnerSvg()
+ const loadingTextElement = document.createElement('span')
+ loadingTextElement.textContent = loadingText
+ submitBtn.appendChild(loadingTextElement)
+ submitBtn.disabled = true
+ submitBtn.classList.add('pointer-events-none')
+
+ // Restore after timeout (in case of network issues)
+ this.submitResetTimer = setTimeout(() => {
+ submitBtn.innerHTML = originalContent
+ submitBtn.disabled = false
+ submitBtn.classList.remove('pointer-events-none')
+ this.submitResetTimer = null
+ }, 30000)
+ }
+ }
+
+ this.el.addEventListener('submit', this.onSubmit)
+ },
+
+ destroyed() {
+ if (this.onSubmit) this.el.removeEventListener('submit', this.onSubmit)
+ if (this.submitResetTimer) clearTimeout(this.submitResetTimer)
+ }
+}
+
+/**
+ * Tag Input Hook
+ * Sends input value to LiveView on change
+ */
+export const TagInputHook = {
+ mounted() {
+ this.handleEvent("clear-tag-input", ({ field }) => {
+ if (field === this.el.dataset.field) {
+ this.el.value = ''
+ }
+ })
+
+ this.onInput = (e) => {
+ this.pushEvent("update_tag_input", {
+ field: this.el.dataset.field,
+ value: e.target.value
+ })
+ }
+
+ this.el.addEventListener('input', this.onInput)
+ },
+
+ destroyed() {
+ if (this.onInput) this.el.removeEventListener('input', this.onInput)
+ }
+}
+
+/**
+ * Suggestion Dropdown Hook
+ * Handles mousedown events on suggestions (fires before blur)
+ */
+export const SuggestionDropdown = {
+ mounted() {
+ this.onMouseDown = (e) => {
+ const suggestionEl = e.target.closest('[data-suggestion-email]')
+ if (suggestionEl) {
+ const email = suggestionEl.dataset.suggestionEmail
+ const field = suggestionEl.dataset.suggestionField
+
+ const inputId = field + '-tag-input'
+ const inputEl = document.getElementById(inputId)
+ if (inputEl) inputEl.value = ''
+
+ this.pushEvent("select_suggestion", { field, email })
+ e.preventDefault()
+ }
+ }
+
+ this.el.addEventListener('mousedown', this.onMouseDown)
+ },
+
+ destroyed() {
+ if (this.onMouseDown) this.el.removeEventListener('mousedown', this.onMouseDown)
+ }
+}
+
+export function detectAndSaveTimezone(element = null) {
+ const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone
+ document.cookie = `detected_timezone=${timezone}; path=/; max-age=31536000; SameSite=Lax`
+
+ if (element) {
+ element.dataset.lastSent = timezone
+ }
+}
+
+export function initTimezoneDetectors(rootCandidate = document) {
+ const root =
+ rootCandidate && typeof rootCandidate.querySelectorAll === 'function' ? rootCandidate : document
+
+ root.querySelectorAll('[data-timezone-detector]').forEach((element) => {
+ detectAndSaveTimezone(element)
+ })
+}
+
+/**
+ * VPN Download Hook
+ * Handles downloading WireGuard configuration files
+ */
+export const VPNDownload = {
+ mounted() {
+ this.handleEvent("download_config", ({ filename, content }) => {
+ const blob = new Blob([content], { type: 'text/plain' })
+ const url = URL.createObjectURL(blob)
+ const a = document.createElement('a')
+ a.href = url
+ a.download = filename
+ document.body.appendChild(a)
+ a.click()
+ document.body.removeChild(a)
+ URL.revokeObjectURL(url)
+
+ if (window.showNotification) {
+ window.showNotification('VPN configuration downloaded', 'info', 'Success!')
+ }
+ })
+ }
+}
+
+/**
+ * Security check hook
+ * Solves configured anti-abuse layers before submitting.
+ */
+const POW_STATUS_CLASSES = {
+ progress: 'text-base-content/60',
+ success: 'text-success',
+ error: 'text-error'
+}
+
+export const AtominePow = {
+ mounted() {
+ this.form = this.el.closest('form') || document.getElementById('register-form')
+ this.statusEl = this.el.querySelector('[data-atomine-pow-status]')
+ this.difficulty = normalizeDifficulty(this.el.dataset.difficulty)
+ this.inFlight = null
+ this.submittingWithToken = false
+ this.handleSubmit = (event) => this.onSubmit(event)
+
+ if (this.form) {
+ this.resetSubmissionState()
+ this.form.addEventListener('submit', this.handleSubmit, true)
+ }
+ },
+
+ async onSubmit(event) {
+ if (!this.form) return
+
+ if (this.form?.dataset.atominePowSkip === 'true') {
+ delete this.form.dataset.atominePowSkip
+ this.submittingWithToken = false
+ return
+ }
+
+ event.preventDefault()
+ event.stopImmediatePropagation()
+
+ if (this.submittingWithToken) return
+
+ try {
+ await this.ensureToken()
+ this.submittingWithToken = true
+ this.form.dataset.atominePowSkip = 'true'
+ this.requestSubmit(event.submitter)
+ } catch (_error) {
+ this.setResponseToken('')
+ this.setStatus('Security check failed. Please try again.', 'error')
+ }
+ },
+
+ updated() {
+ this.statusEl = this.el.querySelector('[data-atomine-pow-status]')
+ if (!this.inFlight) this.resetSubmissionState()
+ },
+
+ resetSubmissionState() {
+ this.submittingWithToken = false
+ if (this.form) delete this.form.dataset.atominePowSkip
+ this.setResponseToken('')
+ },
+
+ ensureToken() {
+ if (!this.inFlight) {
+ this.inFlight = this.solveAndIssueToken().finally(() => {
+ this.inFlight = null
+ })
+ }
+
+ return this.inFlight
+ },
+
+ responseInput() {
+ return this.form?.querySelector('input[name="atomine_pow_token"]')
+ },
+
+ responseInputs() {
+ return Array.from(this.form?.querySelectorAll('input[name="atomine_pow_token"]') || [])
+ },
+
+ responseToken() {
+ return this.responseInputs().find((input) => input.value)?.value || ''
+ },
+
+ setResponseToken(token) {
+ this.responseInputs().forEach((input) => {
+ input.value = token
+ })
+ },
+
+ ensureResponseInput(form) {
+ let input = form.querySelector('input[name="atomine_pow_token"]')
+
+ if (!input) {
+ input = document.createElement('input')
+ input.type = 'hidden'
+ input.name = 'atomine_pow_token'
+ form.appendChild(input)
+ }
+
+ input.id = 'atomine-pow-token'
+ return input
+ },
+
+ async solveAndIssueToken() {
+ if (!window.crypto?.subtle) {
+ throw new Error('Web Crypto is unavailable')
+ }
+
+ this.ensureResponseInput(this.form)
+ this.setStatus('Preparing the security check...')
+
+ const challengeResponse = await postJson('/api/atomine/pow/challenge', {
+ difficulty: this.difficulty
+ })
+
+ const challenge = challengeResponse.challenge
+ const difficulty = normalizeDifficulty(challengeResponse.difficulty ?? this.difficulty)
+ this.setStatus('Checking this browser...')
+ const gateProof = await collectGateProof(challenge)
+
+ this.setStatus('Running a short work check...')
+
+ const solution = await solvePow(challenge, difficulty, (attempts) => {
+ this.setStatus(`Still working... ${attempts.toLocaleString()} attempts tried`)
+ })
+
+ this.setStatus('Finishing the check...')
+
+ const tokenResponse = await postJson('/api/atomine/anonymous-tokens', {
+ challenge,
+ solution,
+ gate_proof: gateProof
+ })
+
+ if (!tokenResponse.token) {
+ throw new Error('Missing Atomine token')
+ }
+
+ this.setResponseToken(tokenResponse.token)
+ this.setStatus('Check complete. Submitting...', 'success')
+ },
+
+ requestSubmit(submitter) {
+ if (typeof this.form.requestSubmit === 'function') {
+ this.form.requestSubmit(submitter || undefined)
+ } else {
+ this.form.submit()
+ }
+ },
+
+ setStatus(message, state = 'progress') {
+ if (!this.statusEl) return
+ this.statusEl.textContent = message
+ this.statusEl.classList.remove(...Object.values(POW_STATUS_CLASSES))
+ this.statusEl.classList.add(POW_STATUS_CLASSES[state] || POW_STATUS_CLASSES.progress)
+ },
+
+ destroyed() {
+ if (this.form && this.handleSubmit) {
+ this.form.removeEventListener('submit', this.handleSubmit, true)
+ }
+ }
+}
+
+async function postJson(url, body) {
+ const response = await fetch(url, {
+ method: 'POST',
+ headers: { 'content-type': 'application/json' },
+ body: JSON.stringify(body)
+ })
+
+ const json = await response.json().catch(() => ({}))
+ if (!response.ok) {
+ throw new Error(json.error || 'request failed')
+ }
+
+ return json
+}
+
+async function collectGateProof(challenge) {
+ const checks = []
+
+ checks.push(measureBrowserCheck('layout.getComputedStyle', () => {
+ const probe = document.createElement('div')
+ probe.style.cssText = 'position:absolute;left:-9999px;width:37px;height:11px;padding:3px;display:block;'
+ document.body.appendChild(probe)
+ const style = window.getComputedStyle(probe)
+ const ok = style.display === 'block' && style.width === '37px' && style.paddingLeft === '3px'
+ probe.remove()
+ return { ok }
+ }))
+
+ checks.push(measureBrowserCheck('canvas.toDataURL', () => {
+ const canvas = document.createElement('canvas')
+ canvas.width = 16
+ canvas.height = 16
+ const ctx = canvas.getContext('2d')
+ if (!ctx) return { ok: false }
+ ctx.fillStyle = '#1f6feb'
+ ctx.fillRect(0, 0, 16, 16)
+ ctx.fillStyle = '#ffffff'
+ ctx.fillText('A', 3, 12)
+ const data = canvas.toDataURL('image/png')
+ return { ok: data.startsWith('data:image/png;base64,'), bytes: data.length }
+ }))
+
+ checks.push(measureBrowserCheck('event.isTrusted', () => {
+ let syntheticTrusted = null
+ const button = document.createElement('button')
+ button.type = 'button'
+ button.addEventListener('click', (event) => {
+ syntheticTrusted = event.isTrusted
+ }, { once: true })
+ button.dispatchEvent(new MouseEvent('click', { bubbles: true }))
+ return { ok: syntheticTrusted === false, synthetic_trusted: syntheticTrusted }
+ }))
+
+ checks.push(measureBrowserCheck('navigator.webdriver', () => {
+ const webdriver = navigator.webdriver === true
+ return { ok: !webdriver, webdriver }
+ }))
+
+ checks.push(measureBrowserCheck('dom.querySelector', () => {
+ const id = `atomine-gate-${Math.random().toString(36).slice(2)}`
+ const probe = document.createElement('span')
+ probe.id = id
+ probe.dataset.atomineBrowserProof = 'true'
+ document.body.appendChild(probe)
+ const ok = document.querySelector(`#${id}`)?.dataset.atomineBrowserProof === 'true'
+ probe.remove()
+ return { ok }
+ }))
+
+ if (checks.some((check) => !check.ok)) {
+ throw new Error('browser instrumentation failed')
+ }
+
+ return {
+ version: 'atomine-gate-v1',
+ layers: ['pow', 'browser_instrumentation'],
+ browser_instrumentation: {
+ challenge_hash: await sha256Base64Url(challenge),
+ checks,
+ signals: {
+ user_agent_hash: await sha256Base64Url(navigator.userAgent || ''),
+ languages: Array.isArray(navigator.languages) ? navigator.languages.slice(0, 5) : [],
+ hardware_concurrency: navigator.hardwareConcurrency || null,
+ device_memory: navigator.deviceMemory || null,
+ timezone: Intl.DateTimeFormat().resolvedOptions().timeZone || null
+ }
+ }
+ }
+}
+
+function measureBrowserCheck(name, fn) {
+ const startedAt = performance.now()
+
+ try {
+ const result = fn() || {}
+ return {
+ name,
+ ok: result.ok === true,
+ duration_ms: Math.max(0, Math.round(performance.now() - startedAt)),
+ ...result
+ }
+ } catch (error) {
+ return {
+ name,
+ ok: false,
+ duration_ms: Math.max(0, Math.round(performance.now() - startedAt)),
+ error: error?.name || 'Error'
+ }
+ }
+}
+
+async function sha256Base64Url(value) {
+ const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(value))
+ const bytes = new Uint8Array(digest)
+ let binary = ''
+
+ for (const byte of bytes) {
+ binary += String.fromCharCode(byte)
+ }
+
+ return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '')
+}
+
+async function solvePow(challenge, difficulty, onProgress) {
+ const encoder = new TextEncoder()
+ let nonce = 0
+
+ while (true) {
+ const solution = String(nonce)
+ const digest = await crypto.subtle.digest('SHA-256', encoder.encode(`${challenge}:${solution}`))
+
+ if (leadingZeroBits(new Uint8Array(digest)) >= difficulty) {
+ return solution
+ }
+
+ nonce += 1
+
+ if (nonce % ATOMINE_PROGRESS_INTERVAL === 0) {
+ if (onProgress) onProgress(nonce)
+ await new Promise((resolve) => setTimeout(resolve, 0))
+ }
+ }
+}
+
+function normalizeDifficulty(value) {
+ const parsed = Number.parseInt(value ?? '18', 10)
+ if (Number.isNaN(parsed)) return 18
+ return Math.min(ATOMINE_MAX_DIFFICULTY, Math.max(ATOMINE_MIN_DIFFICULTY, parsed))
+}
+
+function leadingZeroBits(bytes) {
+ let total = 0
+
+ for (const byte of bytes) {
+ if (byte === 0) {
+ total += 8
+ continue
+ }
+
+ for (let bit = 7; bit >= 0; bit -= 1) {
+ if ((byte & (1 << bit)) === 0) {
+ total += 1
+ } else {
+ return total
+ }
+ }
+ }
+
+ return total
+}
diff --git a/apps/elektrine/assets/js/hooks/index.js b/apps/elektrine/assets/js/hooks/index.js
new file mode 100644
index 0000000..1b4aab5
--- /dev/null
+++ b/apps/elektrine/assets/js/hooks/index.js
@@ -0,0 +1,211 @@
+/**
+ * Central export for all LiveView hooks
+ * Organized by feature area for maintainability.
+ */
+
+// Chat hooks
+import {
+ AutoExpandTextarea,
+ SimpleChatInput,
+ MessageList,
+ ChatKeyboardShortcuts,
+} from "./chat_hooks";
+import { ChatE2EE } from "./chat_e2ee_hook";
+import {
+ ContextMenu,
+ MessageContextMenu,
+ MessageMenuButton,
+ CopyChatMessage,
+} from "./chat_context_menu_hooks";
+import { VoiceRecorder } from "./chat_voice_recorder_hook";
+
+// Email hooks
+import {
+ KeyboardShortcuts,
+ EmailShowKeyboardShortcuts,
+} from "./email_hooks";
+import { EmailComposeKeyboardShortcuts } from "./email_compose_shortcuts_hook";
+import { EmailIframeResize } from "./email_iframe_resize_hook";
+
+// Markdown hooks
+import { ReplyMarkdownEditor } from "./markdown_hooks";
+
+// Notification hooks
+import { NotificationHandler } from "./notification_hooks";
+import { NotificationVisibility } from "./notification_visibility";
+import { WebPushManager } from "./web_push_hook";
+
+// UI hooks
+import {
+ PreserveFocus,
+ PreserveSearchFocus,
+ FlashAutoDismiss,
+ TimelineReply,
+ ScrollToTop,
+ RemoteProfileStickyFollow,
+ ImageFallback,
+} from "./ui_hooks";
+import { FileExplorer } from "./file_explorer_hook";
+import { PaigeSearch } from "./paige_search_hook";
+
+// Call hooks
+import {
+ CallInitiator,
+ CallReceiver,
+ CallControls,
+ VideoDisplay,
+ CallTimer,
+} from "./call_hooks";
+import { VoiceChannel } from "./voice_channel_hook";
+
+// Profile hooks
+import {
+ TypewriterHook,
+ TabTitleTypewriter,
+ VideoBackground,
+ StatusSelector,
+} from "./profile_hooks";
+import { ProofGraph } from "./proof_graph_hook";
+import { UPlotChart } from "./analytics_hooks";
+
+// Timeline/Feed hooks
+import {
+ PostClick,
+ ManualPagination,
+} from "./timeline_hooks";
+import { UserHoverCard, ImageModal } from "./timeline_media_hooks";
+import {
+ PreserveStreamAnchor,
+ PreserveQueuedPostsButtonScroll,
+} from "./timeline_preservation_hooks";
+import { SessionContinuity } from "./timeline_session_continuity";
+import { AnimatedCount, RemoteFollowButton } from "./timeline_status_hooks";
+
+// Form/Utility hooks
+import {
+ FormSubmit,
+ TagInputHook,
+ SuggestionDropdown,
+ VPNDownload,
+ AtominePow,
+} from "./form_hooks";
+
+// Password manager hooks
+import { Nerve } from "./nerve_hooks";
+
+// Account-password encrypted data vault
+import { VaultManager } from "./vault_hooks";
+import { KairoVault } from "./kairo_hooks";
+import { KairoGraph } from "./kairo_graph_hook";
+
+// Mailbox private storage hooks
+import {
+ MailboxPrivateStorage,
+} from "./mailbox_private_storage_hooks";
+import { PrivateMailboxCompose } from "./mailbox_private_compose_hook";
+import { PrivateMailboxMessages } from "./mailbox_private_messages_hook";
+
+// Passkey hooks
+import { PasskeyRegister, PasskeyAuth } from "./passkey_hooks";
+
+// Presence hooks
+import { ActivityTracker } from "./presence_hooks";
+
+// Static site and profile-builder hooks
+import { DragDrop, CodeEditor } from "./static_site_hooks";
+import { ProfileLinkReorder } from "./profile_link_reorder_hook";
+
+// Export all hooks as a single object
+export const Hooks = {
+ // Chat
+ AutoExpandTextarea,
+ SimpleChatInput,
+ MessageList,
+ ContextMenu,
+ MessageContextMenu,
+ MessageMenuButton,
+ ChatKeyboardShortcuts,
+ CopyChatMessage,
+ VoiceRecorder,
+ ChatE2EE,
+
+ // Email
+ KeyboardShortcuts,
+ EmailIframeResize,
+ EmailShowKeyboardShortcuts,
+ EmailComposeKeyboardShortcuts,
+
+ // Markdown
+ ReplyMarkdownEditor,
+
+ // Notifications
+ NotificationHandler,
+ NotificationVisibility,
+ WebPushManager,
+
+ // UI
+ PreserveFocus,
+ PreserveSearchFocus,
+ FlashAutoDismiss,
+ FileExplorer,
+ PaigeSearch,
+ TimelineReply,
+ ScrollToTop,
+ RemoteProfileStickyFollow,
+ ImageFallback,
+
+ // Calls
+ CallInitiator,
+ CallReceiver,
+ CallControls,
+ VideoDisplay,
+ CallTimer,
+ VoiceChannel,
+
+ // Profile
+ TypewriterHook,
+ TabTitleTypewriter,
+ VideoBackground,
+ StatusSelector,
+ ProofGraph,
+ UPlotChart,
+
+ // Timeline/Feed
+ PostClick,
+ AnimatedCount,
+ RemoteFollowButton,
+ ManualPagination,
+ PreserveStreamAnchor,
+ PreserveQueuedPostsButtonScroll,
+ UserHoverCard,
+ ImageModal,
+ SessionContinuity,
+
+ // Form/Utility
+ FormSubmit,
+ TagInputHook,
+ SuggestionDropdown,
+ VPNDownload,
+ AtominePow,
+ Nerve,
+ VaultManager,
+ KairoVault,
+ KairoGraph,
+ MailboxPrivateStorage,
+ PrivateMailboxCompose,
+ PrivateMailboxMessages,
+
+ // Static Site
+ DragDrop,
+ CodeEditor,
+ ProfileLinkReorder,
+
+ // Presence
+ ActivityTracker,
+
+ // Passkey
+ PasskeyRegister,
+ PasskeyAuth,
+
+ // (intentionally no product-specific hooks here)
+};
diff --git a/apps/elektrine/assets/js/hooks/kairo_graph_hook.js b/apps/elektrine/assets/js/hooks/kairo_graph_hook.js
new file mode 100644
index 0000000..2b4a855
--- /dev/null
+++ b/apps/elektrine/assets/js/hooks/kairo_graph_hook.js
@@ -0,0 +1,442 @@
+/**
+ * KairoGraph: a force-directed graph of Kairo sources.
+ *
+ * The LiveView serves a `data-graph` payload of `{nodes, edges}` where each node
+ * is a source (file) and an edge connects two sources that share a tag (edge
+ * `weight` = number of shared tags). Layout is a Fruchterman-Reingold force
+ * simulation whose per-frame displacement is capped by a cooling "temperature",
+ * so it converges quickly and then stops.
+ *
+ * Rendering is done on a : the whole scene is cleared and repainted each
+ * frame, which avoids the repaint trails and per-element cost of animating
+ * hundreds of SVG nodes. Click a node to open that source; drag to reposition,
+ * scroll to zoom, drag the background to pan.
+ */
+
+const GRAVITY = 0.04 // pull toward center each frame
+const COOLING = 0.95 // temperature decay per frame
+const MIN_TEMP = 0.4 // below this the simulation is considered settled
+const DRAG_THRESHOLD = 4 // px before a press counts as a drag, not a click
+
+const EDGE_COLOR = "148, 163, 184" // slate-400 as "r, g, b"
+
+function clamp(value, min, max) {
+ return Math.min(max, Math.max(min, value))
+}
+
+function parsePayload(el) {
+ const raw = el.dataset.graph
+ if (!raw) return null
+ try {
+ const parsed = JSON.parse(raw)
+ if (!parsed || !Array.isArray(parsed.nodes)) return null
+ return parsed
+ } catch (_error) {
+ return null
+ }
+}
+
+export const KairoGraph = {
+ mounted() {
+ this.lastPayload = null
+ this.loop = this.loop.bind(this)
+ this.frame = null
+ this.drawFrame = null
+ this.render()
+ },
+
+ updated() {
+ if ((this.el.dataset.graph || "") !== this.lastPayload) this.render()
+ },
+
+ destroyed() {
+ this.teardown()
+ },
+
+ render() {
+ const graph = parsePayload(this.el)
+ this.teardown()
+ this.lastPayload = this.el.dataset.graph || ""
+
+ this.dpr = window.devicePixelRatio || 1
+ this.labelColor = getComputedStyle(this.el).color || "#64748b"
+
+ this.canvas = document.createElement("canvas")
+ this.canvas.style.display = "block"
+ this.canvas.style.width = "100%"
+ this.canvas.style.height = "100%"
+ this.canvas.style.cursor = "grab"
+ this.canvas.style.touchAction = "none"
+ this.el.appendChild(this.canvas)
+ this.ctx = this.canvas.getContext("2d")
+
+ this.scale = 1
+ this.tx = 0
+ this.ty = 0
+ this.hovered = null
+ this.running = false
+ this.resize()
+
+ if (!graph || graph.nodes.length === 0) {
+ this.nodes = []
+ this.edges = []
+ this.drawEmpty()
+ this.bindEvents()
+ return
+ }
+
+ this.buildScene(graph)
+ this.bindEvents()
+ this.kick()
+ },
+
+ resize() {
+ this.width = Math.max(this.el.clientWidth, 320)
+ this.height = Math.max(this.el.clientHeight, 360)
+ this.canvas.width = Math.round(this.width * this.dpr)
+ this.canvas.height = Math.round(this.height * this.dpr)
+ },
+
+ buildScene(graph) {
+ const cx = this.width / 2
+ const cy = this.height / 2
+ const ring = Math.min(this.width, this.height) * 0.34
+ const count = graph.nodes.length
+
+ this.k = Math.sqrt((this.width * this.height) / Math.max(count, 1)) * 0.55
+ this.temp = Math.min(this.width, this.height) * 0.12
+
+ this.nodes = graph.nodes.map((node, index) => {
+ const angle = (index / count) * Math.PI * 2
+ const jitter = ((index * 37) % 40) - 20
+ return {
+ ...node,
+ degree: 0,
+ x: cx + Math.cos(angle) * (ring + jitter),
+ y: cy + Math.sin(angle) * (ring + jitter),
+ dx: 0,
+ dy: 0
+ }
+ })
+
+ this.nodeById = new Map(this.nodes.map((node) => [node.id, node]))
+ this.edges = (graph.edges || [])
+ .map((edge) => ({
+ weight: edge.weight || 1,
+ source: this.nodeById.get(edge.source),
+ target: this.nodeById.get(edge.target)
+ }))
+ .filter((edge) => edge.source && edge.target)
+
+ this.neighbors = new Map(this.nodes.map((node) => [node.id, new Set([node.id])]))
+ this.edges.forEach((edge) => {
+ edge.source.degree += 1
+ edge.target.degree += 1
+ this.neighbors.get(edge.source.id).add(edge.target.id)
+ this.neighbors.get(edge.target.id).add(edge.source.id)
+ })
+
+ this.nodes.forEach((node) => {
+ node.r = 5 + Math.sqrt(node.degree) * 1.6
+ })
+
+ this.showAllLabels = count <= 45
+ },
+
+ bindEvents() {
+ this.onPointerDown = (event) => {
+ this.canvas.setPointerCapture(event.pointerId)
+ const node = this.nodeAt(event)
+ if (node) {
+ this.dragNode = node
+ this.dragMoved = false
+ this.pointerStart = { x: event.clientX, y: event.clientY }
+ } else {
+ this.panning = true
+ this.panStart = { x: event.clientX, y: event.clientY }
+ this.canvas.style.cursor = "grabbing"
+ }
+ }
+
+ this.onPointerMove = (event) => {
+ if (this.dragNode) {
+ if (
+ Math.abs(event.clientX - this.pointerStart.x) > DRAG_THRESHOLD ||
+ Math.abs(event.clientY - this.pointerStart.y) > DRAG_THRESHOLD
+ ) {
+ this.dragMoved = true
+ }
+ const p = this.toLogical(event)
+ this.dragNode.x = p.x
+ this.dragNode.y = p.y
+ this.kick()
+ } else if (this.panning) {
+ this.tx += event.clientX - this.panStart.x
+ this.ty += event.clientY - this.panStart.y
+ this.panStart = { x: event.clientX, y: event.clientY }
+ this.requestDraw()
+ } else {
+ const node = this.nodeAt(event)
+ const id = node ? node.id : null
+ this.canvas.style.cursor = node ? "pointer" : "grab"
+ if (id !== this.hovered) {
+ this.hovered = id
+ this.requestDraw()
+ }
+ }
+ }
+
+ this.onPointerUp = (event) => {
+ if (this.canvas.hasPointerCapture(event.pointerId)) {
+ this.canvas.releasePointerCapture(event.pointerId)
+ }
+ if (this.dragNode && !this.dragMoved && this.dragNode.ref != null) {
+ this.pushEvent("select_source", { id: String(this.dragNode.ref) })
+ }
+ this.dragNode = null
+ this.panning = false
+ this.canvas.style.cursor = "grab"
+ }
+
+ this.onPointerCancel = (event) => {
+ if (this.canvas.hasPointerCapture(event.pointerId)) {
+ this.canvas.releasePointerCapture(event.pointerId)
+ }
+ this.dragNode = null
+ this.panning = false
+ this.canvas.style.cursor = "grab"
+ }
+
+ this.onWheel = (event) => {
+ event.preventDefault()
+ const rect = this.canvas.getBoundingClientRect()
+ const px = event.clientX - rect.left
+ const py = event.clientY - rect.top
+ const factor = event.deltaY < 0 ? 1.1 : 1 / 1.1
+ const next = clamp(this.scale * factor, 0.25, 4)
+ this.tx = px - ((px - this.tx) * next) / this.scale
+ this.ty = py - ((py - this.ty) * next) / this.scale
+ this.scale = next
+ this.requestDraw()
+ }
+
+ this.canvas.addEventListener("pointerdown", this.onPointerDown)
+ this.canvas.addEventListener("pointermove", this.onPointerMove)
+ this.canvas.addEventListener("pointerup", this.onPointerUp)
+ this.canvas.addEventListener("pointercancel", this.onPointerCancel)
+ this.canvas.addEventListener("wheel", this.onWheel, { passive: false })
+
+ this.resizeObserver = new ResizeObserver(() => {
+ const w = Math.max(this.el.clientWidth, 320)
+ const h = Math.max(this.el.clientHeight, 360)
+ if (w === this.width && h === this.height) return
+ this.resize()
+ this.kick()
+ })
+ this.resizeObserver.observe(this.el)
+ },
+
+ toLogical(event) {
+ const rect = this.canvas.getBoundingClientRect()
+ return {
+ x: (event.clientX - rect.left - this.tx) / this.scale,
+ y: (event.clientY - rect.top - this.ty) / this.scale
+ }
+ },
+
+ nodeAt(event) {
+ const p = this.toLogical(event)
+ let best = null
+ let bestDist = Infinity
+ this.nodes.forEach((node) => {
+ const d = Math.hypot(node.x - p.x, node.y - p.y)
+ const hit = node.r + 4 / this.scale
+ if (d <= hit && d < bestDist) {
+ best = node
+ bestDist = d
+ }
+ })
+ return best
+ },
+
+ kick() {
+ this.temp = Math.max(this.temp, Math.min(this.width, this.height) * 0.06)
+ if (!this.running) {
+ this.running = true
+ this.frame = window.requestAnimationFrame(this.loop)
+ }
+ },
+
+ // One-off repaint when the simulation is settled (hover, pan, zoom).
+ requestDraw() {
+ if (this.running || this.drawFrame !== null) return
+ this.drawFrame = window.requestAnimationFrame(() => {
+ this.drawFrame = null
+ if (!this.running) this.draw()
+ })
+ },
+
+ loop() {
+ this.frame = null
+ if (!this.ctx) return
+ this.simulate()
+ this.draw()
+ this.temp *= COOLING
+ if (this.temp > MIN_TEMP || this.dragNode) {
+ this.frame = window.requestAnimationFrame(this.loop)
+ } else {
+ this.running = false
+ this.draw() // final frame with labels
+ }
+ },
+
+ simulate() {
+ const nodes = this.nodes
+ const k = this.k
+ const cx = this.width / 2
+ const cy = this.height / 2
+
+ nodes.forEach((node) => {
+ node.dx = 0
+ node.dy = 0
+ })
+
+ for (let i = 0; i < nodes.length; i += 1) {
+ for (let j = i + 1; j < nodes.length; j += 1) {
+ const a = nodes[i]
+ const b = nodes[j]
+ let dx = a.x - b.x
+ let dy = a.y - b.y
+ let dist = Math.hypot(dx, dy)
+ if (dist < 0.5) {
+ dx = ((i % 7) - 3) * 0.1 + 0.05
+ dy = ((j % 7) - 3) * 0.1 + 0.05
+ dist = Math.hypot(dx, dy)
+ }
+ const rep = (k * k) / dist
+ const fx = (dx / dist) * rep
+ const fy = (dy / dist) * rep
+ a.dx += fx
+ a.dy += fy
+ b.dx -= fx
+ b.dy -= fy
+ }
+ }
+
+ this.edges.forEach((edge) => {
+ const a = edge.source
+ const b = edge.target
+ const dx = a.x - b.x
+ const dy = a.y - b.y
+ const dist = Math.hypot(dx, dy) || 0.5
+ const att = (dist * dist) / k
+ const fx = (dx / dist) * att
+ const fy = (dy / dist) * att
+ a.dx -= fx
+ a.dy -= fy
+ b.dx += fx
+ b.dy += fy
+ })
+
+ nodes.forEach((node) => {
+ node.dx += (cx - node.x) * GRAVITY
+ node.dy += (cy - node.y) * GRAVITY
+ })
+
+ // Integrate, capping per-frame movement by temperature, and keep nodes
+ // inside the viewport so the area-tuned layout fills the frame at scale 1
+ // instead of expanding off-screen.
+ const m = 24
+ nodes.forEach((node) => {
+ if (this.dragNode === node) return
+ const disp = Math.hypot(node.dx, node.dy) || 1
+ const limit = Math.min(disp, this.temp)
+ node.x = clamp(node.x + (node.dx / disp) * limit, m, this.width - m)
+ node.y = clamp(node.y + (node.dy / disp) * limit, m, this.height - m)
+ })
+ },
+
+ draw() {
+ const ctx = this.ctx
+ if (!ctx) return
+
+ ctx.setTransform(this.dpr, 0, 0, this.dpr, 0, 0)
+ ctx.clearRect(0, 0, this.width, this.height)
+ ctx.translate(this.tx, this.ty)
+ ctx.scale(this.scale, this.scale)
+
+ const active = this.hovered
+ const lit = active ? this.neighbors.get(active) : null
+
+ // Edges.
+ ctx.lineCap = "round"
+ this.edges.forEach((edge) => {
+ const on = !active || edge.source.id === active || edge.target.id === active
+ const alpha = active ? (on ? 0.7 : 0.04) : 0.22
+ ctx.strokeStyle = `rgba(${EDGE_COLOR}, ${alpha})`
+ ctx.lineWidth = clamp(edge.weight, 1, 4)
+ ctx.beginPath()
+ ctx.moveTo(edge.source.x, edge.source.y)
+ ctx.lineTo(edge.target.x, edge.target.y)
+ ctx.stroke()
+ })
+
+ // Nodes.
+ this.nodes.forEach((node) => {
+ const on = !active || lit.has(node.id)
+ ctx.globalAlpha = on ? 1 : 0.2
+ ctx.beginPath()
+ ctx.arc(node.x, node.y, node.r, 0, Math.PI * 2)
+ ctx.fillStyle = node.color || "#9ca3af"
+ ctx.fill()
+ ctx.lineWidth = 1.5
+ ctx.strokeStyle = "rgba(0, 0, 0, 0.28)"
+ ctx.stroke()
+ })
+ ctx.globalAlpha = 1
+
+ // Labels only at rest (or zoomed in / hovered neighborhood), cheap to draw
+ // on canvas with no trails, but skipping them keeps motion frames light.
+ const showLabels = !this.running || this.scale > 1.4 || active
+ if (showLabels) {
+ ctx.fillStyle = this.labelColor
+ ctx.textAlign = "center"
+ ctx.textBaseline = "top"
+ ctx.font = "10px ui-sans-serif, system-ui, sans-serif"
+ const zoomedIn = this.scale > 1.4
+ this.nodes.forEach((node) => {
+ const show = (!this.running && this.showAllLabels) || zoomedIn || (active && lit.has(node.id))
+ if (!show || !node.label) return
+ ctx.globalAlpha = active && !lit.has(node.id) ? 0.2 : 0.85
+ ctx.fillText(node.label, node.x, node.y + node.r + 2)
+ })
+ ctx.globalAlpha = 1
+ }
+ },
+
+ drawEmpty() {
+ const ctx = this.ctx
+ ctx.setTransform(this.dpr, 0, 0, this.dpr, 0, 0)
+ ctx.clearRect(0, 0, this.width, this.height)
+ ctx.fillStyle = `rgba(${EDGE_COLOR}, 0.8)`
+ ctx.textAlign = "center"
+ ctx.textBaseline = "middle"
+ ctx.font = "14px ui-sans-serif, system-ui, sans-serif"
+ ctx.fillText("No sources to graph yet.", this.width / 2, this.height / 2)
+ },
+
+ teardown() {
+ this.running = false
+ if (this.frame !== null) window.cancelAnimationFrame(this.frame)
+ if (this.drawFrame !== null) window.cancelAnimationFrame(this.drawFrame)
+ this.frame = null
+ this.drawFrame = null
+ if (this.resizeObserver) this.resizeObserver.disconnect()
+ this.resizeObserver = null
+ this.dragNode = null
+ this.panning = false
+ if (this.canvas && this.canvas.parentNode) this.canvas.remove()
+ this.canvas = null
+ this.ctx = null
+ }
+}
diff --git a/apps/elektrine/assets/js/hooks/kairo_hooks.js b/apps/elektrine/assets/js/hooks/kairo_hooks.js
new file mode 100644
index 0000000..a609476
--- /dev/null
+++ b/apps/elektrine/assets/js/hooks/kairo_hooks.js
@@ -0,0 +1,213 @@
+/**
+ * KairoVault - zero-knowledge encryption/decryption for Kairo sources.
+ * Encrypted rows decrypt on demand under the Kairo subkey of the unlocked
+ * encrypted data key, which can be unlocked inline for the tab. The composer can also
+ * encrypt new notes client-side before they are pushed to the server, so the
+ * plaintext body is never persisted.
+ */
+
+import { decryptValue, encryptValue, unwrapWithSecret } from "./vault_crypto"
+import * as vaultSession from "./vault_session"
+
+const FEATURE = "kairo"
+const AAD = { purpose: "elektrine-kairo-source" }
+
+export const KairoVault = {
+ mounted() {
+ this.encryptSavePending = false
+ this.unlockPending = false
+ this.unsubscribe = vaultSession.subscribe(() => this.renderLockState())
+ this.onRootClick = (event) => this.onClick(event)
+ this.onRootKeydown = (event) => this.onKeydown(event)
+ this.el.addEventListener("click", this.onRootClick)
+ this.el.addEventListener("keydown", this.onRootKeydown)
+ this.renderLockState()
+ },
+
+ updated() {
+ // LiveView re-rendered the contents (e.g. selecting a source), which resets
+ // the locked-hint to its server default; re-apply the lock state.
+ this.renderLockState()
+ },
+
+ destroyed() {
+ this.unsubscribe && this.unsubscribe()
+ this.el.removeEventListener("click", this.onRootClick)
+ this.el.removeEventListener("keydown", this.onRootKeydown)
+ },
+
+ renderLockState() {
+ const hint = this.el.querySelector("[data-kairo-locked-hint]")
+ if (hint) hint.classList.toggle("hidden", vaultSession.isUnlocked())
+ },
+
+ onClick(event) {
+ const unlock = event.target.closest("[data-kairo-master-unlock]")
+ if (unlock) {
+ event.preventDefault()
+ this.unlockMaster()
+ return
+ }
+
+ const decrypt = event.target.closest("[data-kairo-decrypt]")
+ if (decrypt) {
+ event.preventDefault()
+ this.decryptRow(decrypt)
+ return
+ }
+
+ const encryptSave = event.target.closest("[data-kairo-encrypt-save]")
+ if (encryptSave) {
+ event.preventDefault()
+ this.saveEncryptedNote(encryptSave)
+ }
+ },
+
+ onKeydown(event) {
+ if (event.key !== "Enter" || !event.target.closest("[data-kairo-master-unlock-input]")) return
+ event.preventDefault()
+ this.unlockMaster()
+ },
+
+ async unlockMaster() {
+ if (this.unlockPending) return
+
+ const input = this.el.querySelector("[data-kairo-master-unlock-input]")
+ const error = this.el.querySelector("[data-kairo-master-error]")
+ const wrapped = this.wrappedDek()
+ const setError = (message) => {
+ if (!error) return
+ error.textContent = message || ""
+ error.classList.toggle("hidden", !message)
+ }
+
+ if (!wrapped) {
+ return setError("Set up account-password encryption first.")
+ }
+
+ if (!input || input.value.trim() === "") {
+ return setError("Enter your account password.")
+ }
+
+ const button = this.el.querySelector("[data-kairo-master-unlock]")
+ this.unlockPending = true
+ if (button) {
+ button.disabled = true
+ button.setAttribute("aria-busy", "true")
+ }
+
+ try {
+ const mdk = await unwrapWithSecret(wrapped, input.value)
+ vaultSession.unlock(mdk)
+ input.value = ""
+ setError("")
+ // The vault-change subscription re-renders the lock state.
+ } catch (_error) {
+ setError(
+ "Incorrect account password. If you just reset it, recover encrypted data at /account/encrypted-data."
+ )
+ } finally {
+ this.unlockPending = false
+ if (button) {
+ button.disabled = false
+ button.removeAttribute("aria-busy")
+ }
+ }
+ },
+
+ wrappedDek() {
+ const raw = this.el.dataset.kairoMasterWrappedDek
+ if (!raw) return null
+ try {
+ return JSON.parse(raw)
+ } catch (_error) {
+ return null
+ }
+ },
+
+ async saveEncryptedNote(button) {
+ if (this.encryptSavePending) return
+
+ const form = this.el.querySelector("#kairo-note-form")
+ if (!form) return
+
+ const error = this.el.querySelector("[data-kairo-encrypt-error]")
+ const setError = (message) => {
+ if (!error) return
+ error.textContent = message || ""
+ error.classList.toggle("hidden", !message)
+ }
+
+ if (!vaultSession.isUnlocked()) {
+ return setError("Enter your account password to save encrypted notes.")
+ }
+
+ const field = (name) => {
+ const input = form.querySelector(`[name="note[${name}]"]`)
+ return input ? input.value : ""
+ }
+
+ const content = field("content")
+ const title = field("title")
+ if (content.trim() === "" && title.trim() === "") {
+ return setError("Add a title or some content first.")
+ }
+
+ this.encryptSavePending = true
+ button.disabled = true
+ button.setAttribute("aria-busy", "true")
+
+ try {
+ const key = await vaultSession.featureKey(FEATURE)
+ const payload = await encryptValue(content, key, AAD)
+
+ this.pushEvent(
+ "save_encrypted_note",
+ {
+ note: { title, tags: field("tags"), project_id: field("project_id") },
+ payload
+ },
+ (reply) => {
+ this.finishEncryptedSave()
+ if (reply && reply.ok) {
+ setError("")
+ } else {
+ setError((reply && reply.error) || "Could not save the encrypted note.")
+ }
+ }
+ )
+ } catch (_error) {
+ this.finishEncryptedSave()
+ setError("Encryption failed in this browser.")
+ }
+ },
+
+ finishEncryptedSave() {
+ this.encryptSavePending = false
+ const button = this.el.querySelector("[data-kairo-encrypt-save]")
+ if (!button) return
+ button.disabled = false
+ button.removeAttribute("aria-busy")
+ },
+
+ async decryptRow(button) {
+ const output = button.parentElement.querySelector("[data-kairo-output]")
+ if (!output) return
+
+ if (!vaultSession.isUnlocked()) {
+ output.textContent = "Enter your account password to read this."
+ output.classList.remove("hidden")
+ return
+ }
+
+ try {
+ const payload = JSON.parse(button.dataset.kairoPayload)
+ const key = await vaultSession.featureKey(FEATURE)
+ output.textContent = await decryptValue(payload, key, AAD)
+ output.classList.remove("hidden")
+ } catch (_error) {
+ output.textContent = "Could not decrypt this source."
+ output.classList.remove("hidden")
+ }
+ }
+}
diff --git a/apps/elektrine/assets/js/hooks/mailbox_private_auth_forms.js b/apps/elektrine/assets/js/hooks/mailbox_private_auth_forms.js
new file mode 100644
index 0000000..ccb9a8f
--- /dev/null
+++ b/apps/elektrine/assets/js/hooks/mailbox_private_auth_forms.js
@@ -0,0 +1,112 @@
+import { submitFormPreservingEvents } from "../utils/form_submission"
+import {
+ readMailboxFields,
+ readPasswordUpdateContext,
+ readVaultFields,
+ passwordUpdateValidationError,
+ shouldHandlePasswordUpdate
+} from "./mailbox_private_auth_payloads"
+import { unwrapWithSecret, wrapWithSecret } from "./vault_crypto"
+import {
+ VERIFY_TEXT,
+ cacheLoginPassword,
+ encoder,
+ notify,
+ unwrapMailboxPrivateKey,
+ wrapBytes
+} from "./mailbox_private_storage_hooks"
+
+function bindPrivateMailboxLoginForm(form) {
+ if (!form || form.dataset.privateMailboxLoginBound === "true") return
+
+ form.addEventListener("submit", () => {
+ const passwordInput = form.querySelector("input[name='user[password]']")
+ const password = passwordInput?.value || ""
+
+ if (password.trim() !== "") {
+ cacheLoginPassword(password)
+ }
+ })
+
+ form.dataset.privateMailboxLoginBound = "true"
+}
+
+function bindPrivateMailboxPasswordForm(form) {
+ if (!form || form.dataset.privateMailboxPasswordBound === "true") return
+
+ form.addEventListener("submit", async (event) => {
+ if (form.dataset.privateMailboxRewrapSubmitting === "true") {
+ return
+ }
+
+ const context = readPasswordUpdateContext(form)
+
+ if (!shouldHandlePasswordUpdate(context)) return
+ if (context.currentPassword.trim() === "" || context.newPassword.trim() === "") return
+
+ const vaultFields = readVaultFields(form)
+ const mailboxFields = readMailboxFields(form)
+ const validationError = passwordUpdateValidationError(context, vaultFields, mailboxFields)
+
+ if (validationError) {
+ event.preventDefault()
+ notify(validationError)
+ return
+ }
+
+ try {
+ event.preventDefault()
+
+ if (context.vaultConfigured) {
+ const mdk = await unwrapWithSecret(context.vaultWrappedDek, context.currentPassword)
+ vaultFields.wrappedDekField.value = JSON.stringify(
+ await wrapWithSecret(mdk, context.newPassword)
+ )
+ vaultFields.wrappedDekRecoveryField.value = JSON.stringify(context.vaultWrappedDekRecovery)
+ }
+
+ if (context.shouldRewrapMailbox) {
+ const privateKeyBytes = await unwrapMailboxPrivateKey(
+ context.wrappedKeyPayload,
+ context.verifierPayload,
+ context.currentPassword
+ )
+ const nextWrappedKey = await wrapBytes(privateKeyBytes, context.newPassword, "account_password")
+ const nextVerifier = await wrapBytes(
+ encoder.encode(VERIFY_TEXT),
+ context.newPassword,
+ "account_password",
+ "verifier"
+ )
+
+ mailboxFields.wrappedKeyField.value = JSON.stringify(nextWrappedKey)
+ mailboxFields.verifierField.value = JSON.stringify(nextVerifier)
+ mailboxFields.unlockModeField.value = "account_password"
+ }
+
+ cacheLoginPassword(context.newPassword)
+
+ form.dataset.privateMailboxRewrapSubmitting = "true"
+ submitFormPreservingEvents(form)
+ } catch (_error) {
+ notify(
+ "Could not update encrypted data for the new password. Check your current password and try again."
+ )
+ }
+ })
+
+ form.dataset.privateMailboxPasswordBound = "true"
+}
+
+export function initPrivateMailboxAuthForms(rootCandidate = document) {
+ const root =
+ rootCandidate && typeof rootCandidate.querySelectorAll === "function" ? rootCandidate : document
+
+ root
+ .querySelectorAll("[data-private-mailbox-login-form='true']")
+ .forEach((form) => bindPrivateMailboxLoginForm(form))
+
+ root
+ .querySelectorAll("[data-private-mailbox-password-form='true']")
+ .forEach((form) => bindPrivateMailboxPasswordForm(form))
+}
diff --git a/apps/elektrine/assets/js/hooks/mailbox_private_auth_payloads.js b/apps/elektrine/assets/js/hooks/mailbox_private_auth_payloads.js
new file mode 100644
index 0000000..5e23cac
--- /dev/null
+++ b/apps/elektrine/assets/js/hooks/mailbox_private_auth_payloads.js
@@ -0,0 +1,68 @@
+import { parsePayload } from "./mailbox_private_crypto"
+
+function value(form, selector) {
+ return form.querySelector(selector)?.value || ""
+}
+
+export function readPasswordUpdateContext(form) {
+ const vaultConfigured = form.dataset.vaultConfigured === "true"
+ const configured = form.dataset.privateMailboxConfigured === "true"
+ const unlockMode = form.dataset.privateMailboxUnlockMode || "separate_passphrase"
+
+ return {
+ vaultConfigured,
+ configured,
+ unlockMode,
+ shouldRewrapMailbox: configured && unlockMode === "account_password",
+ currentPassword: value(form, "input[name='user[current_password]']"),
+ newPassword: value(form, "input[name='user[password]']"),
+ vaultWrappedDek: parsePayload(form.dataset.vaultWrappedDek),
+ vaultWrappedDekRecovery: parsePayload(form.dataset.vaultWrappedDekRecovery),
+ wrappedKeyPayload: parsePayload(form.dataset.privateMailboxWrappedKey),
+ verifierPayload: parsePayload(form.dataset.privateMailboxVerifier)
+ }
+}
+
+export function readVaultFields(form) {
+ return {
+ wrappedDekField: form.querySelector("input[name='user[vault_wrapped_dek]']"),
+ wrappedDekRecoveryField: form.querySelector(
+ "input[name='user[vault_wrapped_dek_recovery]']"
+ )
+ }
+}
+
+export function readMailboxFields(form) {
+ return {
+ wrappedKeyField: form.querySelector("input[name='user[private_mailbox_wrapped_private_key]']"),
+ verifierField: form.querySelector("input[name='user[private_mailbox_verifier]']"),
+ unlockModeField: form.querySelector("input[name='user[private_mailbox_unlock_mode]']")
+ }
+}
+
+export function shouldHandlePasswordUpdate(context) {
+ return context.vaultConfigured || (context.configured && context.unlockMode === "account_password")
+}
+
+export function passwordUpdateValidationError(context, vaultFields, mailboxFields) {
+ if (context.vaultConfigured && (!context.vaultWrappedDek || !context.vaultWrappedDekRecovery)) {
+ return "Encrypted data update is unavailable. Reload and try again."
+ }
+
+ if (context.vaultConfigured && (!vaultFields.wrappedDekField || !vaultFields.wrappedDekRecoveryField)) {
+ return "Encrypted data update fields are missing. Reload and try again."
+ }
+
+ if (context.shouldRewrapMailbox && (!context.wrappedKeyPayload || !context.verifierPayload)) {
+ return "Private mailbox rewrap data is unavailable. Reload and try again."
+ }
+
+ if (
+ context.shouldRewrapMailbox &&
+ (!mailboxFields.wrappedKeyField || !mailboxFields.verifierField || !mailboxFields.unlockModeField)
+ ) {
+ return "Private mailbox password update fields are missing. Reload and try again."
+ }
+
+ return null
+}
diff --git a/apps/elektrine/assets/js/hooks/mailbox_private_compose_hook.js b/apps/elektrine/assets/js/hooks/mailbox_private_compose_hook.js
new file mode 100644
index 0000000..6e0e14b
--- /dev/null
+++ b/apps/elektrine/assets/js/hooks/mailbox_private_compose_hook.js
@@ -0,0 +1,198 @@
+import {
+ buildForwardBody,
+ buildReplyBody,
+ escapeHtml,
+ formatAttachmentMeta,
+ payloadString,
+ prefixedSubject
+} from "./mailbox_private_content"
+import {
+ decryptAttachmentPayload,
+ decryptMessagePayload,
+ getStoredPrivateKey,
+ maybeSetValue,
+ parsePayload
+} from "./mailbox_private_storage_hooks"
+
+export const PrivateMailboxCompose = {
+ mounted() {
+ this.mailboxId = this.el.dataset.privateMailboxId
+ this.mode = this.el.dataset.privateComposeMode
+ this.messageEnvelope = parsePayload(this.el.dataset.privateOriginalPayload)
+ this.attachments = parsePayload(this.el.dataset.privateOriginalAttachments) || {}
+ this.metadata = {
+ from: this.el.dataset.privateOriginalFrom || "",
+ to: this.el.dataset.privateOriginalTo || "",
+ cc: this.el.dataset.privateOriginalCc || "",
+ bcc: this.el.dataset.privateOriginalBcc || "",
+ status: this.el.dataset.privateOriginalStatus || "",
+ insertedAt: this.el.dataset.privateOriginalInsertedAt || ""
+ }
+
+ this.onMailboxChange = () => this.applyPrefill()
+ window.addEventListener("elektrine:private-mailbox-unlocked", this.onMailboxChange)
+ window.addEventListener("elektrine:private-mailbox-locked", this.onMailboxChange)
+
+ this.applyPrefill()
+ },
+
+ updated() {
+ this.applyPrefill()
+ },
+
+ destroyed() {
+ window.removeEventListener("elektrine:private-mailbox-unlocked", this.onMailboxChange)
+ window.removeEventListener("elektrine:private-mailbox-locked", this.onMailboxChange)
+ },
+
+ get subjectInput() {
+ return document.querySelector("input[name='email[subject]']")
+ },
+
+ get hiddenBodyInput() {
+ return document.querySelector("[data-private-compose-hidden-body]")
+ },
+
+ get previewElement() {
+ return document.querySelector("[data-private-compose-preview]")
+ },
+
+ get privateForwardAttachmentsInput() {
+ return document.querySelector("#private-forward-attachments")
+ },
+
+ get attachmentsPreview() {
+ return document.querySelector("[data-private-compose-attachments-preview]")
+ },
+
+ renderLockedAttachments() {
+ if (!this.attachmentsPreview || this.mode !== "forward") return
+
+ const hasPrivateAttachments = Object.values(this.attachments).some((attachment) =>
+ Boolean(attachment?.private_encrypted_payload)
+ )
+
+ if (!hasPrivateAttachments) return
+
+ this.attachmentsPreview.innerHTML = `
+
+ Unlock the mailbox in this tab to include protected attachments in the forwarded message.
+
+ `
+ },
+
+ async applyPrefill() {
+ if (!this.mailboxId || !this.messageEnvelope) return
+
+ if (!getStoredPrivateKey(this.mailboxId)) {
+ this.renderLockedAttachments()
+ if (this.privateForwardAttachmentsInput) {
+ this.privateForwardAttachmentsInput.value = ""
+ }
+ return
+ }
+
+ try {
+ const payload = await decryptMessagePayload(this.messageEnvelope, this.mailboxId)
+ if (!payload) return
+
+ const originalSubject = typeof payload.subject === "string" ? payload.subject : ""
+ const subject = prefixedSubject(this.mode, originalSubject)
+ const metadata = {
+ ...this.metadata,
+ from: payloadString(payload, "from") || this.metadata.from,
+ to: payloadString(payload, "to") || this.metadata.to,
+ cc: payloadString(payload, "cc") || this.metadata.cc,
+ bcc: payloadString(payload, "bcc") || this.metadata.bcc
+ }
+
+ maybeSetValue(this.subjectInput, subject, (currentValue) =>
+ currentValue.includes("Encrypted message")
+ )
+
+ const decryptedAttachments = await this.decryptForwardAttachments()
+
+ const body =
+ this.mode === "forward"
+ ? buildForwardBody(payload, metadata, decryptedAttachments)
+ : buildReplyBody(payload, metadata)
+
+ const protectedForwardAttachments = decryptedAttachments.filter((attachment) => attachment.data)
+
+ if (this.hiddenBodyInput) {
+ this.hiddenBodyInput.value = body
+ }
+
+ if (this.previewElement) {
+ this.previewElement.textContent = body
+ }
+
+ if (this.privateForwardAttachmentsInput) {
+ this.privateForwardAttachmentsInput.value = JSON.stringify(protectedForwardAttachments)
+ }
+
+ this.renderForwardAttachments(decryptedAttachments)
+ } catch (_error) {
+ this.renderLockedAttachments()
+ }
+ },
+
+ async decryptForwardAttachments() {
+ const attachments = Object.entries(this.attachments || {})
+ if (attachments.length === 0) return []
+
+ const decrypted = await Promise.all(
+ attachments.map(async ([attachmentId, attachment]) => {
+ if (!attachment?.private_encrypted_payload) {
+ return {
+ attachment_id: attachmentId,
+ filename: attachment.filename || "attachment",
+ content_type: attachment.content_type || "application/octet-stream",
+ size: attachment.size || 0
+ }
+ }
+
+ const payload = await decryptAttachmentPayload(attachment.private_encrypted_payload, this.mailboxId)
+
+ return {
+ attachment_id: attachmentId,
+ filename: payload.filename || "attachment",
+ content_type: payload.content_type || "application/octet-stream",
+ size: payload.size || 0,
+ encoding: payload.encoding || "base64",
+ data: payload.data || ""
+ }
+ })
+ )
+
+ return decrypted
+ },
+
+ renderForwardAttachments(attachments) {
+ if (!this.attachmentsPreview || this.mode !== "forward") return
+
+ if (attachments.length === 0) {
+ this.attachmentsPreview.innerHTML = ""
+ return
+ }
+
+ this.attachmentsPreview.innerHTML = attachments
+ .map(
+ (attachment) => `
+
+
+
+
+
${escapeHtml(attachment.filename || "attachment")}
+
${escapeHtml(formatAttachmentMeta(attachment))}
+
+
+
Will be forwarded
+
+ `
+ )
+ .join("")
+ }
+}
diff --git a/apps/elektrine/assets/js/hooks/mailbox_private_content.js b/apps/elektrine/assets/js/hooks/mailbox_private_content.js
new file mode 100644
index 0000000..a62481c
--- /dev/null
+++ b/apps/elektrine/assets/js/hooks/mailbox_private_content.js
@@ -0,0 +1,301 @@
+function htmlToText(html) {
+ if (!html) return ""
+
+ const doc = new DOMParser().parseFromString(html, "text/html")
+ return (doc.body?.textContent || "").replace(/\s+/g, " ").trim()
+}
+
+export function previewText(payload) {
+ const text = (payload.text_body || htmlToText(payload.html_body || "") || "").trim()
+ if (!text) return "Encrypted mailbox content"
+ return text.length > 160 ? `${text.slice(0, 160)}...` : text
+}
+
+export function bodyText(payload) {
+ const text = (payload.text_body || "").trim()
+ if (text) return text
+
+ const htmlText = htmlToText(payload.html_body || "")
+ return htmlText || "Encrypted mailbox content"
+}
+
+export function payloadString(payload, field) {
+ const value = payload?.[field]
+ return typeof value === "string" ? value : ""
+}
+
+export function escapeHtml(value) {
+ return String(value || "")
+ .replaceAll("&", "&")
+ .replaceAll("<", "<")
+ .replaceAll(">", ">")
+ .replaceAll('"', """)
+ .replaceAll("'", "'")
+}
+
+export function buildSandboxedEmailHtml(content) {
+ const csp = [
+ "default-src 'none'",
+ "img-src data: cid: https:",
+ "media-src data: cid: https:",
+ "style-src 'unsafe-inline' https:",
+ "font-src data: https:",
+ "connect-src 'none'",
+ "frame-src 'none'",
+ "child-src 'none'",
+ "object-src 'none'",
+ "base-uri 'none'",
+ "form-action 'none'"
+ ].join("; ")
+
+ return `
+
+
+
+
+
+
+
+
+${content || ""}
+`
+}
+
+const HTML_FRAGMENT_PATTERN =
+ /<\/?(?:html|head|body|table|thead|tbody|tfoot|tr|th|td|div|p|span|br|a|img|style|section|article|h[1-6])\b/i
+const ENCODED_HTML_FRAGMENT_PATTERN =
+ /<\s*(?:!doctype\b|\/?\s*(?:html|head|body|table|thead|tbody|tfoot|tr|th|td|div|p|span|br|a|img|style|section|article|h[1-6])\b)/gi
+
+function maybeDecodeEntityEncodedHtml(content) {
+ if (HTML_FRAGMENT_PATTERN.test(content)) return content
+
+ const matches = content.match(ENCODED_HTML_FRAGMENT_PATTERN) || []
+ if (matches.length < 2) return content
+
+ const textarea = document.createElement("textarea")
+ textarea.innerHTML = content
+ return textarea.value
+}
+
+function removeUnsafeElement(element) {
+ if (element && typeof element.remove === "function") {
+ element.remove()
+ }
+}
+
+function isDangerousUrl(value) {
+ if (typeof value !== "string") return false
+
+ const normalized = value.replace(/[\u0000-\u001F\u007F\s]+/g, "").toLowerCase()
+ return (
+ normalized.startsWith("javascript:") ||
+ normalized.startsWith("vbscript:") ||
+ normalized.startsWith("data:text/html")
+ )
+}
+
+function scrubSrcset(value) {
+ if (typeof value !== "string") return ""
+
+ return value
+ .split(",")
+ .map((entry) => entry.trim())
+ .filter((entry) => {
+ const [candidate] = entry.split(/\s+/, 1)
+ return candidate && !isDangerousUrl(candidate)
+ })
+ .join(", ")
+}
+
+export function sanitizeProtectedHtml(content) {
+ if (typeof content !== "string" || content.trim() === "") return ""
+
+ const doc = new DOMParser().parseFromString(
+ maybeDecodeEntityEncodedHtml(content),
+ "text/html"
+ )
+
+ doc
+ .querySelectorAll(
+ "script, iframe, frame, frameset, object, embed, applet, form, input, textarea, select, button, meta[http-equiv], base"
+ )
+ .forEach(removeUnsafeElement)
+
+ doc.querySelectorAll("link").forEach((element) => {
+ const href = element.getAttribute("href") || ""
+ const rel = (element.getAttribute("rel") || "").toLowerCase()
+ const blockedRel =
+ /(?:^|\s)(?:dns-prefetch|import|modulepreload|preconnect|prefetch|preload)(?:\s|$)/.test(
+ rel
+ )
+
+ if (isDangerousUrl(href) || blockedRel) {
+ removeUnsafeElement(element)
+ }
+ })
+
+ doc.querySelectorAll("*").forEach((element) => {
+ Array.from(element.attributes).forEach((attribute) => {
+ const name = attribute.name.toLowerCase()
+ const value = attribute.value || ""
+
+ if (name.startsWith("on")) {
+ element.removeAttribute(attribute.name)
+ return
+ }
+
+ if ((name === "src" || name === "href" || name === "poster") && isDangerousUrl(value)) {
+ element.removeAttribute(attribute.name)
+ return
+ }
+
+ if (name === "srcset") {
+ const scrubbed = scrubSrcset(value)
+ if (scrubbed) {
+ element.setAttribute(attribute.name, scrubbed)
+ } else {
+ element.removeAttribute(attribute.name)
+ }
+ return
+ }
+
+ if (name === "style") {
+ const scrubbedStyle = value
+ .replace(/url\(\s*(['"]?)\s*javascript\s*:[^;>]*\1\s*\)/gi, "none")
+ .replace(/url\(\s*(['"]?)\s*vbscript\s*:[^;>]*\1\s*\)/gi, "none")
+ .replace(/url\(\s*(['"]?)\s*data\s*:\s*text\/html[^;>]*\1\s*\)/gi, "none")
+ .replace(/expression\s*\([^)]*\)/gi, "")
+
+ if (scrubbedStyle.trim() === "") {
+ element.removeAttribute(attribute.name)
+ } else {
+ element.setAttribute(attribute.name, scrubbedStyle)
+ }
+ }
+ })
+ })
+
+ const headStyles = Array.from(doc.head?.querySelectorAll("style") || [])
+ .map((element) => element.outerHTML)
+ .join("\n")
+
+ return [headStyles, doc.body?.innerHTML || ""].filter(Boolean).join("\n")
+}
+
+export function downloadBytes(filename, contentType, data) {
+ const binaryString = atob(data)
+ const bytes = new Uint8Array(binaryString.length)
+
+ for (let index = 0; index < binaryString.length; index += 1) {
+ bytes[index] = binaryString.charCodeAt(index)
+ }
+
+ const blob = new Blob([bytes], { type: contentType || "application/octet-stream" })
+ const url = URL.createObjectURL(blob)
+ const link = document.createElement("a")
+
+ link.href = url
+ link.download = filename || "attachment"
+ link.style.display = "none"
+ document.body.appendChild(link)
+ link.click()
+ document.body.removeChild(link)
+ URL.revokeObjectURL(url)
+}
+
+function formatBytes(size) {
+ const value = Number(size || 0)
+ if (value < 1024) return `${value} bytes`
+ if (value < 1024 * 1024) return `${(value / 1024).toFixed(1)} KB`
+ return `${(value / (1024 * 1024)).toFixed(1)} MB`
+}
+
+export function formatAttachmentMeta(payload, sizeOverride = null) {
+ const size = Number.isFinite(sizeOverride) ? sizeOverride : payload.size || 0
+ const formattedSize = formatBytes(Math.max(size, 0))
+ return `${formattedSize} • ${payload.content_type || "application/octet-stream"}`
+}
+
+function formatDateForQuote(rawValue) {
+ const value = rawValue ? new Date(rawValue) : null
+ if (!value || Number.isNaN(value.getTime())) return ""
+
+ return value.toLocaleString(undefined, {
+ weekday: "short",
+ month: "short",
+ day: "2-digit",
+ year: "numeric",
+ hour: "2-digit",
+ minute: "2-digit"
+ })
+}
+
+function quoteMessageBody(body) {
+ return (body || "")
+ .split("\n")
+ .map((line) => `> ${line}`)
+ .join("\n")
+}
+
+export function prefixedSubject(mode, subject) {
+ const original = typeof subject === "string" ? subject.trim() : ""
+
+ if (mode === "forward") {
+ return original.startsWith("Fwd: ") ? original : `Fwd: ${original}`
+ }
+
+ return original.startsWith("Re: ") ? original : `Re: ${original}`
+}
+
+export function buildReplyBody(payload, metadata) {
+ const dateText = formatDateForQuote(metadata.insertedAt)
+ const senderText = metadata.status === "sent" ? "you" : metadata.from
+
+ return `\n\nOn ${dateText}, ${senderText} wrote:\n${quoteMessageBody(bodyText(payload))}\n`
+}
+
+export function buildForwardBody(payload, metadata, attachments) {
+ const attachmentBlock =
+ attachments.length === 0
+ ? ""
+ : `\nAttachments:\n${attachments
+ .map((attachment) => `- ${attachment.filename} (${attachment.size || 0} bytes)`)
+ .join("\n")}\n`
+
+ return `\n\n---------- Forwarded message ----------\nFrom: ${metadata.from}\nTo: ${metadata.to}\nDate: ${formatDateForQuote(metadata.insertedAt)}\nSubject: ${payload.subject || ""}${attachmentBlock}\n${bodyText(payload)}\n`
+}
+
+export function restoreAttachmentPlaceholder(element) {
+ const filenameEl = element.querySelector("[data-private-attachment-filename]")
+ if (filenameEl?.dataset.privateAttachmentFilenamePlaceholder) {
+ filenameEl.textContent = filenameEl.dataset.privateAttachmentFilenamePlaceholder
+ }
+
+ const metaEl = element.querySelector("[data-private-attachment-meta]")
+ if (metaEl?.dataset.privateAttachmentMetaPlaceholder) {
+ metaEl.textContent = metaEl.dataset.privateAttachmentMetaPlaceholder
+ }
+
+ const labelEl = element.querySelector("[data-private-attachment-download-label]")
+ if (labelEl?.dataset.privateAttachmentDownloadLabelPlaceholder) {
+ labelEl.textContent = labelEl.dataset.privateAttachmentDownloadLabelPlaceholder
+ }
+}
diff --git a/apps/elektrine/assets/js/hooks/mailbox_private_crypto.js b/apps/elektrine/assets/js/hooks/mailbox_private_crypto.js
new file mode 100644
index 0000000..96471c2
--- /dev/null
+++ b/apps/elektrine/assets/js/hooks/mailbox_private_crypto.js
@@ -0,0 +1,435 @@
+/**
+ * Crypto, encoding, and key-management helpers for the private mailbox.
+ *
+ * Extracted from mailbox_private_storage_hooks.js to keep that file within its
+ * maintainability budget. This module owns the low-level primitives (base64,
+ * scrypt key wrapping, RSA envelope decryption, master-key wrapping) and the
+ * in-memory imported-key cache; the hook in mailbox_private_storage_hooks.js
+ * drives them. Public names are re-exported from that module so existing
+ * importers keep working unchanged.
+ */
+
+import { scryptAsync } from "@noble/hashes/scrypt.js"
+import * as vaultSession from "./vault_session"
+
+export const MASTER_MODE = "master"
+const MASTER_FEATURE = "email"
+const WRAP_ALGORITHM = "AES-GCM"
+export const VERIFY_TEXT = "elektrine-private-mailbox-v1"
+const MESSAGE_AAD = new TextEncoder().encode("ElektrineMailboxStorageV1")
+const ATTACHMENT_AAD = new TextEncoder().encode("ElektrineMailboxAttachmentV1")
+const DEFAULT_SCRYPT = { n: 16384, r: 8, p: 1 }
+export const encoder = new TextEncoder()
+const decoder = new TextDecoder()
+const importedPrivateKeys = new Map()
+
+function stableJson(value) {
+ if (!value || typeof value !== "object" || Array.isArray(value)) return JSON.stringify(value)
+
+ return JSON.stringify(
+ Object.keys(value)
+ .sort()
+ .reduce((acc, key) => {
+ acc[key] = value[key]
+ return acc
+ }, {})
+ )
+}
+
+function encodedStableJson(value) {
+ return encoder.encode(stableJson(value))
+}
+
+function randomBytes(length) {
+ const bytes = new Uint8Array(length)
+ crypto.getRandomValues(bytes)
+ return bytes
+}
+
+function bytesToBase64(bytes) {
+ let binary = ""
+
+ bytes.forEach((byte) => {
+ binary += String.fromCharCode(byte)
+ })
+
+ return btoa(binary)
+}
+
+function base64ToBytes(value) {
+ const binary = atob(value)
+ const bytes = new Uint8Array(binary.length)
+
+ for (let index = 0; index < binary.length; index += 1) {
+ bytes[index] = binary.charCodeAt(index)
+ }
+
+ return bytes
+}
+
+function concatBytes(first, second) {
+ const combined = new Uint8Array(first.length + second.length)
+ combined.set(first, 0)
+ combined.set(second, first.length)
+ return combined
+}
+
+export function parsePayload(raw) {
+ if (!raw) return null
+
+ try {
+ return JSON.parse(raw)
+ } catch (_error) {
+ return null
+ }
+}
+
+function isValidUnlockMode(value) {
+ return value === "account_password" || value === "separate_passphrase"
+}
+
+export function isKnownUnlockMode(value) {
+ return isValidUnlockMode(value) || value === MASTER_MODE
+}
+
+export function unlockModeFromPayload(...payloads) {
+ for (const payload of payloads) {
+ if (!payload || typeof payload !== "object") continue
+
+ const unlockMode = payload.unlock_mode || payload.unlockMode
+ if (isKnownUnlockMode(unlockMode)) {
+ return unlockMode
+ }
+ }
+
+ return null
+}
+
+export function notify(message, type = "error", title = "Mailbox") {
+ if (typeof window.showNotification === "function") {
+ window.showNotification(message, type, title)
+ } else {
+ console.error(`[Mailbox] ${message}`)
+ }
+}
+
+export function cacheLoginPassword(password) {
+ void password
+}
+
+export function clearCachedLoginPassword() {
+ return null
+}
+
+export function getCachedLoginPassword() {
+ return null
+}
+
+export function getStoredPrivateKey(mailboxId) {
+ if (!mailboxId) return null
+ return importedPrivateKeys.get(mailboxId) || null
+}
+
+function storePrivateKey(mailboxId, privateKey) {
+ if (!mailboxId) return
+ importedPrivateKeys.set(mailboxId, privateKey)
+}
+
+export function clearPrivateKey(mailboxId) {
+ if (!mailboxId) return
+ importedPrivateKeys.delete(mailboxId)
+}
+
+export function dispatchMailboxEvent(name, mailboxId) {
+ window.dispatchEvent(
+ new CustomEvent(name, {
+ detail: { mailboxId }
+ })
+ )
+}
+
+function chunkString(value, size) {
+ const parts = []
+
+ for (let index = 0; index < value.length; index += size) {
+ parts.push(value.slice(index, index + size))
+ }
+
+ return parts.join("\n")
+}
+
+function bytesToPem(bytes, label) {
+ const body = chunkString(bytesToBase64(bytes), 64)
+ return `-----BEGIN ${label}-----\n${body}\n-----END ${label}-----`
+}
+
+async function deriveWrappingKey(passphrase, salt, params) {
+ const keyBytes = await scryptAsync(encoder.encode(passphrase), salt, {
+ N: params.n,
+ r: params.r,
+ p: params.p,
+ dkLen: 32
+ })
+
+ return crypto.subtle.importKey("raw", keyBytes, { name: WRAP_ALGORITHM }, false, [
+ "encrypt",
+ "decrypt"
+ ])
+}
+
+function keyWrapAADContext(unlockMode, kind) {
+ return {
+ purpose: "elektrine-private-mailbox-key-wrap",
+ version: 2,
+ kind,
+ algorithm: WRAP_ALGORITHM,
+ kdf: "scrypt",
+ unlock_mode: unlockMode
+ }
+}
+
+function wrapParams(payload, iv) {
+ if (Number(payload.version) >= 2) {
+ return { name: WRAP_ALGORITHM, iv, additionalData: encodedStableJson(payload.aad_context) }
+ }
+
+ return { name: WRAP_ALGORITHM, iv }
+}
+
+export async function wrapBytes(bytes, passphrase, unlockMode = null, kind = "private_key") {
+ const salt = randomBytes(16)
+ const iv = randomBytes(12)
+ const key = await deriveWrappingKey(passphrase, salt, DEFAULT_SCRYPT)
+ const aadContext = keyWrapAADContext(unlockMode, kind)
+ const ciphertext = await crypto.subtle.encrypt(
+ { name: WRAP_ALGORITHM, iv, additionalData: encodedStableJson(aadContext) },
+ key,
+ bytes
+ )
+
+ const payload = {
+ version: 2,
+ algorithm: WRAP_ALGORITHM,
+ kdf: "scrypt",
+ aad_context: aadContext,
+ n: DEFAULT_SCRYPT.n,
+ r: DEFAULT_SCRYPT.r,
+ p: DEFAULT_SCRYPT.p,
+ salt: bytesToBase64(salt),
+ iv: bytesToBase64(iv),
+ ciphertext: bytesToBase64(new Uint8Array(ciphertext))
+ }
+
+ if (isValidUnlockMode(unlockMode)) {
+ payload.unlock_mode = unlockMode
+ }
+
+ return payload
+}
+
+async function unwrapBytes(payload, passphrase) {
+ const salt = base64ToBytes(payload.salt)
+ const iv = base64ToBytes(payload.iv)
+ const ciphertext = base64ToBytes(payload.ciphertext)
+ const key = await deriveWrappingKey(passphrase, salt, payload)
+ const plaintext = await crypto.subtle.decrypt(wrapParams(payload, iv), key, ciphertext)
+ return new Uint8Array(plaintext)
+}
+
+// --- shared account-password vault mode ------------------------------------
+// Instead of deriving a wrapping key directly from the account password, shared
+// vault mode wraps the mailbox key with the email subkey of the encrypted data
+// key held in the shared vault session. One account-password unlock therefore
+// unlocks this mailbox too.
+
+async function masterEmailKey() {
+ return vaultSession.featureKey(MASTER_FEATURE)
+}
+
+function masterAADContext(kind) {
+ return {
+ purpose: "elektrine-private-mailbox-key-wrap",
+ version: 2,
+ kind,
+ algorithm: WRAP_ALGORITHM,
+ kdf: MASTER_MODE,
+ unlock_mode: MASTER_MODE
+ }
+}
+
+export async function wrapBytesWithMasterKey(bytes, kind = "private_key") {
+ const key = await masterEmailKey()
+ const iv = randomBytes(12)
+ const aadContext = masterAADContext(kind)
+ const ciphertext = await crypto.subtle.encrypt(
+ { name: WRAP_ALGORITHM, iv, additionalData: encodedStableJson(aadContext) },
+ key,
+ bytes
+ )
+
+ return {
+ version: 2,
+ algorithm: WRAP_ALGORITHM,
+ kdf: MASTER_MODE,
+ unlock_mode: MASTER_MODE,
+ aad_context: aadContext,
+ iv: bytesToBase64(iv),
+ ciphertext: bytesToBase64(new Uint8Array(ciphertext))
+ }
+}
+
+async function unwrapBytesWithMasterKey(payload) {
+ const key = await masterEmailKey()
+ const iv = base64ToBytes(payload.iv)
+ const ciphertext = base64ToBytes(payload.ciphertext)
+ const plaintext = await crypto.subtle.decrypt(wrapParams(payload, iv), key, ciphertext)
+ return new Uint8Array(plaintext)
+}
+
+export async function generateMailboxKeypair() {
+ const keypair = await crypto.subtle.generateKey(
+ {
+ name: "RSA-OAEP",
+ modulusLength: 2048,
+ publicExponent: new Uint8Array([1, 0, 1]),
+ hash: "SHA-256"
+ },
+ true,
+ ["encrypt", "decrypt"]
+ )
+
+ const publicKey = new Uint8Array(await crypto.subtle.exportKey("spki", keypair.publicKey))
+ const privateKey = new Uint8Array(await crypto.subtle.exportKey("pkcs8", keypair.privateKey))
+
+ return {
+ publicKeyPem: bytesToPem(publicKey, "PUBLIC KEY"),
+ privateKey
+ }
+}
+
+async function importStoredPrivateKey(mailboxId) {
+ return getStoredPrivateKey(mailboxId)
+}
+
+async function decryptEnvelope(envelope, mailboxId, aad) {
+ const privateKey = await importStoredPrivateKey(mailboxId)
+ if (!privateKey) return null
+
+ const wrappedKey = base64ToBytes(envelope.encrypted_key)
+ const contentKeyBytes = await crypto.subtle.decrypt({ name: "RSA-OAEP" }, privateKey, wrappedKey)
+ const contentKey = await crypto.subtle.importKey(
+ "raw",
+ contentKeyBytes,
+ { name: "AES-GCM" },
+ false,
+ ["decrypt"]
+ )
+
+ const ciphertext = base64ToBytes(envelope.ciphertext)
+ const tag = base64ToBytes(envelope.tag)
+ const iv = base64ToBytes(envelope.iv)
+
+ const plaintext = await crypto.subtle.decrypt(
+ { name: "AES-GCM", iv, additionalData: envelopeAAD(envelope, aad) },
+ contentKey,
+ concatBytes(ciphertext, tag)
+ )
+
+ return JSON.parse(new TextDecoder().decode(plaintext))
+}
+
+function envelopeAAD(envelope, fallbackAAD) {
+ if (Number(envelope.version) >= 2 && envelope.aad_context) {
+ return encodedStableJson(envelope.aad_context)
+ }
+
+ return fallbackAAD
+}
+
+export async function decryptMessagePayload(envelope, mailboxId) {
+ return decryptEnvelope(envelope, mailboxId, MESSAGE_AAD)
+}
+
+export async function decryptAttachmentPayload(envelope, mailboxId) {
+ return decryptEnvelope(envelope, mailboxId, ATTACHMENT_AAD)
+}
+
+export async function unwrapMailboxPrivateKey(wrappedKeyPayload, verifierPayload, passphrase) {
+ const verifierBytes = await unwrapBytes(verifierPayload, passphrase)
+ const verifierText = decoder.decode(verifierBytes)
+
+ if (verifierText !== VERIFY_TEXT) {
+ throw new Error("invalid-passphrase")
+ }
+
+ return unwrapBytes(wrappedKeyPayload, passphrase)
+}
+
+async function importAndStorePrivateKey(mailboxId, privateKeyBytes) {
+ const privateKey = await crypto.subtle.importKey(
+ "pkcs8",
+ privateKeyBytes,
+ { name: "RSA-OAEP", hash: "SHA-256" },
+ false,
+ ["decrypt"]
+ )
+ storePrivateKey(mailboxId, privateKey)
+ dispatchMailboxEvent("elektrine:private-mailbox-unlocked", mailboxId)
+}
+
+export async function unlockMailbox(mailboxId, wrappedKeyPayload, verifierPayload, passphrase) {
+ const privateKeyBytes = await unwrapMailboxPrivateKey(
+ wrappedKeyPayload,
+ verifierPayload,
+ passphrase
+ )
+ await importAndStorePrivateKey(mailboxId, privateKeyBytes)
+}
+
+export async function unlockMailboxWithMaster(mailboxId, wrappedKeyPayload, verifierPayload) {
+ const verifierBytes = await unwrapBytesWithMasterKey(verifierPayload)
+
+ if (decoder.decode(verifierBytes) !== VERIFY_TEXT) {
+ throw new Error("invalid-master-key")
+ }
+
+ const privateKeyBytes = await unwrapBytesWithMasterKey(wrappedKeyPayload)
+ await importAndStorePrivateKey(mailboxId, privateKeyBytes)
+}
+
+export function lockedStatusText(unlockMode) {
+ if (unlockMode === MASTER_MODE) {
+ return "Mailbox locked. Enter your account password to continue."
+ }
+
+ if (unlockMode === "account_password") {
+ return "Mailbox locked. Enter your account password to continue."
+ }
+
+ return "Mailbox locked."
+}
+
+export function unlockSecretLabel(unlockMode) {
+ if (unlockMode === MASTER_MODE) return "account password"
+ return unlockMode === "account_password" ? "account password" : "mailbox passphrase"
+}
+
+export function unlockSecretPlaceholder(unlockMode) {
+ if (unlockMode === MASTER_MODE) return "Account password"
+ return unlockMode === "account_password" ? "Account password" : "Mailbox passphrase"
+}
+
+export function maybeSetValue(field, nextValue, matcher) {
+ if (!field) return
+
+ const currentValue = field.value || ""
+ const shouldReplace =
+ currentValue.trim() === "" ||
+ currentValue === field.dataset.privateMailboxLastApplied ||
+ (typeof matcher === "function" && matcher(currentValue))
+
+ if (!shouldReplace) return
+
+ field.value = nextValue
+ field.dataset.privateMailboxLastApplied = nextValue
+}
diff --git a/apps/elektrine/assets/js/hooks/mailbox_private_messages_hook.js b/apps/elektrine/assets/js/hooks/mailbox_private_messages_hook.js
new file mode 100644
index 0000000..b07efb6
--- /dev/null
+++ b/apps/elektrine/assets/js/hooks/mailbox_private_messages_hook.js
@@ -0,0 +1,242 @@
+import {
+ bodyText,
+ buildSandboxedEmailHtml,
+ downloadBytes,
+ formatAttachmentMeta,
+ payloadString,
+ previewText,
+ restoreAttachmentPlaceholder,
+ sanitizeProtectedHtml
+} from "./mailbox_private_content"
+import {
+ decryptAttachmentPayload,
+ decryptMessagePayload,
+ getStoredPrivateKey,
+ notify,
+ parsePayload
+} from "./mailbox_private_storage_hooks"
+
+const decryptedAttachments = new WeakMap()
+
+export const PrivateMailboxMessages = {
+ mounted() {
+ this.mailboxId = this.el.dataset.privateMailboxId
+ this.onMailboxChange = () => this.renderPrivateContent()
+ this.onClick = (event) => this.handleClick(event)
+
+ window.addEventListener("elektrine:private-mailbox-unlocked", this.onMailboxChange)
+ window.addEventListener("elektrine:private-mailbox-locked", this.onMailboxChange)
+ this.el.addEventListener("click", this.onClick)
+
+ this.renderPrivateContent()
+ },
+
+ updated() {
+ this.mailboxId = this.el.dataset.privateMailboxId
+ this.renderPrivateContent()
+ },
+
+ destroyed() {
+ window.removeEventListener("elektrine:private-mailbox-unlocked", this.onMailboxChange)
+ window.removeEventListener("elektrine:private-mailbox-locked", this.onMailboxChange)
+ this.el.removeEventListener("click", this.onClick)
+ },
+
+ async handleClick(event) {
+ const downloadButton = event.target.closest("[data-private-attachment-download]")
+ if (!downloadButton) return
+
+ event.preventDefault()
+
+ const attachmentElement = downloadButton.closest("[data-private-attachment='true']")
+ if (!attachmentElement || !this.mailboxId || !getStoredPrivateKey(this.mailboxId)) {
+ notify("Unlock your mailbox before downloading protected attachments.")
+ return
+ }
+
+ try {
+ const payload = await this.decryptAttachmentElement(attachmentElement)
+ if (!payload?.data) {
+ notify("This protected attachment could not be decrypted.")
+ return
+ }
+
+ downloadBytes(payload.filename, payload.content_type, payload.data)
+ } catch (_error) {
+ notify("Failed to decrypt this protected attachment.")
+ }
+ },
+
+ async decryptAttachmentElement(element) {
+ const cached = decryptedAttachments.get(element)
+ if (cached) return cached
+
+ const envelope = parsePayload(element.dataset.privateAttachmentPayload)
+ if (!envelope) return null
+
+ const payload = await decryptAttachmentPayload(envelope, this.mailboxId)
+ if (payload) {
+ decryptedAttachments.set(element, payload)
+ }
+
+ return payload
+ },
+
+ async renderPrivateContent() {
+ const messageElements = Array.from(this.el.querySelectorAll("[data-private-message='true']"))
+ const attachmentElements = Array.from(
+ this.el.querySelectorAll("[data-private-attachment='true']")
+ )
+ const standaloneAddressElements = Array.from(
+ this.el.querySelectorAll("[data-private-address-payload]")
+ )
+
+ if (!this.mailboxId || !getStoredPrivateKey(this.mailboxId)) {
+ this.restorePlaceholders(messageElements)
+ standaloneAddressElements.forEach((element) => this.restoreAddressPlaceholder(element))
+ attachmentElements.forEach((element) => restoreAttachmentPlaceholder(element))
+ return
+ }
+
+ await Promise.all(
+ messageElements.map(async (element) => {
+ const envelope = parsePayload(element.dataset.privateMessagePayload)
+ if (!envelope) return
+
+ try {
+ const payload = await decryptMessagePayload(envelope, this.mailboxId)
+ if (!payload) return
+
+ const subjectEl = element.querySelector("[data-private-subject]")
+ if (subjectEl) {
+ const subject = typeof payload.subject === "string" ? payload.subject.trim() : ""
+ subjectEl.textContent = subject || "(No Subject)"
+ }
+
+ const previewEl = element.querySelector("[data-private-preview]")
+ if (previewEl) {
+ previewEl.textContent = previewText(payload)
+ }
+
+ element.querySelectorAll("[data-private-address]").forEach((addressEl) => {
+ const field = addressEl.dataset.privateAddress
+ const value = payloadString(payload, field).trim()
+
+ if (value) {
+ addressEl.textContent = value
+ }
+ })
+
+ const bodyEl = element.querySelector("[data-private-body]")
+ const iframe = element.querySelector("[data-private-html-body-iframe]")
+ const iframeContainer = element.querySelector("[data-private-html-body-container]")
+
+ if (bodyEl) {
+ bodyEl.textContent = bodyText(payload)
+ }
+
+ if (iframe && iframeContainer && typeof payload.html_body === "string" && payload.html_body.trim() !== "") {
+ iframe.srcdoc = buildSandboxedEmailHtml(sanitizeProtectedHtml(payload.html_body))
+ iframeContainer.classList.remove("hidden")
+
+ if (bodyEl) {
+ bodyEl.classList.add("hidden")
+ }
+ } else if (iframe && iframeContainer) {
+ iframe.srcdoc = ""
+ iframeContainer.classList.add("hidden")
+
+ if (bodyEl) {
+ bodyEl.classList.remove("hidden")
+ }
+ }
+ } catch (_error) {
+ this.restorePlaceholderForElement(element)
+ }
+ })
+ )
+
+ await Promise.all(
+ standaloneAddressElements.map(async (element) => {
+ const envelope = parsePayload(element.dataset.privateAddressPayload)
+ if (!envelope) return
+
+ try {
+ const payload = await decryptMessagePayload(envelope, this.mailboxId)
+ const value = payloadString(payload, element.dataset.privateAddress).trim()
+
+ if (value) {
+ element.textContent = value
+ }
+ } catch (_error) {
+ this.restoreAddressPlaceholder(element)
+ }
+ })
+ )
+
+ await Promise.all(
+ attachmentElements.map(async (element) => {
+ try {
+ const payload = await this.decryptAttachmentElement(element)
+ if (!payload) return
+
+ const filenameEl = element.querySelector("[data-private-attachment-filename]")
+ if (filenameEl) {
+ filenameEl.textContent = payload.filename || "attachment"
+ }
+
+ const metaEl = element.querySelector("[data-private-attachment-meta]")
+ if (metaEl) {
+ metaEl.textContent = formatAttachmentMeta(payload)
+ }
+
+ const labelEl = element.querySelector("[data-private-attachment-download-label]")
+ if (labelEl) {
+ labelEl.textContent = "Download"
+ }
+ } catch (_error) {
+ restoreAttachmentPlaceholder(element)
+ }
+ })
+ )
+ },
+
+ restorePlaceholders(elements) {
+ elements.forEach((element) => this.restorePlaceholderForElement(element))
+ },
+
+ restorePlaceholderForElement(element) {
+ const subjectEl = element.querySelector("[data-private-subject]")
+ if (subjectEl && subjectEl.dataset.privateSubjectPlaceholder) {
+ subjectEl.textContent = subjectEl.dataset.privateSubjectPlaceholder
+ }
+
+ const previewEl = element.querySelector("[data-private-preview]")
+ if (previewEl && previewEl.dataset.privatePreviewPlaceholder) {
+ previewEl.textContent = previewEl.dataset.privatePreviewPlaceholder
+ }
+
+ element.querySelectorAll("[data-private-address]").forEach((addressEl) => {
+ this.restoreAddressPlaceholder(addressEl)
+ })
+
+ const bodyEl = element.querySelector("[data-private-body]")
+ if (bodyEl && bodyEl.dataset.privateBodyPlaceholder) {
+ bodyEl.textContent = bodyEl.dataset.privateBodyPlaceholder
+ bodyEl.classList.remove("hidden")
+ }
+
+ const iframe = element.querySelector("[data-private-html-body-iframe]")
+ const iframeContainer = element.querySelector("[data-private-html-body-container]")
+ if (iframe && iframeContainer) {
+ iframe.srcdoc = ""
+ iframeContainer.classList.add("hidden")
+ }
+ },
+
+ restoreAddressPlaceholder(addressEl) {
+ if (addressEl.dataset.privateAddressPlaceholder) {
+ addressEl.textContent = addressEl.dataset.privateAddressPlaceholder
+ }
+ }
+}
diff --git a/apps/elektrine/assets/js/hooks/mailbox_private_storage_hooks.js b/apps/elektrine/assets/js/hooks/mailbox_private_storage_hooks.js
new file mode 100644
index 0000000..8068eea
--- /dev/null
+++ b/apps/elektrine/assets/js/hooks/mailbox_private_storage_hooks.js
@@ -0,0 +1,538 @@
+/**
+ * MailboxPrivateStorage hook: drives the lock/unlock/setup UI for a mailbox's
+ * zero-knowledge private storage. The crypto and key-management primitives live
+ * in ./mailbox_private_crypto; the public names that other modules import from
+ * here are re-exported below so their imports keep working unchanged.
+ */
+
+import { unwrapWithSecret } from "./vault_crypto"
+import * as vaultSession from "./vault_session"
+import {
+ MASTER_MODE,
+ VERIFY_TEXT,
+ cacheLoginPassword,
+ clearCachedLoginPassword,
+ clearPrivateKey,
+ dispatchMailboxEvent,
+ encoder,
+ generateMailboxKeypair,
+ getCachedLoginPassword,
+ getStoredPrivateKey,
+ isKnownUnlockMode,
+ lockedStatusText,
+ notify,
+ parsePayload,
+ unlockMailbox,
+ unlockMailboxWithMaster,
+ unlockModeFromPayload,
+ unlockSecretLabel,
+ unlockSecretPlaceholder,
+ wrapBytes,
+ wrapBytesWithMasterKey
+} from "./mailbox_private_crypto"
+
+export {
+ VERIFY_TEXT,
+ cacheLoginPassword,
+ decryptAttachmentPayload,
+ decryptMessagePayload,
+ encoder,
+ getStoredPrivateKey,
+ maybeSetValue,
+ notify,
+ parsePayload,
+ unwrapMailboxPrivateKey,
+ wrapBytes
+} from "./mailbox_private_crypto"
+
+
+export const MailboxPrivateStorage = {
+ mounted() {
+ this.captureElements()
+ this.bindEvents()
+ this.unsubscribeVault = vaultSession.subscribe(() => this.onVaultChange())
+ this.renderLockState()
+ this.renderSetupModeState()
+ void this.maybeAutoUnlock()
+ },
+
+ updated() {
+ this.captureElements()
+ this.renderLockState()
+ this.renderSetupModeState()
+ void this.maybeAutoUnlock()
+ },
+
+ destroyed() {
+ if (this.onClick) this.el.removeEventListener("click", this.onClick)
+ if (this.onChange) this.el.removeEventListener("change", this.onChange)
+ if (this.unsubscribeVault) this.unsubscribeVault()
+ },
+
+ onVaultChange() {
+ // The shared account-password vault locked or unlocked: setup gating and
+ // auto-unlock both depend on it.
+ this.renderSetupModeState()
+ if (vaultSession.isUnlocked()) {
+ this.autoUnlockFailed = false
+ this.renderLockState()
+ void this.maybeAutoUnlock()
+ } else if (this.unlockMode === MASTER_MODE && this.mailboxId) {
+ clearPrivateKey(this.mailboxId)
+ this.renderLockState()
+ }
+ },
+
+ captureElements() {
+ this.mailboxId = this.el.dataset.privateMailboxId
+ this.configured = this.el.dataset.privateMailboxConfigured === "true"
+ this.wrappedKeyPayload = parsePayload(this.el.dataset.privateMailboxWrappedKey)
+ this.verifierPayload = parsePayload(this.el.dataset.privateMailboxVerifier)
+ const datasetUnlockMode = this.el.dataset.privateMailboxUnlockMode
+
+ this.unlockMode =
+ (isKnownUnlockMode(datasetUnlockMode) ? datasetUnlockMode : null) ||
+ unlockModeFromPayload(this.wrappedKeyPayload, this.verifierPayload) ||
+ "separate_passphrase"
+ this.statusElements = Array.from(this.el.querySelectorAll("[data-private-mailbox-status]"))
+ this.lockedContentElements = Array.from(
+ this.el.querySelectorAll("[data-private-mailbox-locked-content]")
+ )
+ this.unlockedContentElements = Array.from(
+ this.el.querySelectorAll("[data-private-mailbox-unlocked-content]")
+ )
+ this.passphraseInput = this.el.querySelector("[data-private-mailbox-passphrase]")
+ this.setupForm = this.el.querySelector("[data-private-mailbox-setup-form]")
+ this.setupModeInput = this.el.querySelector("[data-private-mailbox-setup-mode]")
+ this.setupAccountPasswordInput = this.el.querySelector("[data-private-mailbox-account-password]")
+ this.accountPasswordFields = this.el.querySelector("[data-private-mailbox-account-password-fields]")
+ this.masterFields = this.el.querySelector("[data-private-mailbox-master-fields]")
+ this.masterUnlockFields = this.el.querySelector("[data-private-mailbox-master-unlock-fields]")
+ this.masterConfigured = this.el.dataset.privateMailboxMasterConfigured === "true"
+ this.masterWrappedDek = parsePayload(this.el.dataset.privateMailboxMasterWrappedDek)
+ this.customPassphraseFields = this.el.querySelector(
+ "[data-private-mailbox-custom-passphrase-fields]"
+ )
+ this.setupPassphraseInput = this.el.querySelector("[data-private-mailbox-setup-passphrase]")
+ this.setupConfirmInput = this.el.querySelector("[data-private-mailbox-setup-passphrase-confirm]")
+ this.wrappedKeyInput = this.el.querySelector("[data-private-mailbox-wrapped-key-input]")
+ this.publicKeyInput = this.el.querySelector("[data-private-mailbox-public-key-input]")
+ this.verifierInput = this.el.querySelector("[data-private-mailbox-verifier-input]")
+ this.autoUnlockSuppressed = this.autoUnlockSuppressed === true
+
+ if (this.passphraseInput) {
+ this.passphraseInput.placeholder = unlockSecretPlaceholder(this.unlockMode)
+ }
+ },
+
+ setStatusText(text) {
+ this.statusElements.forEach((element) => {
+ element.textContent = text
+ })
+ },
+
+ renderUnlockPanels(unlocked) {
+ this.lockedContentElements.forEach((element) => {
+ element.classList.toggle("hidden", unlocked)
+ })
+
+ this.unlockedContentElements.forEach((element) => {
+ element.classList.toggle("hidden", !unlocked)
+ })
+ },
+
+ bindEvents() {
+ if (this.onClick) this.el.removeEventListener("click", this.onClick)
+ if (this.onChange) this.el.removeEventListener("change", this.onChange)
+
+ this.onClick = async (event) => {
+ const unlockButton = event.target.closest("[data-private-mailbox-unlock]")
+ if (unlockButton) {
+ event.preventDefault()
+ await this.handleUnlock()
+ return
+ }
+
+ const lockButton = event.target.closest("[data-private-mailbox-lock]")
+ if (lockButton) {
+ event.preventDefault()
+ this.handleLock()
+ return
+ }
+
+ const setupButton = event.target.closest("[data-private-mailbox-setup-submit]")
+ if (setupButton) {
+ event.preventDefault()
+ await this.handleSetup()
+ return
+ }
+
+ const masterUnlockButton = event.target.closest("[data-private-mailbox-master-unlock]")
+ if (masterUnlockButton) {
+ event.preventDefault()
+ await this.handleMasterVaultUnlock()
+ }
+ }
+
+ this.onChange = (event) => {
+ const setupMode = event.target.closest("[data-private-mailbox-setup-mode]")
+ if (!setupMode) return
+
+ this.renderSetupModeState()
+ }
+
+ this.el.addEventListener("click", this.onClick)
+ this.el.addEventListener("change", this.onChange)
+ },
+
+ currentSetupMode() {
+ const selectedMode = this.setupModeInput?.value
+
+ if (isKnownUnlockMode(selectedMode)) {
+ return selectedMode
+ }
+
+ return "account_password"
+ },
+
+ renderSetupModeState() {
+ const mode = this.currentSetupMode()
+
+ if (this.accountPasswordFields) {
+ const hidden = mode !== "account_password"
+ this.accountPasswordFields.classList.toggle("hidden", hidden)
+
+ if (this.setupAccountPasswordInput) {
+ this.setupAccountPasswordInput.toggleAttribute("required", !hidden)
+ }
+ }
+
+ if (this.customPassphraseFields) {
+ const hidden = mode !== "separate_passphrase"
+ this.customPassphraseFields.classList.toggle("hidden", hidden)
+
+ if (this.setupPassphraseInput) {
+ this.setupPassphraseInput.toggleAttribute("required", !hidden)
+ }
+
+ if (this.setupConfirmInput) {
+ this.setupConfirmInput.toggleAttribute("required", !hidden)
+ }
+ }
+
+ if (this.masterFields) {
+ this.masterFields.classList.toggle("hidden", mode !== MASTER_MODE)
+ }
+
+ if (this.masterUnlockFields) {
+ // Show the inline unlock only when encrypted data is enabled but the
+ // account-password vault is locked for this tab; once unlocked, setup can proceed.
+ const showUnlock = mode === MASTER_MODE && this.masterConfigured && !vaultSession.isUnlocked()
+ this.masterUnlockFields.classList.toggle("hidden", !showUnlock)
+ }
+ },
+
+ // The dedicated master-unlock field (settings page) or, on pages that only
+ // render one unlock field (the inbox), the shared passphrase input.
+ masterPassphraseInput() {
+ return (
+ this.el.querySelector("[data-private-mailbox-master-unlock-input]") ||
+ this.el.querySelector("[data-private-mailbox-passphrase]")
+ )
+ },
+
+ async handleMasterVaultUnlock() {
+ const input = this.masterPassphraseInput()
+ const passphrase = input?.value || ""
+
+ if (!this.masterWrappedDek) {
+ this.setMasterError("Set up account-password encrypted data first.")
+ return
+ }
+
+ if (passphrase.trim() === "") {
+ this.setMasterError("Enter your account password.")
+ return
+ }
+
+ try {
+ const mdk = await unwrapWithSecret(this.masterWrappedDek, passphrase)
+ vaultSession.unlock(mdk)
+ if (input) input.value = ""
+ this.setMasterError("")
+ // The vault-change subscription re-renders setup state and auto-unlocks a
+ // configured master mailbox.
+ } catch (_error) {
+ this.setMasterError(
+ "Incorrect account password. If you just reset it, recover encrypted data at /account/encrypted-data."
+ )
+ }
+ },
+
+ setMasterError(message) {
+ this.el.querySelectorAll("[data-private-mailbox-master-error]").forEach((el) => {
+ el.textContent = message || ""
+ el.classList.toggle("hidden", !message)
+ })
+ },
+
+ // True when an auto-unlock is about to run for this panel (the encrypted-data vault
+ // is already unlocked in this tab). Used to render a quiet "unlocking" state
+ // instead of flashing the passphrase prompt on load, then swapping it out.
+ autoUnlockPending() {
+ return (
+ !this.autoUnlockSuppressed &&
+ !this.autoUnlockFailed &&
+ this.configured &&
+ this.unlockMode === MASTER_MODE &&
+ !!this.wrappedKeyPayload &&
+ !!this.verifierPayload &&
+ vaultSession.isUnlocked()
+ )
+ },
+
+ renderLockState() {
+ const hasUnlockedKey = this.mailboxId && getStoredPrivateKey(this.mailboxId)
+
+ if (hasUnlockedKey) {
+ this.setStatusText("Mailbox unlocked in memory for this tab.")
+ this.renderUnlockPanels(true)
+ return
+ }
+
+ if (!this.configured) {
+ this.setStatusText("Private storage not configured.")
+ this.renderUnlockPanels(false)
+ return
+ }
+
+ if (this.autoUnlockPending()) {
+ this.setStatusText("Unlocking private mailbox…")
+ this.renderUnlockPanels(true)
+ return
+ }
+
+ this.setStatusText(lockedStatusText(this.unlockMode))
+ this.renderUnlockPanels(false)
+ },
+
+ async maybeAutoUnlock() {
+ if (
+ this.autoUnlockSuppressed ||
+ !this.configured ||
+ !this.mailboxId ||
+ !this.wrappedKeyPayload ||
+ !this.verifierPayload ||
+ getStoredPrivateKey(this.mailboxId)
+ ) {
+ return
+ }
+
+ if (this.unlockMode === MASTER_MODE) {
+ if (!vaultSession.isUnlocked()) return
+
+ try {
+ await unlockMailboxWithMaster(
+ this.mailboxId,
+ this.wrappedKeyPayload,
+ this.verifierPayload
+ )
+ this.autoUnlockFailed = false
+ this.renderLockState()
+ } catch (_error) {
+ // encrypted data key mismatch or transient error; fall back to the manual prompt
+ this.autoUnlockFailed = true
+ this.renderLockState()
+ }
+
+ return
+ }
+
+ if (this.unlockMode !== "account_password") {
+ return
+ }
+
+ const cachedPassword = getCachedLoginPassword()
+ if (!cachedPassword) return
+
+ try {
+ await unlockMailbox(
+ this.mailboxId,
+ this.wrappedKeyPayload,
+ this.verifierPayload,
+ cachedPassword
+ )
+
+ if (this.passphraseInput) {
+ this.passphraseInput.value = ""
+ }
+
+ this.renderLockState()
+ } catch (_error) {
+ clearCachedLoginPassword()
+ this.renderLockState()
+ }
+ },
+
+ async handleUnlock() {
+ if (!this.mailboxId || !this.wrappedKeyPayload || !this.verifierPayload) {
+ notify("Private mailbox storage is not configured yet.")
+ return
+ }
+
+ if (this.unlockMode === MASTER_MODE) {
+ // If encrypted data is locked for this tab, unlock it inline with the
+ // account password before unwrapping the mailbox key.
+ if (!vaultSession.isUnlocked()) {
+ const masterInput = this.masterPassphraseInput()
+ const masterPassphrase = masterInput?.value || ""
+
+ if (!this.masterWrappedDek) {
+ notify("Set up account-password encrypted data first.")
+ return
+ }
+
+ if (masterPassphrase.trim() === "") {
+ notify("Enter your account password to unlock this mailbox.")
+ return
+ }
+
+ try {
+ const mdk = await unwrapWithSecret(this.masterWrappedDek, masterPassphrase)
+ vaultSession.unlock(mdk)
+ if (masterInput) masterInput.value = ""
+ } catch (_error) {
+ notify(
+ "Incorrect account password. If you just reset it, recover encrypted data at /account/encrypted-data."
+ )
+ return
+ }
+ }
+
+ try {
+ await unlockMailboxWithMaster(this.mailboxId, this.wrappedKeyPayload, this.verifierPayload)
+ this.autoUnlockSuppressed = false
+ this.renderLockState()
+ notify("Mailbox unlocked for this tab.", "success")
+ } catch (_error) {
+ notify(
+ "This mailbox was wrapped with an older encrypted-data key. Reset private mailbox storage in Account Settings, then set it up again."
+ )
+ }
+
+ return
+ }
+
+ const passphrase = this.passphraseInput?.value || ""
+
+ if (passphrase.trim() === "") {
+ notify(`Enter your ${unlockSecretLabel(this.unlockMode)} first.`)
+ return
+ }
+
+ try {
+ await unlockMailbox(this.mailboxId, this.wrappedKeyPayload, this.verifierPayload, passphrase)
+ if (this.unlockMode === "account_password") {
+ cacheLoginPassword(passphrase)
+ }
+
+ this.autoUnlockSuppressed = false
+ if (this.passphraseInput) this.passphraseInput.value = ""
+ this.renderLockState()
+ notify("Mailbox unlocked for this tab.", "success")
+ } catch (_error) {
+ notify(
+ `Could not unlock the mailbox. Check your ${unlockSecretLabel(this.unlockMode)} and try again.`
+ )
+ }
+ },
+
+ handleLock() {
+ if (!this.mailboxId) return
+
+ clearPrivateKey(this.mailboxId)
+ this.autoUnlockSuppressed = true
+ dispatchMailboxEvent("elektrine:private-mailbox-locked", this.mailboxId)
+ if (this.passphraseInput) this.passphraseInput.value = ""
+ this.renderLockState()
+ },
+
+ async handleSetup() {
+ const unlockMode = this.currentSetupMode()
+ const accountPassword = this.setupAccountPasswordInput?.value || ""
+ const passphrase = this.setupPassphraseInput?.value || ""
+ const confirmation = this.setupConfirmInput?.value || ""
+
+ if (!this.setupForm || !this.mailboxId) {
+ notify("Mailbox setup is unavailable right now.")
+ return
+ }
+
+ if (unlockMode === MASTER_MODE) {
+ if (!vaultSession.isUnlocked()) {
+ notify(
+ this.masterConfigured
+ ? "Enter your account password above first, then enable private storage."
+ : "Set up account-password encrypted data first."
+ )
+ return
+ }
+
+ try {
+ const { publicKeyPem, privateKey } = await generateMailboxKeypair()
+ const wrappedPrivateKey = await wrapBytesWithMasterKey(privateKey, "private_key")
+ const verifier = await wrapBytesWithMasterKey(encoder.encode(VERIFY_TEXT), "verifier")
+
+ this.wrappedKeyInput.value = JSON.stringify(wrappedPrivateKey)
+ this.publicKeyInput.value = publicKeyPem
+ this.verifierInput.value = JSON.stringify(verifier)
+ this.setupForm.requestSubmit()
+ } catch (_error) {
+ notify("Could not generate mailbox keys.")
+ }
+
+ return
+ }
+
+ if (unlockMode === "account_password") {
+ if (accountPassword.trim() === "") {
+ notify("Enter your current password to enable private mailbox storage.")
+ return
+ }
+ } else {
+ if (passphrase.length < 12) {
+ notify("Use a mailbox passphrase with at least 12 characters.")
+ return
+ }
+
+ if (passphrase !== confirmation) {
+ notify("Mailbox passphrase confirmation does not match.")
+ return
+ }
+ }
+
+ try {
+ const { publicKeyPem, privateKey } = await generateMailboxKeypair()
+ const wrappingSecret = unlockMode === "account_password" ? accountPassword : passphrase
+ const wrappedPrivateKey = await wrapBytes(privateKey, wrappingSecret, unlockMode)
+ const verifier = await wrapBytes(
+ encoder.encode(VERIFY_TEXT),
+ wrappingSecret,
+ unlockMode,
+ "verifier"
+ )
+
+ this.wrappedKeyInput.value = JSON.stringify(wrappedPrivateKey)
+ this.publicKeyInput.value = publicKeyPem
+ this.verifierInput.value = JSON.stringify(verifier)
+
+ if (unlockMode === "account_password") {
+ cacheLoginPassword(accountPassword)
+ }
+
+ this.setupForm.requestSubmit()
+ } catch (_error) {
+ notify("Could not generate mailbox keys in this browser.")
+ }
+ }
+}
diff --git a/apps/elektrine/assets/js/hooks/markdown_hooks.js b/apps/elektrine/assets/js/hooks/markdown_hooks.js
new file mode 100644
index 0000000..aab2ada
--- /dev/null
+++ b/apps/elektrine/assets/js/hooks/markdown_hooks.js
@@ -0,0 +1,308 @@
+import { sanitizeMarkdownHref } from '../markdown_helpers'
+
+export const ReplyMarkdownEditor = {
+ mounted() {
+ // Focus on the textarea when mounted (FocusOnMount functionality)
+ this.el.focus()
+
+ // Setup markdown toolbar functionality
+ this.setupReplyToolbars()
+ this.setupReplyPreview()
+ this.setupReplyTabs()
+ },
+
+ destroyed() {
+ // Clean up event listeners
+ const toolbar = document.getElementById('reply-formatting-toolbar')
+ if (toolbar && this.toolbarClickHandler) {
+ toolbar.removeEventListener('click', this.toolbarClickHandler)
+ }
+
+ if (this.tabClickHandler) {
+ document.removeEventListener('click', this.tabClickHandler)
+ }
+
+ if (this.inputHandler) {
+ this.el.removeEventListener('input', this.inputHandler)
+ }
+ },
+
+ setupReplyTabs() {
+ // Handle tab switching for reply editor
+ // Remove existing listener if present
+ if (this.tabClickHandler) {
+ document.removeEventListener('click', this.tabClickHandler)
+ }
+
+ this.tabClickHandler = (e) => {
+ const tab = e.target.closest('[data-reply-editor-tab]')
+ if (!tab) return
+
+ e.preventDefault()
+ const mode = tab.dataset.replyEditorTab
+
+ // Update tab active states
+ document.querySelectorAll('[data-reply-editor-tab]').forEach(t => {
+ t.classList.remove('tab-active')
+ })
+ tab.classList.add('tab-active')
+
+ const writeDiv = document.getElementById('reply-editor-write')
+ const previewDiv = document.getElementById('reply-editor-preview')
+ const container = document.getElementById('reply-editor-container')
+
+ switch(mode) {
+ case 'write':
+ writeDiv.classList.remove('hidden')
+ previewDiv.classList.add('hidden')
+ container.classList.remove('grid', 'grid-cols-2', 'gap-2')
+ break
+ case 'preview':
+ writeDiv.classList.add('hidden')
+ previewDiv.classList.remove('hidden')
+ container.classList.remove('grid', 'grid-cols-2', 'gap-2')
+ this.updateReplyPreview()
+ break
+ case 'split':
+ writeDiv.classList.remove('hidden')
+ previewDiv.classList.remove('hidden')
+ container.classList.add('grid', 'grid-cols-2', 'gap-2')
+ this.updateReplyPreview()
+ break
+ }
+ }
+
+ document.addEventListener('click', this.tabClickHandler)
+ },
+
+ setupReplyToolbars() {
+ // Setup toolbar for reply/forward mode
+ const toolbar = document.getElementById('reply-formatting-toolbar')
+ if (!toolbar) {
+ return
+ }
+
+ // Remove existing listener if present to prevent double-firing
+ if (this.toolbarClickHandler) {
+ toolbar.removeEventListener('click', this.toolbarClickHandler)
+ }
+
+ // Create and store the handler
+ this.toolbarClickHandler = (e) => {
+ const button = e.target.closest('[data-markdown-format]')
+ if (!button) return
+
+ e.preventDefault()
+
+ const format = button.dataset.markdownFormat
+ const target = button.dataset.target
+ const textarea = target ? document.getElementById(target) : this.el
+
+ if (textarea) {
+ this.insertFormatInTarget(format, textarea)
+ // Update preview if visible
+ this.updateReplyPreview()
+ }
+ }
+
+ toolbar.addEventListener('click', this.toolbarClickHandler)
+ },
+
+ setupReplyPreview() {
+ // Auto-update preview if it's visible for reply mode
+ // Remove existing listener if present
+ if (this.inputHandler) {
+ this.el.removeEventListener('input', this.inputHandler)
+ }
+
+ this.inputHandler = () => {
+ this.updateReplyPreview()
+ }
+
+ this.el.addEventListener('input', this.inputHandler)
+ },
+
+ updateReplyPreview() {
+ const previewContent = document.getElementById('reply-preview-content')
+ const previewDiv = document.getElementById('reply-editor-preview')
+
+ if (previewContent && previewDiv && !previewDiv.classList.contains('hidden')) {
+ const html = this.markdownToHtml(this.el.value)
+ previewContent.innerHTML = html || 'Nothing to preview yet...
'
+ }
+ },
+
+ insertFormatInTarget(format, textarea) {
+ const start = textarea.selectionStart
+ const end = textarea.selectionEnd
+ const selectedText = textarea.value.substring(start, end)
+ let replacement = ''
+
+ switch (format) {
+ case 'bold':
+ replacement = `**${selectedText || 'bold text'}**`
+ break
+ case 'italic':
+ replacement = `*${selectedText || 'italic text'}*`
+ break
+ case 'code':
+ replacement = `\`${selectedText || 'code'}\``
+ break
+ case 'h1':
+ case 'heading':
+ replacement = `# ${selectedText || 'Heading 1'}`
+ break
+ case 'h2':
+ replacement = `## ${selectedText || 'Heading 2'}`
+ break
+ case 'h3':
+ replacement = `### ${selectedText || 'Heading 3'}`
+ break
+ case 'link':
+ const url = selectedText.startsWith('http') ? selectedText : 'https://example.com'
+ const linkText = selectedText.startsWith('http') ? 'link text' : selectedText || 'link text'
+ replacement = `[${linkText}](${url})`
+ break
+ case 'list':
+ case 'list-bullet':
+ replacement = `- ${selectedText || 'list item'}`
+ break
+ case 'list-number':
+ replacement = `1. ${selectedText || 'list item'}`
+ break
+ case 'quote':
+ replacement = `> ${selectedText || 'quoted text'}`
+ break
+ case 'code-block':
+ replacement = `\`\`\`\n${selectedText || 'code here'}\n\`\`\``
+ break
+ }
+
+ textarea.value = textarea.value.substring(0, start) + replacement + textarea.value.substring(end)
+ textarea.focus()
+
+ // Set cursor position
+ const newPos = start + replacement.length
+ textarea.setSelectionRange(newPos, newPos)
+ },
+
+ toggleReplyPreview(textarea) {
+ const previewPanel = document.getElementById('reply-preview-panel')
+ const previewContent = document.getElementById('reply-preview-content')
+
+ if (!previewPanel || !previewContent) return
+
+ if (previewPanel.classList.contains('hidden')) {
+ // Show preview
+ const markdown = textarea.value
+ const html = this.markdownToHtml(markdown)
+ previewContent.innerHTML = html
+ previewPanel.classList.remove('hidden')
+ } else {
+ // Hide preview
+ previewPanel.classList.add('hidden')
+ }
+ },
+
+ markdownToHtml(markdown) {
+ if (!markdown) return ''
+
+ // Process markdown line by line for better handling
+ const lines = markdown.split('\n')
+ const htmlLines = []
+ let inCodeBlock = false
+ let codeBlockLines = []
+
+ for (let line of lines) {
+ // Handle code blocks
+ if (line.startsWith('```')) {
+ if (inCodeBlock) {
+ htmlLines.push(`${codeBlockLines.join('\n')} `)
+ codeBlockLines = []
+ inCodeBlock = false
+ } else {
+ inCodeBlock = true
+ }
+ continue
+ }
+
+ if (inCodeBlock) {
+ codeBlockLines.push(this.escapeHtml(line))
+ continue
+ }
+
+ // Process individual line
+ let processedLine = line
+
+ // Headers (must be at line start)
+ if (line.startsWith('### ')) {
+ processedLine = `${this.escapeHtml(line.substring(4))} `
+ } else if (line.startsWith('## ')) {
+ processedLine = `${this.escapeHtml(line.substring(3))} `
+ } else if (line.startsWith('# ')) {
+ processedLine = `${this.escapeHtml(line.substring(2))} `
+ }
+ // Blockquotes
+ else if (line.startsWith('> ')) {
+ processedLine = `${this.processMarkdownInline(line.substring(2))} `
+ }
+ // Unordered lists
+ else if (line.startsWith('- ') || line.startsWith('* ')) {
+ processedLine = `${this.processMarkdownInline(line.substring(2))} `
+ }
+ // Ordered lists
+ else if (/^\d+\. /.test(line)) {
+ processedLine = `${this.processMarkdownInline(line.replace(/^\d+\. /, ''))} `
+ }
+ // Regular paragraph
+ else if (line.trim() !== '') {
+ processedLine = `${this.processMarkdownInline(line)}
`
+ } else {
+ processedLine = ''
+ }
+
+ if (processedLine) {
+ htmlLines.push(processedLine)
+ }
+ }
+
+ // Handle any unclosed code block
+ if (inCodeBlock && codeBlockLines.length > 0) {
+ htmlLines.push(`${codeBlockLines.join('\n')} `)
+ }
+
+ // Wrap consecutive list items in ul/ol tags
+ let finalHtml = htmlLines.join('\n')
+ finalHtml = finalHtml.replace(/([\s\S]*?<\/li>\n?)+/g, (match) => {
+ return ``
+ })
+
+ return finalHtml
+ },
+
+ processMarkdownInline(text) {
+ return this.escapeHtml(text)
+ // Bold (must come before italic)
+ .replace(/\*\*([^*]+)\*\*/g, '$1 ')
+ // Italic
+ .replace(/\*([^*]+)\*/g, '$1 ')
+ // Inline code
+ .replace(/`([^`]+)`/g, '$1')
+ // Links
+ .replace(/\[([^\]]+)\]\(([^\)]+)\)/g, (match, label, href) => {
+ const safeHref = sanitizeMarkdownHref(href)
+
+ if (!safeHref) {
+ return match
+ }
+
+ return `${label} `
+ })
+ },
+
+ escapeHtml(text) {
+ const div = document.createElement('div')
+ div.textContent = text
+ return div.innerHTML
+ }
+}
diff --git a/apps/elektrine/assets/js/hooks/nerve_hooks.js b/apps/elektrine/assets/js/hooks/nerve_hooks.js
new file mode 100644
index 0000000..d17e065
--- /dev/null
+++ b/apps/elektrine/assets/js/hooks/nerve_hooks.js
@@ -0,0 +1,275 @@
+/**
+ * Nerve - zero-knowledge password entries, now keyed by the account
+ * password. Unlock is shared via vault_session; entries are encrypted/decrypted
+ * with the Nerve subkey of the encrypted data key. The server only ever stores ciphertext.
+ */
+
+import { encryptValue, decryptValue, unwrapWithSecret } from "./vault_crypto"
+import * as vaultSession from "./vault_session"
+
+const FEATURE = "nerve"
+const PASSWORD_LENGTH = 24
+const PASSWORD_ALPHABET =
+ "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnpqrstuvwxyz23456789!@#$%^&*-_"
+
+const metadataAad = () => ({ purpose: "elektrine-nerve-metadata" })
+
+function entryAad(metadata, field) {
+ return {
+ purpose: "elektrine-nerve-entry",
+ field,
+ title: (metadata.title || "").trim(),
+ login_username: (metadata.login_username || "").trim(),
+ website: (metadata.website || "").trim()
+ }
+}
+
+export const Nerve = {
+ mounted() {
+ this.unsubscribe = vaultSession.subscribe(() => this.onLockChange())
+ this.bind()
+ this.onLockChange()
+ },
+
+ destroyed() {
+ this.unsubscribe && this.unsubscribe()
+ },
+
+ bind() {
+ this.el.addEventListener("click", (event) => {
+ if (event.target.closest("[data-vault-unlock]")) return this.unlock()
+ if (event.target.closest("[data-vault-lock]")) return vaultSession.lock()
+ if (event.target.closest("[data-nerve-toggle-password]")) return this.togglePasswordVisibility()
+ if (event.target.closest("[data-nerve-generate]")) return this.generatePassword()
+ if (event.target.closest("[data-nerve-entry-submit]")) {
+ event.preventDefault()
+ return this.saveEntry()
+ }
+ const reveal = event.target.closest("[data-nerve-reveal]")
+ if (reveal) {
+ event.preventDefault()
+ return this.toggleReveal(reveal)
+ }
+ })
+ },
+
+ // --- lock state ---
+
+ onLockChange() {
+ const unlocked = vaultSession.isUnlocked()
+ const status = this.el.querySelector("[data-vault-status]")
+ if (status) {
+ status.textContent = unlocked
+ ? status.dataset.unlockedLabel || "Unlocked."
+ : status.dataset.lockedLabel || "Locked."
+ }
+
+ if (unlocked) this.decryptVisibleMetadata()
+ },
+
+ setError(message) {
+ const el = this.el.querySelector("[data-vault-error]")
+ if (el) el.textContent = message || ""
+ },
+
+ wrappedDek() {
+ const raw = this.el.dataset.vaultWrappedDek
+ if (!raw) return null
+ try {
+ return JSON.parse(raw)
+ } catch (_error) {
+ return null
+ }
+ },
+
+ async unlock() {
+ this.setError("")
+ const input = this.el.querySelector("[data-vault-unlock-input]")
+ const wrapped = this.wrappedDek()
+ if (!wrapped) return this.setError("Set up account-password encryption first.")
+
+ try {
+ const mdk = await unwrapWithSecret(wrapped, input ? input.value : "")
+ vaultSession.unlock(mdk)
+ if (input) input.value = ""
+ } catch (_error) {
+ this.setError(
+ "Incorrect account password. If you just reset it, recover encrypted data at /account/encrypted-data."
+ )
+ }
+ },
+
+ // --- entries ---
+
+ async key() {
+ return vaultSession.featureKey(FEATURE)
+ },
+
+ async decryptMetadata(row, key) {
+ const raw = row.dataset.encryptedMetadata
+ if (!raw) return {}
+ const payload = JSON.parse(raw)
+ return JSON.parse(await decryptValue(payload, key, metadataAad()))
+ },
+
+ async decryptVisibleMetadata() {
+ let key
+ try {
+ key = await this.key()
+ } catch (_error) {
+ return
+ }
+
+ const rows = this.el.querySelectorAll("[data-encrypted-metadata]")
+ for (const row of rows) {
+ try {
+ const metadata = await this.decryptMetadata(row, key)
+ this.setText(row, "[data-nerve-title-output]", metadata.title)
+ this.setText(row, "[data-nerve-username-output]", metadata.login_username)
+ this.setText(row, "[data-nerve-website-output]", metadata.website)
+ } catch (_error) {
+ // leave the placeholder if a row fails to decrypt
+ }
+ }
+ },
+
+ async saveEntry() {
+ this.setError("")
+ if (!vaultSession.isUnlocked()) return this.setError("Enter your account password first.")
+
+ const form = this.el.querySelector("#nerve-entry-form")
+ const metadata = {
+ title: this.fieldValue('[name="entry[title]"]'),
+ login_username: this.fieldValue('[name="entry[login_username]"]'),
+ website: this.fieldValue('[name="entry[website]"]')
+ }
+ const password = this.value("[data-nerve-password-input]")
+ const notes = this.value("[data-nerve-notes-input]")
+
+ if (!password) return this.setError("A password is required.")
+
+ try {
+ const key = await this.key()
+
+ this.setHidden(
+ "[data-nerve-encrypted-metadata]",
+ await encryptValue(JSON.stringify(metadata), key, metadataAad())
+ )
+ this.setHidden(
+ "[data-nerve-encrypted-password]",
+ await encryptValue(password, key, entryAad(metadata, "password"))
+ )
+ this.setHidden(
+ "[data-nerve-encrypted-notes]",
+ notes ? await encryptValue(notes, key, entryAad(metadata, "notes")) : null
+ )
+
+ // Never let the plaintext reach the server.
+ this.setValue("[data-nerve-password-input]", "")
+ this.setValue("[data-nerve-notes-input]", "")
+ form.requestSubmit()
+ } catch (_error) {
+ this.setError("Could not encrypt this entry.")
+ }
+ },
+
+ toggleReveal(button) {
+ const id = button.dataset.nerveReveal
+ const secretRow = this.el.querySelector(`[data-nerve-secret-row="${id}"]`)
+ if (!secretRow) return
+
+ if (!secretRow.classList.contains("hidden")) {
+ secretRow.classList.add("hidden")
+ return
+ }
+
+ if (!vaultSession.isUnlocked()) return this.setError("Enter your account password first.")
+
+ this.pushEvent("load_secret", { id }, (reply) => this.showSecret(button, secretRow, reply))
+ },
+
+ async showSecret(button, secretRow, reply) {
+ if (!reply || reply.status !== "ok") return
+
+ try {
+ const key = await this.key()
+ const row = button.closest("[data-encrypted-metadata]") || button
+ const metadata = await this.decryptMetadata(row, key)
+
+ const passwordOut = secretRow.querySelector("[data-nerve-password-output]")
+ if (passwordOut && reply.encrypted_password) {
+ passwordOut.textContent = await decryptValue(
+ JSON.parse(reply.encrypted_password),
+ key,
+ entryAad(metadata, "password")
+ )
+ }
+
+ const notesWrapper = secretRow.querySelector("[data-nerve-notes-wrapper]")
+ const notesOut = secretRow.querySelector("[data-nerve-notes-output]")
+ if (notesOut && reply.encrypted_notes) {
+ notesOut.textContent = await decryptValue(
+ JSON.parse(reply.encrypted_notes),
+ key,
+ entryAad(metadata, "notes")
+ )
+ notesWrapper && notesWrapper.classList.remove("hidden")
+ }
+
+ secretRow.classList.remove("hidden")
+ } catch (_error) {
+ this.setError("Could not decrypt this entry.")
+ }
+ },
+
+ generatePassword() {
+ const bytes = new Uint8Array(PASSWORD_LENGTH)
+ crypto.getRandomValues(bytes)
+ const password = Array.from(bytes, (b) => PASSWORD_ALPHABET[b % PASSWORD_ALPHABET.length]).join("")
+ this.setValue("[data-nerve-password-input]", password)
+ // Reveal it so the user can see/copy what was generated.
+ this.setPasswordVisible(true)
+ },
+
+ togglePasswordVisibility() {
+ const input = this.el.querySelector("[data-nerve-password-input]")
+ this.setPasswordVisible(input && input.type === "password")
+ },
+
+ setPasswordVisible(visible) {
+ const input = this.el.querySelector("[data-nerve-password-input]")
+ if (!input) return
+ input.type = visible ? "text" : "password"
+
+ const show = this.el.querySelector("[data-nerve-eye-show]")
+ const hide = this.el.querySelector("[data-nerve-eye-hide]")
+ if (show) show.classList.toggle("hidden", visible)
+ if (hide) hide.classList.toggle("hidden", !visible)
+ },
+
+ // --- small DOM helpers ---
+
+ value(selector) {
+ const el = this.el.querySelector(selector)
+ return el ? el.value : ""
+ },
+
+ fieldValue(selector) {
+ const el = this.el.querySelector(`#nerve-entry-form ${selector}`)
+ return el ? el.value : ""
+ },
+
+ setValue(selector, value) {
+ const el = this.el.querySelector(selector)
+ if (el) el.value = value
+ },
+
+ setHidden(selector, payload) {
+ this.setValue(selector, payload ? JSON.stringify(payload) : "")
+ },
+
+ setText(scope, selector, text) {
+ const target = scope.querySelector(selector)
+ if (target && typeof text === "string") target.textContent = text
+ }
+}
diff --git a/apps/elektrine/assets/js/hooks/notification_hooks.js b/apps/elektrine/assets/js/hooks/notification_hooks.js
new file mode 100644
index 0000000..65a9a21
--- /dev/null
+++ b/apps/elektrine/assets/js/hooks/notification_hooks.js
@@ -0,0 +1,75 @@
+// Notification-related LiveView hooks
+import { showNotificationFromPayload } from '../notification_system'
+
+export const NotificationHandler = {
+ mounted() {
+ // Store reference to current loading notifications for updates
+ this.loadingNotifications = new Map()
+
+ this.handleEvent("show_notification", (data) => {
+ const { id, type } = data
+ const notification = showNotificationFromPayload(data)
+
+ // Store loading notifications by ID for later updates
+ if (id && type === 'loading' && notification) {
+ this.loadingNotifications.set(id, notification)
+ }
+ })
+
+ // Handle updating a loading notification
+ this.handleEvent("update_notification", ({id, message, title}) => {
+ const notification = this.loadingNotifications.get(id)
+ if (notification) {
+ const msgEl = notification.querySelector('.text-sm')
+ if (msgEl) msgEl.textContent = message
+ if (title) {
+ const titleEl = notification.querySelector('h3')
+ if (titleEl) titleEl.textContent = title
+ }
+ }
+ })
+
+ // Handle dismissing a specific notification
+ this.handleEvent("dismiss_notification", ({id}) => {
+ const notification = this.loadingNotifications.get(id)
+ if (notification && notification.dismiss) {
+ notification.dismiss()
+ this.loadingNotifications.delete(id)
+ }
+ })
+
+ // Handle completing a loading notification (transition to success/error)
+ this.handleEvent("complete_notification", ({id, type, message, title}) => {
+ const notification = this.loadingNotifications.get(id)
+ if (notification && notification.dismiss) {
+ notification.dismiss()
+ this.loadingNotifications.delete(id)
+ showNotificationFromPayload({
+ message,
+ type: type || 'success',
+ title
+ })
+ }
+ })
+
+ // Listen for notification action events from JavaScript
+ this.actionHandler = (e) => {
+ const { event, data } = e.detail
+ if (event) {
+ this.pushEvent(event, data || {})
+ }
+ }
+ window.addEventListener('phx:notification_action', this.actionHandler)
+ },
+
+ destroyed() {
+ if (this.actionHandler) {
+ window.removeEventListener('phx:notification_action', this.actionHandler)
+ }
+ // Dismiss any remaining loading notifications
+ this.loadingNotifications?.forEach(notification => {
+ if (notification.dismiss) notification.dismiss()
+ })
+ }
+}
+
diff --git a/apps/elektrine/assets/js/hooks/notification_visibility.js b/apps/elektrine/assets/js/hooks/notification_visibility.js
new file mode 100644
index 0000000..8218e1c
--- /dev/null
+++ b/apps/elektrine/assets/js/hooks/notification_visibility.js
@@ -0,0 +1,124 @@
+// Hook for marking notifications as read when they become visible
+export const NotificationVisibility = {
+ mounted() {
+ this.observer = null
+ this.markedAsRead = new Set()
+ this.pendingMarkAsRead = []
+
+ this.setupIntersectionObserver()
+ },
+
+ autoMarkReadEnabled() {
+ return this.el.dataset.autoMarkRead !== 'false'
+ },
+
+ setupIntersectionObserver() {
+ if (!this.autoMarkReadEnabled()) {
+ return
+ }
+
+ // Options for the intersection observer
+ const options = {
+ root: null, // Use the viewport as the root
+ rootMargin: '0px',
+ threshold: 0.1 // Trigger when just 10% of the notification is visible
+ }
+
+ // Create the observer
+ this.observer = new IntersectionObserver((entries) => {
+ entries.forEach(entry => {
+ if (entry.isIntersecting) {
+ const notificationEl = entry.target
+ const isUnread = notificationEl.dataset.unread === 'true'
+ const notificationIds = this.notificationIdsForElement(notificationEl)
+
+ if (!isUnread || notificationIds.length === 0) {
+ return
+ }
+
+ const idsToQueue = notificationIds.filter(id => !this.markedAsRead.has(id))
+
+ if (idsToQueue.length > 0) {
+ idsToQueue.forEach(id => {
+ this.markedAsRead.add(id)
+ this.pendingMarkAsRead.push(id)
+ })
+
+ // Update the data attribute to prevent re-marking
+ notificationEl.dataset.unread = 'false'
+ }
+ }
+ })
+
+ // Send event to mark visible notifications as read
+ if (this.pendingMarkAsRead.length > 0) {
+ // Debounce the marking to avoid too many server calls
+ clearTimeout(this.markTimeout)
+ this.markTimeout = setTimeout(() => {
+ // Send all pending notifications at once
+ const idsToMark = [...this.pendingMarkAsRead]
+ this.pendingMarkAsRead = []
+
+ this.pushEvent('mark_visible_as_read', {
+ notification_ids: idsToMark
+ })
+ }, 100) // Reduced to 100ms for faster response
+ }
+ }, options)
+
+ // Observe all notification cards
+ this.observeNotifications()
+ },
+
+ notificationIdsForElement(notificationEl) {
+ const groupedIds = notificationEl.dataset.notificationIds
+
+ if (groupedIds) {
+ return groupedIds
+ .split(',')
+ .map(id => id.trim())
+ .filter(Boolean)
+ }
+
+ const singleId = notificationEl.dataset.notificationId
+ return singleId ? [singleId] : []
+ },
+
+ observeNotifications() {
+ const notifications = this.el.querySelectorAll('[data-notification-id], [data-notification-ids]')
+
+ notifications.forEach(notification => {
+ // Only observe unread notifications
+ if (notification.dataset.unread === 'true') {
+ this.observer.observe(notification)
+ }
+ })
+ },
+
+ updated() {
+ if (!this.autoMarkReadEnabled()) {
+ if (this.observer) {
+ this.observer.disconnect()
+ this.observer = null
+ }
+
+ return
+ }
+
+ if (!this.observer) {
+ this.setupIntersectionObserver()
+ return
+ }
+
+ // When the DOM updates, check for new notifications to observe
+ this.observeNotifications()
+ },
+
+ destroyed() {
+ // Clean up the observer when the component is destroyed
+ if (this.observer) {
+ this.observer.disconnect()
+ }
+ clearTimeout(this.markTimeout)
+ }
+}
diff --git a/apps/elektrine/assets/js/hooks/paige_search_hook.js b/apps/elektrine/assets/js/hooks/paige_search_hook.js
new file mode 100644
index 0000000..cd82571
--- /dev/null
+++ b/apps/elektrine/assets/js/hooks/paige_search_hook.js
@@ -0,0 +1,73 @@
+/**
+ * Keyboard UX for the Paige search page.
+ *
+ * - "/" or Cmd/Ctrl+K focuses the search input from anywhere on the page.
+ * - ArrowDown/ArrowUp move between the input and suggestion items.
+ * - Escape closes suggestions and returns focus to the input.
+ *
+ * Client-side only: no per-keystroke events are sent to the server
+ * (paige_live_test.exs asserts the input has no keyup bindings).
+ */
+export const PaigeSearch = {
+ mounted() {
+ this.onWindowKeydown = (event) => {
+ const input = this.input()
+ if (!input) return
+
+ const commandK = (event.metaKey || event.ctrlKey) && event.key?.toLowerCase() === "k"
+ const slash = event.key === "/" || event.code === "Slash"
+
+ if (commandK || (slash && !this.isEditableElement(event.target))) {
+ event.preventDefault()
+ event.stopPropagation()
+ input.focus()
+ input.select()
+ }
+ }
+
+ this.onKeydown = (event) => {
+ if (event.key === "Escape") {
+ this.pushEvent("clear_suggestions", {})
+ this.input()?.focus()
+ return
+ }
+
+ if (event.key !== "ArrowDown" && event.key !== "ArrowUp") return
+
+ const items = Array.from(this.el.querySelectorAll("[data-suggestion-item]"))
+ if (items.length === 0) return
+
+ event.preventDefault()
+ const index = items.indexOf(document.activeElement)
+
+ if (event.key === "ArrowDown") {
+ const next = index < 0 ? items[0] : items[Math.min(index + 1, items.length - 1)]
+ next.focus()
+ } else if (index === 0) {
+ this.input()?.focus()
+ } else if (index > 0) {
+ items[index - 1].focus()
+ }
+ }
+
+ window.addEventListener("keydown", this.onWindowKeydown, { capture: true })
+ this.el.addEventListener("keydown", this.onKeydown)
+ },
+
+ destroyed() {
+ window.removeEventListener("keydown", this.onWindowKeydown, { capture: true })
+ this.el.removeEventListener("keydown", this.onKeydown)
+ },
+
+ input() {
+ return this.el.querySelector("#global-search-input")
+ },
+
+ isEditableElement(element) {
+ if (!(element instanceof HTMLElement)) return false
+
+ return Boolean(
+ element.closest("input, textarea, select, button, a, [contenteditable='true']")
+ )
+ },
+}
diff --git a/apps/elektrine/assets/js/hooks/passkey_hooks.js b/apps/elektrine/assets/js/hooks/passkey_hooks.js
new file mode 100644
index 0000000..d6c38c7
--- /dev/null
+++ b/apps/elektrine/assets/js/hooks/passkey_hooks.js
@@ -0,0 +1,278 @@
+/**
+ * Passkey Hooks
+ * WebAuthn/Passkey integration for passwordless authentication.
+ */
+
+import { submitFormPreservingEvents } from '../utils/form_submission'
+
+/**
+ * Helper to convert ArrayBuffer to Base64URL string
+ */
+function bufferToBase64URL(buffer) {
+ const bytes = new Uint8Array(buffer)
+ let binary = ''
+ for (let i = 0; i < bytes.byteLength; i++) {
+ binary += String.fromCharCode(bytes[i])
+ }
+ return btoa(binary)
+ .replace(/\+/g, '-')
+ .replace(/\//g, '_')
+ .replace(/=/g, '')
+}
+
+/**
+ * Helper to convert Base64URL string to ArrayBuffer
+ */
+function base64URLToBuffer(base64url) {
+ // Add padding if needed
+ const padding = '='.repeat((4 - (base64url.length % 4)) % 4)
+ const base64 = base64url
+ .replace(/-/g, '+')
+ .replace(/_/g, '/')
+ + padding
+ const binary = atob(base64)
+ const bytes = new Uint8Array(binary.length)
+ for (let i = 0; i < binary.length; i++) {
+ bytes[i] = binary.charCodeAt(i)
+ }
+ return bytes.buffer
+}
+
+/**
+ * Check if WebAuthn is supported
+ */
+function isWebAuthnSupported() {
+ return !!(window.PublicKeyCredential)
+}
+
+/**
+ * PasskeyRegister Hook
+ * Handles WebAuthn registration for adding new passkeys.
+ *
+ * Usage:
+ */
+export const PasskeyRegister = {
+ mounted() {
+ // Check WebAuthn support on mount
+ if (!isWebAuthnSupported()) {
+ this.el.disabled = true
+ this.el.title = 'Passkeys are not supported in this browser'
+ }
+
+ // Listen for registration challenge from server
+ this.handleEvent('passkey_registration_challenge', async (data) => {
+ try {
+ await this.handleRegistration(data)
+ } catch (error) {
+ this.pushEvent('passkey_registration_error', {
+ error: error.name === 'NotAllowedError'
+ ? 'Registration was cancelled or timed out'
+ : error.message || 'Registration failed'
+ })
+ }
+ })
+ },
+
+ async handleRegistration(challengeData) {
+ // Build the credential creation options
+ const publicKey = {
+ challenge: base64URLToBuffer(challengeData.challenge_b64),
+ rp: {
+ id: challengeData.rp_id,
+ name: challengeData.rp_name
+ },
+ user: {
+ id: base64URLToBuffer(challengeData.user_id),
+ name: challengeData.user_name,
+ displayName: challengeData.user_display_name
+ },
+ pubKeyCredParams: challengeData.pub_key_cred_params.map(p => ({
+ type: p.type,
+ alg: p.alg
+ })),
+ timeout: challengeData.timeout,
+ attestation: challengeData.attestation || 'none',
+ authenticatorSelection: {
+ residentKey: challengeData.authenticator_selection?.resident_key || 'preferred',
+ userVerification: challengeData.authenticator_selection?.user_verification || 'preferred'
+ },
+ excludeCredentials: (challengeData.exclude_credentials || []).map(cred => ({
+ type: cred.type,
+ id: base64URLToBuffer(cred.id),
+ transports: cred.transports
+ }))
+ }
+
+ // Request credential creation from browser/authenticator
+ const credential = await navigator.credentials.create({ publicKey })
+
+ // Extract and encode the response
+ const response = credential.response
+ const attestationResponse = {
+ id: credential.id,
+ rawId: bufferToBase64URL(credential.rawId),
+ type: credential.type,
+ response: {
+ clientDataJSON: bufferToBase64URL(response.clientDataJSON),
+ attestationObject: bufferToBase64URL(response.attestationObject)
+ }
+ }
+
+ // Include transports if available (for future authentication hints)
+ if (response.getTransports) {
+ attestationResponse.response.transports = response.getTransports()
+ }
+
+ // Send to server for verification
+ this.pushEvent('passkey_registration_response', {
+ attestation: attestationResponse,
+ name: challengeData.suggested_name || null
+ })
+ }
+}
+
+/**
+ * PasskeyAuth Hook
+ * Handles WebAuthn authentication for passkey login.
+ *
+ * Usage: Sign in with passkey
+ */
+export const PasskeyAuth = {
+ mounted() {
+ // Check WebAuthn support
+ if (!isWebAuthnSupported()) {
+ this.el.disabled = true
+ this.el.title = 'Passkeys are not supported in this browser'
+ return
+ }
+
+ // Handle click to start authentication
+ this.handleClick = async () => {
+ this.el.disabled = true
+ try {
+ // Request challenge from server
+ const challengeData = await this.getChallenge()
+ if (challengeData.error) {
+ throw new Error(challengeData.error)
+ }
+ await this.handleAuthentication(challengeData)
+ } catch (error) {
+ if (window.showNotification) {
+ window.showNotification(
+ error.name === 'NotAllowedError'
+ ? 'Authentication was cancelled or timed out'
+ : error.message || 'Authentication failed',
+ 'error'
+ )
+ }
+ } finally {
+ this.el.disabled = false
+ }
+ }
+
+ this.el.addEventListener('click', this.handleClick)
+ },
+
+ async getChallenge() {
+ return new Promise((resolve) => {
+ // Set up one-time event handler for challenge response
+ const challengeRef = this.handleEvent('passkey_auth_challenge', (data) => {
+ this.removeHandleEvent(challengeRef)
+ if (this.challengeEventRef === challengeRef) {
+ this.challengeEventRef = null
+ }
+ resolve(data)
+ })
+
+ this.challengeEventRef = challengeRef
+
+ // Request challenge
+ this.pushEvent('get_passkey_challenge', {})
+ })
+ },
+
+ async handleAuthentication(challengeData) {
+ // Build the credential request options
+ const publicKey = {
+ challenge: base64URLToBuffer(challengeData.challenge_b64),
+ rpId: challengeData.rp_id,
+ timeout: challengeData.timeout,
+ userVerification: challengeData.user_verification || 'preferred',
+ allowCredentials: (challengeData.allow_credentials || []).map(cred => ({
+ type: cred.type,
+ id: base64URLToBuffer(cred.id),
+ transports: cred.transports
+ }))
+ }
+
+ // Request authentication from browser/authenticator
+ const credential = await navigator.credentials.get({ publicKey })
+
+ // Extract and encode the response
+ const response = credential.response
+ const assertionResponse = {
+ id: credential.id,
+ rawId: bufferToBase64URL(credential.rawId),
+ type: credential.type,
+ response: {
+ clientDataJSON: bufferToBase64URL(response.clientDataJSON),
+ authenticatorData: bufferToBase64URL(response.authenticatorData),
+ signature: bufferToBase64URL(response.signature)
+ }
+ }
+
+ // Include userHandle if present (for discoverable credentials)
+ if (response.userHandle) {
+ assertionResponse.response.userHandle = bufferToBase64URL(response.userHandle)
+ }
+
+ // Submit to server via form POST for proper session handling
+ this.submitAuthentication(assertionResponse, challengeData.challenge_b64)
+ },
+
+ submitAuthentication(assertion, challenge) {
+ // Create a form to POST the assertion (this ensures proper cookie handling)
+ const form = document.createElement('form')
+ form.method = 'POST'
+ form.action = '/passkey/authenticate'
+ form.style.display = 'none'
+
+ // Add CSRF token
+ const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content
+ if (csrfToken) {
+ const csrfInput = document.createElement('input')
+ csrfInput.type = 'hidden'
+ csrfInput.name = '_csrf_token'
+ csrfInput.value = csrfToken
+ form.appendChild(csrfInput)
+ }
+
+ // Add assertion data
+ const assertionInput = document.createElement('input')
+ assertionInput.type = 'hidden'
+ assertionInput.name = 'assertion'
+ assertionInput.value = JSON.stringify(assertion)
+ form.appendChild(assertionInput)
+
+ // Add challenge
+ const challengeInput = document.createElement('input')
+ challengeInput.type = 'hidden'
+ challengeInput.name = 'challenge'
+ challengeInput.value = challenge
+ form.appendChild(challengeInput)
+
+ document.body.appendChild(form)
+ submitFormPreservingEvents(form)
+ },
+
+ destroyed() {
+ if (this.handleClick) {
+ this.el.removeEventListener('click', this.handleClick)
+ }
+
+ if (this.challengeEventRef) {
+ this.removeHandleEvent(this.challengeEventRef)
+ this.challengeEventRef = null
+ }
+ }
+}
diff --git a/apps/elektrine/assets/js/hooks/portal_dropdowns.js b/apps/elektrine/assets/js/hooks/portal_dropdowns.js
new file mode 100644
index 0000000..e0c0e07
--- /dev/null
+++ b/apps/elektrine/assets/js/hooks/portal_dropdowns.js
@@ -0,0 +1,272 @@
+import { OverlayPortal } from '../utils/overlay_portal'
+
+const activePortalDropdowns = new Set()
+const portalDropdownStates = new WeakMap()
+let portalDropdownGlobalListenersBound = false
+let activePortalTooltip = null
+
+function ensurePortalDropdownGlobalListeners() {
+ if (portalDropdownGlobalListenersBound || typeof window === 'undefined') return
+
+ portalDropdownGlobalListenersBound = true
+ // js-check: allow-global-listener-singleton
+ document.addEventListener('click', handlePortalDropdownDocumentClick)
+ document.addEventListener('pointerdown', handlePortalDropdownDocumentPointerDown)
+ document.addEventListener('keydown', handlePortalDropdownDocumentKeyDown)
+ document.addEventListener('mouseover', handlePortalTooltipMouseOver)
+ document.addEventListener('mouseout', handlePortalTooltipMouseOut)
+ document.addEventListener('focusin', handlePortalTooltipFocusIn)
+ document.addEventListener('focusout', handlePortalTooltipFocusOut)
+ window.addEventListener('scroll', repositionActivePortalDropdowns, true)
+ window.addEventListener('resize', repositionActivePortalDropdowns)
+}
+
+function handlePortalDropdownDocumentClick(event) {
+ const target = event.target instanceof Element ? event.target : null
+ const trigger = target?.closest?.('[data-portal-dropdown-trigger]')
+ const root = trigger?.closest?.('[data-portal-dropdown-root]')
+
+ if (root) {
+ event.preventDefault()
+ event.stopPropagation()
+ togglePortalDropdown(root)
+ return
+ }
+
+ const state = portalDropdownStateForTarget(target)
+ if (state?.isOpen && target?.closest?.('button, a, [phx-click]')) {
+ window.setTimeout(() => closePortalDropdown(state), 0)
+ }
+}
+
+function handlePortalDropdownDocumentPointerDown(event) {
+ if (activePortalDropdowns.size === 0) return
+
+ const target = event.target instanceof Element ? event.target : null
+ const path = typeof event.composedPath === 'function' ? event.composedPath() : []
+
+ for (const state of activePortalDropdowns) {
+ if (
+ path.includes(state.root) ||
+ path.includes(state.menu) ||
+ (target && state.root.contains(target)) ||
+ (target && state.menu.contains(target))
+ ) {
+ return
+ }
+ }
+
+ closeAllPortalDropdowns()
+}
+
+function handlePortalDropdownDocumentKeyDown(event) {
+ if (event.key !== 'Escape' || activePortalDropdowns.size === 0) return
+
+ event.preventDefault()
+ const opened = Array.from(activePortalDropdowns)
+ const lastOpened = opened[opened.length - 1]
+ closeAllPortalDropdowns()
+ lastOpened?.trigger?.focus?.({ preventScroll: true })
+}
+
+function repositionActivePortalDropdowns() {
+ for (const state of activePortalDropdowns) positionPortalDropdown(state)
+ if (activePortalTooltip) positionPortalTooltip(activePortalTooltip)
+}
+
+function togglePortalDropdown(root) {
+ const state = getPortalDropdownState(root)
+ if (!state) return
+
+ if (state.isOpen) closePortalDropdown(state)
+ else openPortalDropdown(state)
+}
+
+function getPortalDropdownState(root) {
+ const trigger = root.querySelector('[data-portal-dropdown-trigger]')
+ const menu = root.querySelector('[data-portal-dropdown-menu]')
+
+ if (!trigger || !menu) return null
+
+ const existing = portalDropdownStates.get(root)
+ if (existing?.trigger === trigger && existing?.menu === menu) return existing
+
+ if (existing) closePortalDropdown(existing)
+
+ const state = {
+ root,
+ trigger,
+ menu,
+ portal: new OverlayPortal(menu, {
+ portalRoot: root.closest('[data-phx-main]') || document.body
+ }),
+ shouldPortal: root.dataset.portalDropdownMode !== 'anchored',
+ isOpen: false
+ }
+
+ trigger.setAttribute('aria-expanded', 'false')
+ menu.setAttribute('aria-hidden', 'true')
+ applyPortalDropdownClosedStyles(menu)
+ portalDropdownStates.set(root, state)
+
+ return state
+}
+
+function openPortalDropdown(state) {
+ closeAllPortalDropdowns(state)
+
+ state.isOpen = true
+ state.root.dataset.portalDropdownOpen = 'true'
+ state.trigger.setAttribute('aria-expanded', 'true')
+ state.menu.setAttribute('aria-hidden', 'false')
+ if (state.shouldPortal) state.portal.mount()
+ positionPortalDropdown(state)
+ applyPortalDropdownOpenStyles(state.menu)
+ activePortalDropdowns.add(state)
+}
+
+function closePortalDropdown(state) {
+ state.isOpen = false
+ delete state.root.dataset.portalDropdownOpen
+ state.trigger?.setAttribute('aria-expanded', 'false')
+ state.menu?.setAttribute('aria-hidden', 'true')
+ applyPortalDropdownClosedStyles(state.menu)
+
+ if (state.shouldPortal) state.portal?.restore()
+ else state.portal?.clearPosition()
+
+ activePortalDropdowns.delete(state)
+}
+
+function closeAllPortalDropdowns(exceptState = null) {
+ for (const state of Array.from(activePortalDropdowns)) {
+ if (state !== exceptState) closePortalDropdown(state)
+ }
+}
+
+function positionPortalDropdown(state) {
+ state.portal?.positionNear(state.trigger, {
+ placement: state.root.dataset.portalPlacement || 'auto',
+ align: state.root.dataset.portalAlign || 'start',
+ margin: Number.parseInt(state.root.dataset.portalMargin || '8', 10) || 8,
+ zIndex: Number.parseInt(state.root.dataset.portalZIndex || '10000', 10) || 10000
+ })
+}
+
+function handlePortalTooltipMouseOver(event) {
+ const target = event.target instanceof Element ? event.target : null
+ const trigger = target?.closest?.('[data-portal-tooltip][data-tip]')
+ const relatedTarget = event.relatedTarget instanceof Node ? event.relatedTarget : null
+
+ if (!trigger || trigger.contains(relatedTarget)) return
+ showPortalTooltip(trigger)
+}
+
+function handlePortalTooltipMouseOut(event) {
+ const target = event.target instanceof Element ? event.target : null
+ const trigger = target?.closest?.('[data-portal-tooltip][data-tip]')
+ const relatedTarget = event.relatedTarget instanceof Node ? event.relatedTarget : null
+
+ if (!trigger || trigger.contains(relatedTarget)) return
+ hidePortalTooltip(trigger)
+}
+
+function handlePortalTooltipFocusIn(event) {
+ const trigger = event.target instanceof Element
+ ? event.target.closest('[data-portal-tooltip][data-tip]')
+ : null
+
+ if (trigger) showPortalTooltip(trigger)
+}
+
+function handlePortalTooltipFocusOut(event) {
+ const trigger = event.target instanceof Element
+ ? event.target.closest('[data-portal-tooltip][data-tip]')
+ : null
+
+ if (trigger) hidePortalTooltip(trigger)
+}
+
+function showPortalTooltip(trigger) {
+ const content = trigger.dataset.tip
+ if (!content) return
+
+ if (activePortalTooltip?.trigger === trigger) {
+ activePortalTooltip.tooltip.textContent = content
+ positionPortalTooltip(activePortalTooltip)
+ return
+ }
+
+ removePortalTooltip()
+
+ const tooltip = document.createElement('div')
+ tooltip.className = 'floating-panel portal-tooltip rounded-box px-2 py-1 text-xs font-medium text-base-content'
+ tooltip.textContent = content
+ tooltip.setAttribute('role', 'tooltip')
+ tooltip.style.pointerEvents = 'none'
+ tooltip.style.maxWidth = 'min(18rem, calc(100vw - 1rem))'
+ tooltip.style.whiteSpace = 'normal'
+
+ const portal = new OverlayPortal(tooltip, {
+ portalRoot: trigger.closest('[data-phx-main]') || document.body
+ })
+
+ activePortalTooltip = { trigger, tooltip, portal }
+ portal.mount()
+ positionPortalTooltip(activePortalTooltip)
+}
+
+function hidePortalTooltip(trigger) {
+ if (!activePortalTooltip || activePortalTooltip.trigger !== trigger) return
+ removePortalTooltip()
+}
+
+function removePortalTooltip() {
+ if (!activePortalTooltip) return
+
+ activePortalTooltip.tooltip.remove()
+ activePortalTooltip = null
+}
+
+function positionPortalTooltip(state) {
+ state.portal.positionNear(state.trigger, {
+ placement: state.trigger.dataset.portalTooltipPlacement || 'top',
+ align: state.trigger.dataset.portalTooltipAlign || 'center',
+ margin: Number.parseInt(state.trigger.dataset.portalTooltipMargin || '8', 10) || 8,
+ zIndex: Number.parseInt(state.trigger.dataset.portalTooltipZIndex || '10001', 10) || 10001
+ })
+}
+
+function applyPortalDropdownOpenStyles(menu) {
+ if (!menu) return
+
+ Object.assign(menu.style, {
+ visibility: 'visible',
+ opacity: '1',
+ pointerEvents: 'auto',
+ transform: 'translateY(0) scale(1)'
+ })
+}
+
+function applyPortalDropdownClosedStyles(menu) {
+ if (!menu) return
+
+ Object.assign(menu.style, {
+ visibility: 'hidden',
+ opacity: '0',
+ pointerEvents: 'none',
+ transform: 'translateY(-4px) scale(0.98)'
+ })
+}
+
+function portalDropdownStateForTarget(target) {
+ if (!target) return null
+
+ for (const state of activePortalDropdowns) {
+ if (state.root.contains(target) || state.menu.contains(target)) return state
+ }
+
+ return null
+}
+
+ensurePortalDropdownGlobalListeners()
diff --git a/apps/elektrine/assets/js/hooks/presence_hooks.js b/apps/elektrine/assets/js/hooks/presence_hooks.js
new file mode 100644
index 0000000..2cf30fc
--- /dev/null
+++ b/apps/elektrine/assets/js/hooks/presence_hooks.js
@@ -0,0 +1,133 @@
+/**
+ * Presence-related LiveView hooks.
+ *
+ * ActivityTracker drives auto-away detection client-side: it pushes
+ * "auto_away_timeout" after five idle minutes and "user_activity" with
+ * clear_away when the user returns. Ordinary activity is handled entirely in
+ * the browser (resetting the idle timer) and sends nothing to the server.
+ *
+ * Device info (type, browser, timezone) is sent once in the LiveSocket
+ * connect params (see detectDevice below), not via events.
+ */
+
+// Auto-away timeout in milliseconds (5 minutes)
+const AUTO_AWAY_TIMEOUT = 5 * 60 * 1000;
+
+function isHookConnected(hook) {
+ return Boolean(hook?.liveSocket?.isConnected?.() && hook?.el?.isConnected);
+}
+
+function safePushEvent(hook, event, payload) {
+ if (!isHookConnected(hook)) return Promise.resolve(null);
+ return Promise.resolve(hook.pushEvent(event, payload)).catch(() => null);
+}
+
+/**
+ * Detects device type, browser, and timezone for presence metadata.
+ * Called once at LiveSocket setup and sent as connect params.
+ */
+export function detectDevice() {
+ const ua = navigator.userAgent;
+ const isMobile = /iPhone|iPad|iPod|Android|webOS|BlackBerry|IEMobile|Opera Mini/i.test(ua);
+ const isTablet = /iPad|Android/i.test(ua) && !/Mobile/i.test(ua);
+
+ let deviceType = 'desktop';
+ if (isTablet) {
+ deviceType = 'tablet';
+ } else if (isMobile) {
+ deviceType = 'mobile';
+ }
+
+ let browser = 'unknown';
+ if (ua.includes('Firefox')) browser = 'firefox';
+ else if (ua.includes('Edg')) browser = 'edge';
+ else if (ua.includes('Chrome')) browser = 'chrome';
+ else if (ua.includes('Safari')) browser = 'safari';
+ else if (ua.includes('Opera') || ua.includes('OPR')) browser = 'opera';
+
+ return {
+ device_type: deviceType,
+ browser: browser,
+ timezone: Intl.DateTimeFormat().resolvedOptions().timeZone
+ };
+}
+
+/**
+ * ActivityTracker - Tracks user activity for auto-away detection.
+ *
+ * Attach to a root element (e.g., body or main container) to track mouse,
+ * keyboard, touch, scroll, click, and visibility events.
+ */
+export const ActivityTracker = {
+ mounted() {
+ this.isAway = false;
+ this.awayTimeout = null;
+ this.boundHandleActivity = this.handleActivity.bind(this);
+
+ const events = ['mousemove', 'keydown', 'touchstart', 'scroll', 'click', 'focus'];
+ events.forEach(event => {
+ document.addEventListener(event, this.boundHandleActivity, { passive: true });
+ });
+
+ this.boundHandleVisibilityChange = this.handleVisibilityChange.bind(this);
+ document.addEventListener('visibilitychange', this.boundHandleVisibilityChange);
+
+ this.resetAwayTimer();
+
+ // Keep local state in sync with the server's view of auto-away.
+ this.handleEvent("auto_away_set", ({ was_auto }) => {
+ if (was_auto) {
+ this.isAway = true;
+ }
+ });
+
+ this.handleEvent("auto_away_cleared", () => {
+ this.isAway = false;
+ });
+ },
+
+ destroyed() {
+ const events = ['mousemove', 'keydown', 'touchstart', 'scroll', 'click', 'focus'];
+ events.forEach(event => {
+ document.removeEventListener(event, this.boundHandleActivity);
+ });
+
+ if (this.boundHandleVisibilityChange) {
+ document.removeEventListener('visibilitychange', this.boundHandleVisibilityChange);
+ }
+
+ if (this.awayTimeout) {
+ clearTimeout(this.awayTimeout);
+ }
+ },
+
+ handleActivity() {
+ this.resetAwayTimer();
+
+ // Only tell the server anything when there's a state change to make:
+ // returning from auto-away.
+ if (this.isAway) {
+ this.isAway = false;
+ void safePushEvent(this, "user_activity", { clear_away: true });
+ }
+ },
+
+ handleVisibilityChange() {
+ if (document.visibilityState === 'visible') {
+ this.handleActivity();
+ }
+ },
+
+ resetAwayTimer() {
+ if (this.awayTimeout) {
+ clearTimeout(this.awayTimeout);
+ }
+
+ this.awayTimeout = setTimeout(() => {
+ if (!this.isAway) {
+ this.isAway = true;
+ void safePushEvent(this, "auto_away_timeout", {});
+ }
+ }, AUTO_AWAY_TIMEOUT);
+ }
+};
diff --git a/apps/elektrine/assets/js/hooks/profile_hooks.js b/apps/elektrine/assets/js/hooks/profile_hooks.js
new file mode 100644
index 0000000..34c5a91
--- /dev/null
+++ b/apps/elektrine/assets/js/hooks/profile_hooks.js
@@ -0,0 +1,279 @@
+/**
+ * Profile Hooks
+ * Hooks related to user profiles and status display.
+ */
+
+/**
+ * Typewriter Effect Hook
+ * Animates text to appear as if being typed
+ */
+export const TypewriterHook = {
+ mounted() {
+ this.hasCompleted = false
+ this.startTypewriter()
+ },
+
+ updated() {
+ const newText = this.el.dataset.text
+
+ if (this.hasCompleted && this.lastText === newText) {
+ if (this.el.textContent !== newText) {
+ this.el.textContent = newText
+ this.el.classList.remove('typewriter-cursor')
+
+ const effectClass = this.el.dataset.effectClass || ''
+ const effectStyle = this.el.dataset.effectStyle || ''
+ const dataText = this.el.dataset.dataText || newText
+
+ if (effectClass) {
+ effectClass.split(' ').filter(c => c.trim()).forEach(c => this.el.classList.add(c))
+ }
+
+ if (effectStyle) {
+ effectStyle.split(';').filter(s => s.trim()).forEach(s => {
+ const [prop, value] = s.split(':').map(p => p.trim())
+ if (prop && value) this.el.style.setProperty(prop, value)
+ })
+ }
+
+ if (dataText && (effectClass.includes('glitch') || effectClass.includes('double'))) {
+ this.el.setAttribute('data-text', dataText)
+ }
+ }
+ return
+ }
+
+ if (this.timeout) clearTimeout(this.timeout)
+ this.hasCompleted = false
+ this.startTypewriter()
+ },
+
+ destroyed() {
+ if (this.timeout) clearTimeout(this.timeout)
+ },
+
+ startTypewriter() {
+ const text = this.el.dataset.text
+ const speed = this.el.dataset.speed || 'normal'
+ const effectClass = this.el.dataset.effectClass || ''
+ const effectStyle = this.el.dataset.effectStyle || ''
+ const dataText = this.el.dataset.dataText || text
+
+ this.lastText = text
+
+ if (!text) {
+ this.el.textContent = ' '
+ return
+ }
+
+ const speeds = { slow: 100, normal: 50, fast: 25 }
+ const delay = speeds[speed] || speeds.normal
+
+ this.el.textContent = text
+ this.el.style.opacity = '0'
+ this.el.offsetHeight
+ this.el.textContent = ''
+ this.el.style.opacity = '1'
+ this.el.style.minHeight = '1em'
+
+ if (effectClass) {
+ effectClass.split(' ').filter(c => c.trim()).forEach(c => this.el.classList.add(c))
+ }
+
+ if (effectStyle) {
+ effectStyle.split(';').filter(s => s.trim()).forEach(s => {
+ const [prop, value] = s.split(':').map(p => p.trim())
+ if (prop && value) this.el.style.setProperty(prop, value)
+ })
+ }
+
+ if (dataText && (effectClass.includes('glitch') || effectClass.includes('double'))) {
+ this.el.setAttribute('data-text', dataText)
+ }
+
+ this.el.classList.add('typewriter-cursor')
+
+ let index = 0
+ const typeChar = () => {
+ if (index < text.length) {
+ this.el.textContent = text.substring(0, index + 1)
+ if (dataText && (effectClass.includes('glitch') || effectClass.includes('double'))) {
+ this.el.setAttribute('data-text', text.substring(0, index + 1))
+ }
+ index++
+ this.timeout = setTimeout(typeChar, delay)
+ } else {
+ this.el.classList.remove('typewriter-cursor')
+ this.hasCompleted = true
+ }
+ }
+
+ this.timeout = setTimeout(typeChar, 500)
+ }
+}
+
+/**
+ * Tab Title Typewriter Effect
+ * Animates the browser tab title with typewriter effect
+ */
+export const TabTitleTypewriter = {
+ mounted() {
+ const text = this.el.dataset.text
+ const speed = this.el.dataset.speed || 'normal'
+ const speeds = { slow: 150, normal: 100, fast: 50 }
+ const delay = speeds[speed] || speeds.normal
+
+ this.originalTitle = document.title
+
+ let index = 0
+ const typeChar = () => {
+ if (index <= text.length) {
+ document.title = text.substring(0, index) + (index < text.length ? '|' : '')
+ index++
+ setTimeout(typeChar, delay)
+ } else {
+ document.title = text
+ }
+ }
+
+ typeChar()
+ },
+
+ destroyed() {
+ if (this.originalTitle) document.title = this.originalTitle
+ }
+}
+
+/**
+ * Video Background Hook
+ * Ensures background videos autoplay reliably across all browsers
+ */
+export const VideoBackground = {
+ mounted() {
+ this.video = this.el
+ this.isPlaying = false
+ this.setupEventListeners()
+ if (this.video.readyState >= 1) this.tryPlay()
+ },
+
+ destroyed() {
+ if (this.visibilityHandler) {
+ document.removeEventListener('visibilitychange', this.visibilityHandler)
+ }
+ },
+
+ setupEventListeners() {
+ this.video.addEventListener('loadedmetadata', () => this.tryPlay())
+ this.video.addEventListener('playing', () => { this.isPlaying = true })
+ this.video.addEventListener('pause', () => { this.isPlaying = false })
+ this.video.addEventListener('ended', () => this.tryPlay())
+
+ this.visibilityHandler = () => {
+ if (!document.hidden && !this.isPlaying) this.tryPlay()
+ }
+ document.addEventListener('visibilitychange', this.visibilityHandler)
+ },
+
+ tryPlay() {
+ if (!this.video.paused) return
+
+ const playPromise = this.video.play()
+ if (playPromise !== undefined) {
+ playPromise
+ .then(() => { this.isPlaying = true })
+ .catch(() => {
+ this.isPlaying = false
+ setTimeout(() => { if (!this.isPlaying) this.tryPlay() }, 1000)
+ })
+ }
+ }
+}
+
+/**
+ * Status Selector Hook
+ * Updates UI immediately when status changes
+ */
+export const StatusSelector = {
+ mounted() {
+ this.handleEvent("status_updated", ({ status }) => this.updateStatusUI(status))
+ this.handleSubmit = (event) => {
+ const form = event.target.closest('form[action="/account/status"]')
+ if (!form || !this.el.contains(form)) return
+
+ event.preventDefault()
+
+ const statusInput = form.querySelector('input[name="status"]')
+ const status = statusInput?.value
+ if (!status) return
+
+ this.updateStatusUI(status)
+ this.submitStatus(form, status)
+ }
+
+ this.el.addEventListener('submit', this.handleSubmit)
+ },
+
+ destroyed() {
+ if (this.handleSubmit) this.el.removeEventListener('submit', this.handleSubmit)
+ },
+
+ async submitStatus(form, status) {
+ const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content
+ const headers = csrfToken ? { 'X-CSRF-Token': csrfToken } : {}
+
+ try {
+ await fetch(form.action, {
+ method: 'POST',
+ headers,
+ body: new FormData(form),
+ credentials: 'same-origin'
+ })
+
+ const idleDetector = document.getElementById('idle-detector')
+ if (idleDetector) {
+ idleDetector.dataset.userStatus = status
+ }
+ } catch (error) {
+ console.error('Failed to update status:', error)
+ }
+ },
+
+ updateStatusUI(status) {
+ const indicator = this.el.querySelector('.status-indicator')
+ if (indicator) {
+ indicator.classList.remove('bg-success', 'bg-warning', 'bg-error', 'bg-gray-400', 'bg-base-300')
+ switch(status) {
+ case 'online': indicator.classList.add('bg-success'); break
+ case 'away': indicator.classList.add('bg-warning'); break
+ case 'dnd': indicator.classList.add('bg-error'); break
+ case 'offline': indicator.classList.add('bg-base-300'); break
+ }
+ }
+
+ const buttons = this.el.querySelectorAll('form[action="/account/status"] button')
+ buttons.forEach(button => {
+ const form = button.closest('form')
+ const statusInput = form.querySelector('input[name="status"]')
+ if (statusInput) {
+ const buttonStatus = statusInput.value
+ button.classList.remove(
+ 'bg-success/10', 'text-success', 'border-success/30',
+ 'bg-warning/10', 'text-warning', 'border-warning/30',
+ 'bg-error/10', 'text-error', 'border-error/30',
+ 'bg-base-300/20', 'bg-base-300/50'
+ )
+ button.classList.add('border', 'border-base-300')
+
+ if (buttonStatus === status) {
+ button.classList.remove('border-base-300')
+ switch(status) {
+ case 'online': button.classList.add('bg-success/10', 'text-success', 'border-success/30'); break
+ case 'away': button.classList.add('bg-warning/10', 'text-warning', 'border-warning/30'); break
+ case 'dnd': button.classList.add('bg-error/10', 'text-error', 'border-error/30'); break
+ case 'offline': button.classList.add('bg-base-300/20'); break
+ }
+ }
+ }
+ })
+ }
+}
diff --git a/apps/elektrine/assets/js/hooks/profile_link_reorder_hook.js b/apps/elektrine/assets/js/hooks/profile_link_reorder_hook.js
new file mode 100644
index 0000000..f70ed33
--- /dev/null
+++ b/apps/elektrine/assets/js/hooks/profile_link_reorder_hook.js
@@ -0,0 +1,64 @@
+export const ProfileLinkReorder = {
+ mounted() {
+ this.dragging = null
+
+ this.onDragStart = (event) => {
+ const row = event.target.closest("[data-profile-link-id]")
+ if (!row) return
+
+ this.dragging = row
+ row.classList.add("opacity-60")
+
+ if (event.dataTransfer) {
+ event.dataTransfer.effectAllowed = "move"
+ event.dataTransfer.setData("text/plain", row.dataset.profileLinkId || "")
+ }
+ }
+
+ this.onDragOver = (event) => {
+ if (!this.dragging) return
+
+ const row = event.target.closest("[data-profile-link-id]")
+ if (!row || row === this.dragging || row.parentElement !== this.el) return
+
+ event.preventDefault()
+ const rect = row.getBoundingClientRect()
+ const placeAfter = event.clientY > rect.top + rect.height / 2
+ this.el.insertBefore(this.dragging, placeAfter ? row.nextSibling : row)
+ }
+
+ this.onDrop = (event) => {
+ if (!this.dragging) return
+ event.preventDefault()
+ this.pushOrder()
+ }
+
+ this.onDragEnd = () => {
+ if (this.dragging) {
+ this.dragging.classList.remove("opacity-60")
+ }
+
+ this.dragging = null
+ }
+
+ this.el.addEventListener("dragstart", this.onDragStart)
+ this.el.addEventListener("dragover", this.onDragOver)
+ this.el.addEventListener("drop", this.onDrop)
+ this.el.addEventListener("dragend", this.onDragEnd)
+ },
+
+ destroyed() {
+ this.el.removeEventListener("dragstart", this.onDragStart)
+ this.el.removeEventListener("dragover", this.onDragOver)
+ this.el.removeEventListener("drop", this.onDrop)
+ this.el.removeEventListener("dragend", this.onDragEnd)
+ },
+
+ pushOrder() {
+ const ids = Array.from(this.el.querySelectorAll("[data-profile-link-id]"))
+ .map((row) => row.dataset.profileLinkId)
+ .filter(Boolean)
+
+ this.pushEvent("reorder_links", { ids })
+ }
+}
diff --git a/apps/elektrine/assets/js/hooks/proof_graph_dom.js b/apps/elektrine/assets/js/hooks/proof_graph_dom.js
new file mode 100644
index 0000000..af3c052
--- /dev/null
+++ b/apps/elektrine/assets/js/hooks/proof_graph_dom.js
@@ -0,0 +1,18 @@
+const SVG_NS = "http://www.w3.org/2000/svg"
+const XLINK_NS = "http://www.w3.org/1999/xlink"
+
+export function svg(tagName) {
+ return document.createElementNS(SVG_NS, tagName)
+}
+
+export function setImageHref(node, url) {
+ node.setAttribute("href", url)
+ node.setAttributeNS(XLINK_NS, "xlink:href", url)
+}
+
+export function setAttributes(node, attrs) {
+ Object.entries(attrs).forEach(([key, value]) => {
+ if (value === null || value === undefined) return
+ node.setAttribute(key, value)
+ })
+}
diff --git a/apps/elektrine/assets/js/hooks/proof_graph_hook.js b/apps/elektrine/assets/js/hooks/proof_graph_hook.js
new file mode 100644
index 0000000..abc8a43
--- /dev/null
+++ b/apps/elektrine/assets/js/hooks/proof_graph_hook.js
@@ -0,0 +1,599 @@
+import { CLUSTERS, EDGE_COLORS, EDGE_DASHARRAY, NODE_STYLES, USER_NODE_KINDS } from "./proof_graph_styles"
+import { createGraphDefs, createNodePaints, paletteForNode } from "./proof_graph_paints"
+import { svg, setAttributes, setImageHref } from "./proof_graph_dom"
+
+function clamp(value, min, max) {
+ return Math.min(max, Math.max(min, value))
+}
+
+function trimText(value, maxLength = 15) {
+ if (!value || value.length <= maxLength) return value || ""
+ return `${value.slice(0, maxLength - 3)}...`
+}
+
+function isUserNode(node) {
+ return USER_NODE_KINDS.has(node.kind)
+}
+
+function labelMaxLength(node) {
+ if (node.kind === "subject") return 12
+ if (isUserNode(node)) return 8
+ if (node.kind === "aggregate") return 5
+ if (node.kind === "trust") return 6
+ return 8
+}
+
+function subtitleMaxLength(node) {
+ if (node.kind === "subject") return 16
+ if (isUserNode(node)) return 0
+ return 10
+}
+
+function layoutForNode(node) {
+ if (node.kind === "subject") {
+ return {
+ labelY: 10,
+ subtitleY: 26,
+ labelFontSize: 12,
+ subtitleFontSize: 8,
+ showSubtitle: true
+ }
+ }
+
+ if (isUserNode(node)) {
+ return {
+ labelY: 3,
+ subtitleY: 0,
+ labelFontSize: 9,
+ subtitleFontSize: 0,
+ showSubtitle: false
+ }
+ }
+
+ return {
+ labelY: -1,
+ subtitleY: 13,
+ labelFontSize: node.kind === "aggregate" ? 12 : 11,
+ subtitleFontSize: 8,
+ showSubtitle: true
+ }
+}
+
+function hashString(value) {
+ return Array.from(value || "").reduce((acc, char) => acc * 31 + char.charCodeAt(0), 7)
+}
+
+function polarToCartesian(centerX, centerY, radius, angleDeg, yScale = 0.82) {
+ const angle = (angleDeg * Math.PI) / 180
+ return {
+ x: centerX + Math.cos(angle) * radius,
+ y: centerY + Math.sin(angle) * radius * yScale
+ }
+}
+
+function buildEdgePath(source, target, bend) {
+ const midX = (source.x + target.x) / 2
+ const midY = (source.y + target.y) / 2
+ const deltaX = target.x - source.x
+ const deltaY = target.y - source.y
+ const length = Math.hypot(deltaX, deltaY) || 1
+ const normalX = -deltaY / length
+ const normalY = deltaX / length
+ const controlX = midX + normalX * bend
+ const controlY = midY + normalY * bend
+ return `M ${source.x} ${source.y} Q ${controlX} ${controlY} ${target.x} ${target.y}`
+}
+
+function parseGraphPayload(el) {
+ const raw = el.dataset.graph
+ if (!raw) return null
+
+ try {
+ return JSON.parse(raw)
+ } catch (_error) {
+ return null
+ }
+}
+
+function createSceneState(graph, bounds) {
+ const center = { x: bounds.width / 2, y: bounds.height / 2 }
+ const graphRadius = Math.min(bounds.width, bounds.height) * 0.38
+ const nodesByCluster = new Map()
+
+ graph.nodes.forEach((node) => {
+ if (!nodesByCluster.has(node.cluster)) nodesByCluster.set(node.cluster, [])
+ nodesByCluster.get(node.cluster).push(node)
+ })
+
+ const nodes = graph.nodes.map((node) => {
+ const config = CLUSTERS[node.cluster] || CLUSTERS.followers
+ const clusterNodes = nodesByCluster.get(node.cluster) || [node]
+ const index = clusterNodes.findIndex((entry) => entry.id === node.id)
+ const spread = config.spread || 0
+ const step = clusterNodes.length > 1 ? spread / (clusterNodes.length - 1) : 0
+ const startAngle = config.angle - spread / 2
+ const angle = clusterNodes.length > 1 ? startAngle + step * index : config.angle
+ const anchor =
+ node.cluster === "core"
+ ? { ...center }
+ : polarToCartesian(center.x, center.y, graphRadius * config.radius, angle)
+ const seed = hashString(node.id)
+ const radius =
+ node.kind === "subject"
+ ? 54
+ : node.kind === "aggregate"
+ ? 35 + Math.round((node.weight || 0.6) * 10)
+ : 28 + Math.round((node.weight || 0.6) * 8)
+
+ return {
+ ...node,
+ anchorX: anchor.x,
+ anchorY: anchor.y,
+ x: anchor.x + ((seed % 13) - 6) * 2.6,
+ y: anchor.y + ((seed % 17) - 8) * 2.2,
+ vx: 0,
+ vy: 0,
+ radius,
+ seed,
+ driftOffset: (seed % 360) * (Math.PI / 180),
+ depth: node.cluster === "core" ? 0 : config.radius
+ }
+ })
+
+ const nodeById = new Map(nodes.map((node) => [node.id, node]))
+
+ const edges = graph.edges
+ .map((edge) => ({
+ ...edge,
+ sourceNode: nodeById.get(edge.source),
+ targetNode: nodeById.get(edge.target)
+ }))
+ .filter((edge) => edge.sourceNode && edge.targetNode)
+
+ return { center, graphRadius, nodes, edges }
+}
+
+function createEdgeElement(edge) {
+ const path = svg("path")
+ const color = EDGE_COLORS[edge.kind] || "#64748b"
+ setAttributes(path, {
+ fill: "none",
+ stroke: color,
+ "stroke-width": edge.kind === "trust" ? 2.8 : 2.1,
+ "stroke-linecap": "round",
+ "stroke-dasharray": EDGE_DASHARRAY[edge.kind] || null,
+ opacity: 0.34
+ })
+ return path
+}
+
+function createNodeElements(node) {
+ const group = svg("g")
+ const halo = svg("circle")
+ const body = svg("circle")
+ const avatar = node.avatar_url ? svg("image") : null
+ const wash = svg("circle")
+ const glaze = svg("circle")
+ const ring = svg("circle")
+ const labelText = svg("text")
+ const subtitleText = svg("text")
+ const style = NODE_STYLES[node.kind] || NODE_STYLES.signal
+ const layout = layoutForNode(node)
+ const palette = paletteForNode(node)
+ const paints = createNodePaints(this.defs, node, palette)
+
+ group.style.cursor = node.href ? "pointer" : "grab"
+
+ setAttributes(halo, {
+ r: node.radius + 16,
+ fill: palette.glow,
+ opacity: style.haloOpacity ?? (node.kind === "subject" ? 0.3 : 0.08),
+ "pointer-events": "none"
+ })
+
+ setAttributes(body, {
+ r: node.radius,
+ fill: paints.fill,
+ stroke: palette.stroke,
+ "stroke-width": node.kind === "subject" ? 2.8 : 2.2,
+ filter: "drop-shadow(0 16px 28px var(--proof-shadow))",
+ "pointer-events": "none"
+ })
+
+ if (avatar && paints.avatarRadius && paints.avatarClipId) {
+ setAttributes(avatar, {
+ x: -paints.avatarRadius,
+ y: -paints.avatarRadius,
+ width: paints.avatarRadius * 2,
+ height: paints.avatarRadius * 2,
+ preserveAspectRatio: "xMidYMid slice",
+ "clip-path": `url(#${paints.avatarClipId})`,
+ "pointer-events": "none"
+ })
+
+ setImageHref(avatar, node.avatar_url)
+ }
+
+ setAttributes(wash, {
+ r: Math.max(node.radius - 1.5, 0),
+ fill: paints.wash,
+ opacity: node.avatar_url ? 0.82 : style.washOpacity ?? 0.84,
+ "pointer-events": "none"
+ })
+
+ setAttributes(glaze, {
+ r: Math.max(node.radius - 4, 0),
+ fill: paints.glaze,
+ opacity: node.avatar_url ? 0.72 : style.glazeOpacity ?? 0.3,
+ "pointer-events": "none"
+ })
+
+ setAttributes(ring, {
+ r: Math.max(node.radius - (node.kind === "subject" ? 5 : 4), 0),
+ fill: "none",
+ stroke: palette.ring,
+ "stroke-width": node.kind === "subject" ? 1.4 : 1.15,
+ opacity: node.kind === "subject" ? 0.9 : 0.72,
+ "pointer-events": "none"
+ })
+
+ labelText.textContent = trimText(node.label, labelMaxLength(node))
+ setAttributes(labelText, {
+ "text-anchor": "middle",
+ y: layout.labelY,
+ "font-size": layout.labelFontSize,
+ "font-weight": node.kind === "subject" ? "800" : "700",
+ fill: palette.text,
+ "pointer-events": "none"
+ })
+ labelText.style.filter = "drop-shadow(0 2px 8px rgba(2, 6, 23, 0.9))"
+
+ subtitleText.textContent = layout.showSubtitle ? trimText(node.subtitle, subtitleMaxLength(node)) : ""
+ setAttributes(subtitleText, {
+ "text-anchor": "middle",
+ y: layout.subtitleY,
+ "font-size": layout.subtitleFontSize,
+ "font-weight": "600",
+ "letter-spacing": "0.08em",
+ fill: palette.subtitle,
+ opacity: layout.showSubtitle ? 0.98 : 0,
+ "pointer-events": "none"
+ })
+ subtitleText.style.filter = "drop-shadow(0 2px 6px rgba(2, 6, 23, 0.8))"
+
+ group.appendChild(halo)
+ group.appendChild(body)
+ if (avatar) group.appendChild(avatar)
+ group.appendChild(wash)
+ group.appendChild(glaze)
+ group.appendChild(ring)
+ group.appendChild(labelText)
+ group.appendChild(subtitleText)
+
+ return { group, halo, body, avatar, wash, glaze, ring, labelText, subtitleText }
+}
+
+function showTooltip(tooltip, event, node) {
+ if (!tooltip) return
+
+ const title = document.createElement("div")
+ title.className = "font-semibold text-slate-950"
+ title.textContent = node.label || ""
+
+ const children = [title]
+ const subtitleText = node.subtitle || ""
+
+ if (subtitleText) {
+ const subtitle = document.createElement("div")
+ subtitle.className = "mt-1 text-slate-600"
+ subtitle.textContent = subtitleText
+ children.push(subtitle)
+ }
+
+ tooltip.replaceChildren(...children)
+ tooltip.style.left = `${event.offsetX + 18}px`
+ tooltip.style.top = `${event.offsetY + 18}px`
+ tooltip.classList.remove("hidden")
+}
+
+function hideTooltip(tooltip) {
+ if (!tooltip) return
+ tooltip.classList.add("hidden")
+}
+
+export const ProofGraph = {
+ mounted() {
+ this.canvas = this.el.querySelector('[data-role="graph-canvas"]')
+ this.tooltip = this.el.querySelector('[data-role="graph-tooltip"]')
+ this.lastGraphJson = null
+ this.pointer = null
+ this.draggedNode = null
+ this.dragMoved = false
+ this.highlightedNodeId = null
+ this.renderGraph()
+ },
+
+ updated() {
+ const rawGraph = this.el.dataset.graph || ""
+ if (rawGraph !== this.lastGraphJson) {
+ this.renderGraph()
+ }
+ },
+
+ destroyed() {
+ this.teardownScene()
+ },
+
+ renderGraph() {
+ const graph = parseGraphPayload(this.el)
+ if (!graph || !Array.isArray(graph.nodes) || graph.nodes.length === 0) return
+
+ this.teardownScene()
+
+ this.lastGraphJson = this.el.dataset.graph || ""
+ this.graph = graph
+ this.svg = svg("svg")
+ setAttributes(this.svg, {
+ width: "100%",
+ height: "100%",
+ viewBox: `0 0 ${this.el.clientWidth || 1200} ${this.el.clientHeight || 620}`,
+ "aria-label": "Interactive proof graph"
+ })
+ this.svg.style.display = "block"
+
+ this.defs = createGraphDefs()
+ this.edgeLayer = svg("g")
+ this.nodeLayer = svg("g")
+ this.svg.appendChild(this.defs)
+ this.svg.appendChild(this.edgeLayer)
+ this.svg.appendChild(this.nodeLayer)
+ const mountTarget = this.canvas || this.el
+ mountTarget.appendChild(this.svg)
+
+ this.scene = createSceneState(graph, {
+ width: Math.max(this.el.clientWidth, 320),
+ height: Math.max(this.el.clientHeight, 420)
+ })
+
+ this.scene.edges.forEach((edge) => {
+ edge.element = createEdgeElement(edge)
+ this.edgeLayer.appendChild(edge.element)
+ })
+
+ this.scene.nodes.forEach((node) => {
+ const elements = createNodeElements.call(this, node)
+ Object.assign(node, elements)
+ this.nodeLayer.appendChild(node.group)
+
+ node.group.addEventListener("pointerenter", (event) => {
+ this.highlightedNodeId = node.id
+ showTooltip(this.tooltip, event, node)
+ })
+
+ node.group.addEventListener("pointerleave", () => {
+ this.highlightedNodeId = null
+ hideTooltip(this.tooltip)
+ })
+
+ node.group.addEventListener("pointermove", (event) => {
+ if (this.highlightedNodeId === node.id) showTooltip(this.tooltip, event, node)
+ })
+
+ node.group.addEventListener("pointerdown", (event) => {
+ event.preventDefault()
+ this.draggedNode = node
+ this.dragMoved = false
+ node.group.setPointerCapture(event.pointerId)
+ })
+
+ node.group.addEventListener("pointerup", (event) => {
+ const clickTarget = !this.dragMoved && node.href
+ this.draggedNode = null
+ this.dragMoved = false
+ if (clickTarget) window.location.assign(node.href)
+ if (node.group.hasPointerCapture(event.pointerId)) {
+ node.group.releasePointerCapture(event.pointerId)
+ }
+ })
+ })
+
+ this.pointerMoveHandler = (event) => {
+ const rect = this.el.getBoundingClientRect()
+ const point = {
+ x: clamp(event.clientX - rect.left, 0, rect.width),
+ y: clamp(event.clientY - rect.top, 0, rect.height)
+ }
+
+ this.pointer = point
+
+ if (this.draggedNode) {
+ this.dragMoved = true
+ this.draggedNode.x = point.x
+ this.draggedNode.y = point.y
+ this.draggedNode.vx = 0
+ this.draggedNode.vy = 0
+ }
+ }
+
+ this.pointerLeaveHandler = () => {
+ this.pointer = null
+ if (!this.draggedNode) hideTooltip(this.tooltip)
+ }
+
+ this.el.addEventListener("pointermove", this.pointerMoveHandler)
+ this.el.addEventListener("pointerleave", this.pointerLeaveHandler)
+
+ this.resizeObserver = new ResizeObserver(() => {
+ const nextWidth = Math.max(this.el.clientWidth, 320)
+ const nextHeight = Math.max(this.el.clientHeight, 420)
+ setAttributes(this.svg, { viewBox: `0 0 ${nextWidth} ${nextHeight}` })
+ this.reconcileSceneNodes()
+ })
+ this.resizeObserver.observe(this.el)
+
+ this.animate = this.animate.bind(this)
+ this.frame = window.requestAnimationFrame(this.animate)
+ },
+
+ reconcileSceneNodes() {
+ const oldNodes = new Map((this.scene?.nodes || []).map((node) => [node.id, node]))
+ this.scene = createSceneState(this.graph, {
+ width: Math.max(this.el.clientWidth, 320),
+ height: Math.max(this.el.clientHeight, 420)
+ })
+
+ this.scene.edges.forEach((edge, index) => {
+ edge.element = this.edgeLayer.children[index] || createEdgeElement(edge)
+ if (!edge.element.parentNode) this.edgeLayer.appendChild(edge.element)
+ })
+
+ this.scene.nodes.forEach((node, index) => {
+ const previous = oldNodes.get(node.id)
+ if (previous) {
+ node.group = previous.group
+ node.halo = previous.halo
+ node.body = previous.body
+ node.avatar = previous.avatar
+ node.wash = previous.wash
+ node.glaze = previous.glaze
+ node.ring = previous.ring
+ node.labelText = previous.labelText
+ node.subtitleText = previous.subtitleText
+ node.x = previous.x
+ node.y = previous.y
+ node.vx = previous.vx
+ node.vy = previous.vy
+ } else {
+ const elements = createNodeElements.call(this, node)
+ Object.assign(node, elements)
+ this.nodeLayer.appendChild(node.group)
+ }
+
+ if (!node.group.parentNode) this.nodeLayer.appendChild(node.group)
+ if (this.nodeLayer.children[index] !== node.group) {
+ this.nodeLayer.appendChild(node.group)
+ }
+ })
+ },
+
+ animate(timestamp) {
+ if (!this.scene) return
+
+ const { nodes, edges, center, graphRadius } = this.scene
+ const width = Math.max(this.el.clientWidth, 320)
+ const height = Math.max(this.el.clientHeight, 420)
+ const pointerShiftX = this.pointer ? ((this.pointer.x - center.x) / center.x) * 12 : 0
+ const pointerShiftY = this.pointer ? ((this.pointer.y - center.y) / center.y) * 10 : 0
+
+ nodes.forEach((node) => {
+ if (node.id === this.highlightedNodeId) {
+ node.vx *= 0.82
+ node.vy *= 0.82
+ }
+
+ if (this.draggedNode && this.draggedNode.id === node.id) return
+
+ const driftX = Math.cos(timestamp / 1350 + node.driftOffset) * (8 + node.depth * 12)
+ const driftY = Math.sin(timestamp / 1680 + node.driftOffset) * (6 + node.depth * 10)
+ const targetX = node.anchorX + driftX + pointerShiftX * (0.8 + node.depth)
+ const targetY = node.anchorY + driftY + pointerShiftY * (0.8 + node.depth)
+ const spring = node.cluster === "core" ? 0.12 : 0.045
+
+ node.vx += (targetX - node.x) * spring
+ node.vy += (targetY - node.y) * spring
+ node.vx *= 0.9
+ node.vy *= 0.9
+ })
+
+ for (let outer = 0; outer < nodes.length; outer += 1) {
+ for (let inner = outer + 1; inner < nodes.length; inner += 1) {
+ const first = nodes[outer]
+ const second = nodes[inner]
+ const deltaX = second.x - first.x
+ const deltaY = second.y - first.y
+ const distance = Math.hypot(deltaX, deltaY) || 1
+ const minimum = first.radius + second.radius + 24
+
+ if (distance < minimum) {
+ const force = (minimum - distance) * 0.018
+ const forceX = (deltaX / distance) * force
+ const forceY = (deltaY / distance) * force
+
+ if (!(this.draggedNode && this.draggedNode.id === first.id) && first.cluster !== "core") {
+ first.vx -= forceX
+ first.vy -= forceY
+ }
+
+ if (!(this.draggedNode && this.draggedNode.id === second.id) && second.cluster !== "core") {
+ second.vx += forceX
+ second.vy += forceY
+ }
+ }
+ }
+ }
+
+ nodes.forEach((node) => {
+ if (node.cluster === "core") {
+ node.x = center.x
+ node.y = center.y
+ } else if (!(this.draggedNode && this.draggedNode.id === node.id)) {
+ node.x = clamp(node.x + node.vx, 28, width - 28)
+ node.y = clamp(node.y + node.vy, 28, height - 28)
+ }
+ })
+
+ edges.forEach((edge) => {
+ const source = edge.sourceNode
+ const target = edge.targetNode
+ const connected =
+ this.highlightedNodeId &&
+ (source.id === this.highlightedNodeId || target.id === this.highlightedNodeId)
+ const bend = edge.kind === "invite" ? 22 : edge.kind === "trust" ? 12 : 18
+ setAttributes(edge.element, {
+ d: buildEdgePath(source, target, bend),
+ opacity: this.highlightedNodeId ? (connected ? 0.92 : 0.12) : 0.34,
+ "stroke-width": connected ? 3.6 : edge.kind === "trust" ? 2.8 : 2.1
+ })
+ })
+
+ nodes.forEach((node) => {
+ const connected =
+ !this.highlightedNodeId ||
+ node.id === this.highlightedNodeId ||
+ edges.some(
+ (edge) =>
+ (edge.sourceNode.id === node.id || edge.targetNode.id === node.id) &&
+ (edge.sourceNode.id === this.highlightedNodeId || edge.targetNode.id === this.highlightedNodeId)
+ )
+ const scale = this.highlightedNodeId && node.id === this.highlightedNodeId ? 1.08 : 1
+ const opacity = connected ? 1 : 0.32
+ setAttributes(node.halo, { opacity: connected ? (node.kind === "subject" ? 0.42 : 0.22) : 0.06 })
+ setAttributes(node.group, {
+ transform: `translate(${node.x}, ${node.y}) scale(${scale})`
+ })
+ node.group.style.opacity = opacity.toString()
+ node.group.style.transition = "opacity 120ms ease"
+ })
+
+ this.frame = window.requestAnimationFrame(this.animate)
+ },
+
+ teardownScene() {
+ if (this.frame) window.cancelAnimationFrame(this.frame)
+ if (this.resizeObserver) this.resizeObserver.disconnect()
+ if (this.pointerMoveHandler) this.el.removeEventListener("pointermove", this.pointerMoveHandler)
+ if (this.pointerLeaveHandler) this.el.removeEventListener("pointerleave", this.pointerLeaveHandler)
+ hideTooltip(this.tooltip)
+ this.frame = null
+ this.resizeObserver = null
+ this.pointerMoveHandler = null
+ this.pointerLeaveHandler = null
+
+ if (this.svg && this.svg.parentNode) {
+ this.svg.remove()
+ }
+
+ this.svg = null
+ this.scene = null
+ }
+}
diff --git a/apps/elektrine/assets/js/hooks/proof_graph_paints.js b/apps/elektrine/assets/js/hooks/proof_graph_paints.js
new file mode 100644
index 0000000..6d61aec
--- /dev/null
+++ b/apps/elektrine/assets/js/hooks/proof_graph_paints.js
@@ -0,0 +1,215 @@
+import { svg, setAttributes } from "./proof_graph_dom"
+
+function sanitizeId(value) {
+ return String(value || "")
+ .trim()
+ .replace(/[^a-zA-Z0-9_-]/g, "-")
+}
+
+function hashString(value) {
+ return Array.from(value || "").reduce((acc, char) => acc * 31 + char.charCodeAt(0), 7)
+}
+
+function createGradient(id, tagName, attrs, stops) {
+ const gradient = svg(tagName)
+ setAttributes(gradient, { id, ...attrs })
+
+ stops.forEach((stopConfig) => {
+ const stop = svg("stop")
+ setAttributes(stop, { offset: stopConfig.offset, "stop-color": stopConfig.color })
+
+ if (stopConfig.opacity !== undefined) {
+ setAttributes(stop, { "stop-opacity": stopConfig.opacity })
+ }
+
+ gradient.appendChild(stop)
+ })
+
+ return gradient
+}
+
+export function paletteForNode(node) {
+ const seed = Math.abs(hashString(node.id || node.label || "node"))
+ const hue = seed % 360
+ const hueShift = (hue + 26 + (seed % 19)) % 360
+ const saturation = node.kind === "subject" ? 88 : 78
+ const lightness = node.kind === "subject" ? 66 : 60
+
+ return {
+ accent: `hsl(${hue}, ${saturation}%, ${lightness}%)`,
+ stroke: `hsla(${hue}, ${Math.min(saturation + 6, 96)}%, ${Math.max(lightness - 6, 40)}%, 0.96)`,
+ ring: `hsla(${hue}, 95%, 85%, ${node.kind === "subject" ? 0.92 : 0.76})`,
+ glow: `hsla(${hue}, 96%, 70%, ${node.kind === "subject" ? 0.34 : 0.22})`,
+ fillTop: `hsla(${hue}, 56%, ${node.kind === "subject" ? 28 : 18}%, 0.96)`,
+ fillBottom: `hsla(${hueShift}, 62%, 10%, 0.98)`,
+ washTop: `hsla(${hue}, 94%, 76%, ${node.kind === "subject" ? 0.18 : 0.14})`,
+ washBottom: `hsla(${hueShift}, 88%, 58%, ${node.kind === "subject" ? 0.24 : 0.2})`,
+ text: "rgba(248, 250, 252, 0.98)",
+ subtitle: "rgba(226, 232, 240, 0.82)"
+ }
+}
+
+export function createNodePaints(defs, node, palette) {
+ const safeId = sanitizeId(node.id)
+ const fillId = `proof-node-fill-${safeId}`
+ const washId = `proof-node-wash-${safeId}`
+ const glazeId = `proof-node-glaze-${safeId}`
+ let avatarClipId = null
+ let avatarRadius = null
+
+ if (node.avatar_url) {
+ avatarClipId = `proof-node-avatar-clip-${safeId}`
+ avatarRadius = Math.max(node.radius - (node.kind === "subject" ? 6 : 4), 0)
+ const avatarClip = svg("clipPath")
+ const avatarClipCircle = svg("circle")
+
+ setAttributes(avatarClip, {
+ id: avatarClipId
+ })
+
+ setAttributes(avatarClipCircle, {
+ r: avatarRadius
+ })
+
+ avatarClip.appendChild(avatarClipCircle)
+ defs.appendChild(avatarClip)
+ }
+
+ defs.appendChild(
+ createGradient(fillId, "linearGradient", { x1: "0%", y1: "0%", x2: "100%", y2: "100%" }, [
+ { offset: "0%", color: palette.fillTop },
+ { offset: "100%", color: palette.fillBottom }
+ ])
+ )
+
+ defs.appendChild(
+ createGradient(washId, "linearGradient", { x1: "0%", y1: "0%", x2: "0%", y2: "100%" }, [
+ { offset: "0%", color: node.avatar_url ? "rgba(255, 255, 255, 0.04)" : palette.washTop },
+ { offset: "45%", color: node.avatar_url ? "rgba(15, 23, 42, 0.08)" : palette.washTop },
+ { offset: "100%", color: node.avatar_url ? "rgba(2, 6, 23, 0.62)" : palette.washBottom }
+ ])
+ )
+
+ defs.appendChild(
+ createGradient(glazeId, "radialGradient", { cx: "32%", cy: "24%", r: "82%" }, [
+ { offset: "0%", color: palette.accent, opacity: node.avatar_url ? 0.18 : 0.3 },
+ { offset: "36%", color: "#ffffff", opacity: node.avatar_url ? 0.1 : 0.18 },
+ { offset: "100%", color: "#ffffff", opacity: 0 }
+ ])
+ )
+
+ return {
+ fill: `url(#${fillId})`,
+ wash: `url(#${washId})`,
+ glaze: `url(#${glazeId})`,
+ avatarClipId,
+ avatarRadius
+ }
+}
+
+export function createGraphDefs() {
+ const defs = svg("defs")
+
+ defs.appendChild(
+ createGradient(
+ "proof-node-gradient-surface",
+ "linearGradient",
+ { x1: "0%", y1: "0%", x2: "100%", y2: "100%" },
+ [
+ { offset: "0%", color: "var(--proof-node-surface-top)" },
+ { offset: "100%", color: "var(--proof-node-surface-bottom)" }
+ ]
+ )
+ )
+
+ defs.appendChild(
+ createGradient(
+ "proof-node-gradient-primary",
+ "linearGradient",
+ { x1: "0%", y1: "0%", x2: "100%", y2: "100%" },
+ [
+ { offset: "0%", color: "var(--proof-accent-bright)" },
+ { offset: "100%", color: "var(--proof-accent-strong)" }
+ ]
+ )
+ )
+
+ defs.appendChild(
+ createGradient(
+ "proof-node-gradient-ink",
+ "linearGradient",
+ { x1: "0%", y1: "0%", x2: "100%", y2: "100%" },
+ [
+ { offset: "0%", color: "rgba(15, 23, 42, 0.96)" },
+ { offset: "100%", color: "rgba(30, 41, 59, 0.96)" }
+ ]
+ )
+ )
+
+ defs.appendChild(
+ createGradient(
+ "proof-node-tint-neutral",
+ "linearGradient",
+ { x1: "0%", y1: "0%", x2: "100%", y2: "100%" },
+ [
+ { offset: "0%", color: "var(--proof-neutral-tint)" },
+ { offset: "46%", color: "var(--proof-ink-wash)" },
+ { offset: "100%", color: "#ffffff", opacity: 0 }
+ ]
+ )
+ )
+
+ defs.appendChild(
+ createGradient(
+ "proof-node-tint-accent",
+ "linearGradient",
+ { x1: "0%", y1: "0%", x2: "100%", y2: "100%" },
+ [
+ { offset: "0%", color: "var(--proof-accent-soft)" },
+ { offset: "46%", color: "var(--proof-ink-wash)" },
+ { offset: "100%", color: "#ffffff", opacity: 0 }
+ ]
+ )
+ )
+
+ defs.appendChild(
+ createGradient(
+ "proof-node-tint-primary",
+ "linearGradient",
+ { x1: "0%", y1: "0%", x2: "100%", y2: "100%" },
+ [
+ { offset: "0%", color: "var(--proof-accent-text-wash)" },
+ { offset: "52%", color: "rgba(255, 255, 255, 0.06)" },
+ { offset: "100%", color: "#ffffff", opacity: 0 }
+ ]
+ )
+ )
+
+ defs.appendChild(
+ createGradient(
+ "proof-node-gloss",
+ "radialGradient",
+ { cx: "28%", cy: "24%", r: "82%" },
+ [
+ { offset: "0%", color: "#ffffff", opacity: 0.24 },
+ { offset: "38%", color: "#ffffff", opacity: 0.12 },
+ { offset: "100%", color: "#ffffff", opacity: 0 }
+ ]
+ )
+ )
+
+ defs.appendChild(
+ createGradient(
+ "proof-node-gloss-primary",
+ "radialGradient",
+ { cx: "30%", cy: "22%", r: "84%" },
+ [
+ { offset: "0%", color: "#ffffff", opacity: 0.3 },
+ { offset: "42%", color: "#ffffff", opacity: 0.14 },
+ { offset: "100%", color: "#ffffff", opacity: 0 }
+ ]
+ )
+ )
+
+ return defs
+}
diff --git a/apps/elektrine/assets/js/hooks/proof_graph_styles.js b/apps/elektrine/assets/js/hooks/proof_graph_styles.js
new file mode 100644
index 0000000..66e5096
--- /dev/null
+++ b/apps/elektrine/assets/js/hooks/proof_graph_styles.js
@@ -0,0 +1,119 @@
+export const CLUSTERS = {
+ core: { radius: 0, angle: -90, spread: 0 },
+ trust: { radius: 0.28, angle: -88, spread: 18 },
+ age: { radius: 0.28, angle: 88, spread: 18 },
+ invite: { radius: 0.44, angle: 188, spread: 52 },
+ invitee: { radius: 0.63, angle: 214, spread: 88 },
+ followers: { radius: 0.58, angle: -18, spread: 94 },
+ following: { radius: 0.58, angle: 58, spread: 94 }
+}
+
+export const EDGE_COLORS = {
+ trust: "var(--proof-edge-strong)",
+ signal: "var(--proof-edge-muted)",
+ network: "var(--proof-edge-soft)",
+ invite: "var(--proof-edge-accent)",
+ follow: "var(--proof-edge-muted)"
+}
+
+export const EDGE_DASHARRAY = {
+ signal: "4 12",
+ network: "8 14",
+ invite: "10 10",
+ follow: "5 10"
+}
+
+export const USER_NODE_KINDS = new Set(["subject", "inviter", "invitee", "follower", "following"])
+
+export const NODE_STYLES = {
+ subject: {
+ fill: "url(#proof-node-gradient-primary)",
+ stroke: "var(--proof-accent-strong)",
+ ring: "var(--proof-accent-text-soft)",
+ wash: "url(#proof-node-tint-primary)",
+ washOpacity: 0.12,
+ text: "var(--proof-node-text-inverse)",
+ subtitle: "var(--proof-node-subtle-inverse)",
+ halo: "var(--proof-glow)",
+ haloOpacity: 0.24,
+ glaze: "url(#proof-node-gloss-primary)",
+ glazeOpacity: 0.08
+ },
+ trust: {
+ fill: "url(#proof-node-gradient-ink)",
+ stroke: "var(--proof-accent)",
+ ring: "var(--proof-accent-soft)",
+ wash: "url(#proof-node-tint-accent)",
+ washOpacity: 0.16,
+ text: "var(--proof-node-text-inverse)",
+ subtitle: "var(--proof-node-subtle-inverse)",
+ halo: "var(--proof-glow-soft)",
+ haloOpacity: 0.08
+ },
+ signal: {
+ fill: "url(#proof-node-gradient-ink)",
+ stroke: "var(--proof-node-border-soft)",
+ ring: "var(--proof-node-border-soft)",
+ wash: "url(#proof-node-tint-neutral)",
+ washOpacity: 0.12,
+ text: "var(--proof-node-text-inverse)",
+ subtitle: "var(--proof-node-subtle-inverse)",
+ halo: "var(--proof-glow-soft)",
+ haloOpacity: 0.06
+ },
+ aggregate: {
+ fill: "url(#proof-node-gradient-ink)",
+ stroke: "var(--proof-accent-soft)",
+ ring: "var(--proof-accent-soft)",
+ wash: "url(#proof-node-tint-accent)",
+ washOpacity: 0.14,
+ text: "var(--proof-node-text-inverse)",
+ subtitle: "var(--proof-node-subtle-inverse)",
+ halo: "var(--proof-glow-soft)",
+ haloOpacity: 0.08
+ },
+ inviter: {
+ fill: "url(#proof-node-gradient-ink)",
+ stroke: "var(--proof-accent-soft)",
+ ring: "var(--proof-accent-soft)",
+ wash: "url(#proof-node-tint-accent)",
+ washOpacity: 0.14,
+ text: "var(--proof-node-text-inverse)",
+ subtitle: "var(--proof-node-subtle-inverse)",
+ halo: "var(--proof-glow-soft)",
+ haloOpacity: 0.08
+ },
+ invitee: {
+ fill: "url(#proof-node-gradient-ink)",
+ stroke: "var(--proof-accent-soft)",
+ ring: "var(--proof-accent-soft)",
+ wash: "url(#proof-node-tint-accent)",
+ washOpacity: 0.14,
+ text: "var(--proof-node-text-inverse)",
+ subtitle: "var(--proof-node-subtle-inverse)",
+ halo: "var(--proof-glow-soft)",
+ haloOpacity: 0.08
+ },
+ follower: {
+ fill: "url(#proof-node-gradient-ink)",
+ stroke: "var(--proof-node-border-soft)",
+ ring: "var(--proof-node-border-soft)",
+ wash: "url(#proof-node-tint-neutral)",
+ washOpacity: 0.12,
+ text: "var(--proof-node-text-inverse)",
+ subtitle: "var(--proof-node-subtle-inverse)",
+ halo: "var(--proof-glow-soft)",
+ haloOpacity: 0.06
+ },
+ following: {
+ fill: "url(#proof-node-gradient-ink)",
+ stroke: "var(--proof-node-border-soft)",
+ ring: "var(--proof-node-border-soft)",
+ wash: "url(#proof-node-tint-neutral)",
+ washOpacity: 0.12,
+ text: "var(--proof-node-text-inverse)",
+ subtitle: "var(--proof-node-subtle-inverse)",
+ halo: "var(--proof-glow-soft)",
+ haloOpacity: 0.06
+ }
+}
diff --git a/apps/elektrine/assets/js/hooks/static_site_hooks.js b/apps/elektrine/assets/js/hooks/static_site_hooks.js
new file mode 100644
index 0000000..b469456
--- /dev/null
+++ b/apps/elektrine/assets/js/hooks/static_site_hooks.js
@@ -0,0 +1,86 @@
+// Static site hosting hooks
+
+// DragDrop hook for visual feedback on drag and drop uploads
+export const DragDrop = {
+ mounted() {
+ this.dragDepth = 0
+
+ this.onDragEnter = (e) => {
+ e.preventDefault()
+ this.dragDepth += 1
+ this.pushEvent("dragover", {})
+ }
+
+ this.onDragOver = (e) => {
+ e.preventDefault()
+
+ if (e.dataTransfer) {
+ e.dataTransfer.dropEffect = "copy"
+ }
+ }
+
+ this.onDragLeave = (e) => {
+ e.preventDefault()
+ this.dragDepth = Math.max(this.dragDepth - 1, 0)
+
+ if (this.dragDepth === 0) {
+ this.pushEvent("dragleave", {})
+ }
+ }
+
+ this.onDrop = (e) => {
+ e.preventDefault()
+ this.dragDepth = 0
+ this.pushEvent("drop", {})
+ }
+
+ this.el.addEventListener("dragenter", this.onDragEnter)
+ this.el.addEventListener("dragover", this.onDragOver)
+ this.el.addEventListener("dragleave", this.onDragLeave)
+ this.el.addEventListener("drop", this.onDrop)
+ },
+
+ destroyed() {
+ this.el.removeEventListener("dragenter", this.onDragEnter)
+ this.el.removeEventListener("dragover", this.onDragOver)
+ this.el.removeEventListener("dragleave", this.onDragLeave)
+ this.el.removeEventListener("drop", this.onDrop)
+ }
+}
+
+// CodeEditor hook for better textarea handling
+export const CodeEditor = {
+ mounted() {
+ this.el.addEventListener("keydown", (e) => {
+ // Handle Tab key to insert spaces instead of changing focus
+ if (e.key === "Tab") {
+ e.preventDefault()
+ const start = this.el.selectionStart
+ const end = this.el.selectionEnd
+ const value = this.el.value
+
+ // Insert 2 spaces
+ this.el.value = value.substring(0, start) + " " + value.substring(end)
+ this.el.selectionStart = this.el.selectionEnd = start + 2
+ }
+
+ // Ctrl/Cmd + S to save
+ if ((e.ctrlKey || e.metaKey) && e.key === "s") {
+ e.preventDefault()
+ // Submit the form
+ this.el.closest("form").requestSubmit()
+ }
+ })
+
+ // Auto-resize textarea to fit content (optional)
+ this.autoResize()
+ this.el.addEventListener("input", () => this.autoResize())
+ },
+
+ autoResize() {
+ // Keep minimum height but grow with content
+ this.el.style.height = "auto"
+ const minHeight = 400
+ this.el.style.height = Math.max(minHeight, this.el.scrollHeight) + "px"
+ }
+}
diff --git a/apps/elektrine/assets/js/hooks/timeline_hooks.js b/apps/elektrine/assets/js/hooks/timeline_hooks.js
new file mode 100644
index 0000000..6484d7f
--- /dev/null
+++ b/apps/elektrine/assets/js/hooks/timeline_hooks.js
@@ -0,0 +1,714 @@
+/**
+ * Timeline Hooks
+ * Hooks for timeline/feed interactions including post clicks, infinite scroll,
+ * dwell time tracking and infinite scroll restoration.
+ */
+
+// Dwell time tracking constants
+const SCROLL_PAST_THRESHOLD_MS = 500;
+const MIN_DWELL_TIME_MS = 1000;
+const BATCH_INTERVAL_MS = 5000;
+
+function currentScrollY() {
+ return Math.max(
+ window.scrollY || 0,
+ window.pageYOffset || 0,
+ document.documentElement?.scrollTop || 0,
+ document.body?.scrollTop || 0,
+ );
+}
+
+function postAnchorFromElement(card) {
+ const postId = card?.dataset?.postId;
+ if (!postId) return null;
+
+ return { postId, top: card.getBoundingClientRect().top };
+}
+
+function findVisiblePostAnchor(root = document) {
+ const viewportHeight =
+ window.innerHeight || document.documentElement.clientHeight;
+ const viewportWidth =
+ window.innerWidth || document.documentElement.clientWidth;
+
+ if (viewportHeight <= 0 || viewportWidth <= 0) return null;
+
+ const rootRect = root.getBoundingClientRect?.();
+ const rootCenterX = rootRect
+ ? rootRect.left + rootRect.width / 2
+ : viewportWidth / 2;
+ const sampleX = Math.max(0, Math.min(viewportWidth - 1, rootCenterX));
+ const sampleYs = [
+ Math.min(96, viewportHeight - 1),
+ Math.floor(viewportHeight * 0.25),
+ Math.floor(viewportHeight * 0.5),
+ Math.floor(viewportHeight * 0.75),
+ ].filter((y) => y >= 0 && y < viewportHeight);
+
+ for (const sampleY of sampleYs) {
+ const target = document.elementFromPoint(sampleX, sampleY);
+ const card = target?.closest?.("[data-post-id]");
+ if (card && root.contains(card)) {
+ const anchor = postAnchorFromElement(card);
+ if (anchor) return anchor;
+ }
+ }
+
+ const lowerBound = -Math.max(viewportHeight * 1.5, 1200);
+ const belowBreak = viewportHeight + Math.max(viewportHeight, 1200);
+ const postCards = root.querySelectorAll("[data-post-id]");
+
+ for (const card of postCards) {
+ const rect = card.getBoundingClientRect();
+ if (rect.bottom <= lowerBound) continue;
+ if (rect.top >= belowBreak) break;
+ if (rect.bottom <= 0 || rect.top >= viewportHeight) continue;
+
+ const anchor = postAnchorFromElement(card);
+ if (anchor) return anchor;
+ }
+
+ return null;
+}
+
+// Global state for batching dwell time updates
+let dwellTimeBuffer = new Map();
+let batchTimeout = null;
+let navigationFlushInProgress = false;
+const activeDwellTrackers = new Set();
+let infiniteScrollRestoreSnapshot = null;
+const ACTION_LOCK_MS = 700;
+const actionLocks = new Map();
+
+if (typeof window !== "undefined") {
+ // js-check: allow-global-listener-singleton
+ window.addEventListener(
+ "click",
+ (event) => {
+ const target = event.target instanceof Element ? event.target : null;
+ const actionEl = target?.closest?.("[data-action-lock-key]");
+ if (!(actionEl instanceof HTMLButtonElement)) return;
+
+ const key = actionEl.dataset.actionLockKey;
+ if (!key) return;
+
+ if (actionLocks.has(key)) {
+ event.preventDefault();
+ event.stopImmediatePropagation();
+ return;
+ }
+
+ actionLocks.set(key, window.setTimeout(() => actionLocks.delete(key), ACTION_LOCK_MS));
+ },
+ true,
+ );
+}
+
+if (typeof window !== "undefined") {
+ window.addEventListener("phx:page-loading-start", () => {
+ if (navigationFlushInProgress || activeDwellTrackers.size === 0) return;
+
+ const firstTracker = activeDwellTrackers.values().next().value;
+ if (firstTracker) void flushDwellBatchBeforeNavigation(firstTracker);
+ });
+
+ window.addEventListener("phx:page-loading-stop", () => {
+ navigationFlushInProgress = false;
+ });
+}
+
+function buildDwellPayload(state, dwellTimeMs) {
+ if (!state.postId || dwellTimeMs < MIN_DWELL_TIME_MS) return null;
+
+ return {
+ post_id: state.postId,
+ dwell_time_ms: dwellTimeMs,
+ scroll_depth: state.maxScrollDepth,
+ expanded: state.wasExpanded,
+ source: state.source,
+ };
+}
+
+function mergeDwellPayloads(existing, incoming) {
+ if (!existing) return incoming;
+ if (!incoming) return existing;
+
+ return {
+ post_id: incoming.post_id || existing.post_id,
+ dwell_time_ms:
+ (existing.dwell_time_ms || 0) + (incoming.dwell_time_ms || 0),
+ scroll_depth: Math.max(
+ existing.scroll_depth || 0,
+ incoming.scroll_depth || 0,
+ ),
+ expanded: Boolean(existing.expanded || incoming.expanded),
+ source: incoming.source || existing.source,
+ };
+}
+
+function queueDwellPayload(postId, payload) {
+ if (!postId || !payload) return;
+ dwellTimeBuffer.set(
+ postId,
+ mergeDwellPayloads(dwellTimeBuffer.get(postId), payload),
+ );
+}
+
+function takeQueuedDwellPayload(postId) {
+ if (!postId) return null;
+
+ const payload = dwellTimeBuffer.get(postId) || null;
+ if (payload) dwellTimeBuffer.delete(postId);
+ return payload;
+}
+
+function registerDwellTracker(hook) {
+ if (hook?.postId) activeDwellTrackers.add(hook);
+}
+
+function unregisterDwellTracker(hook) {
+ activeDwellTrackers.delete(hook);
+}
+
+async function flushDwellBatchBeforeNavigation(hook) {
+ navigationFlushInProgress = true;
+
+ activeDwellTrackers.forEach((tracker) => {
+ tracker.pauseTracking?.();
+ });
+
+ if (batchTimeout) {
+ clearTimeout(batchTimeout);
+ batchTimeout = null;
+ }
+
+ if (dwellTimeBuffer.size === 0) return;
+
+ const data = Array.from(dwellTimeBuffer.values());
+ dwellTimeBuffer.clear();
+
+ await Promise.race([
+ safePushEvent(hook, "record_dwell_times", { views: data }),
+ new Promise((resolve) => setTimeout(resolve, 150)),
+ ]);
+}
+
+function isHookConnected(hook) {
+ return Boolean(hook?.liveSocket?.isConnected?.() && hook?.el?.isConnected);
+}
+
+function safePushEvent(hook, event, payload) {
+ if (!isHookConnected(hook)) return Promise.resolve(null);
+ return Promise.resolve(hook.pushEvent(event, payload)).catch(() => null);
+}
+
+function firstElementInEventPath(event) {
+ if (typeof event?.composedPath !== "function") return null;
+
+ return event
+ .composedPath()
+ .find((node) => node instanceof Element) || null;
+}
+
+function eventPathContainsSelector(event, selector) {
+ if (typeof event?.composedPath !== "function") return false;
+
+ return event.composedPath().some((node) => {
+ return node instanceof Element && node.matches(selector);
+ });
+}
+
+/**
+ * Post Click Hook
+ * Handles navigation when clicking on posts and tracks dwell time
+ */
+export const PostClick = {
+ mounted() {
+ this.suppressNextClickFromPickerDismiss = false;
+
+ this.handlePointerDown = (e) => {
+ const openReactionPicker = this.el.querySelector(
+ "[data-reaction-picker-root][data-portal-dropdown-open='true'], [data-reaction-picker-root]:focus-within",
+ );
+
+ if (openReactionPicker && !e.target.closest("[data-reaction-picker-root]")) {
+ this.suppressNextClickFromPickerDismiss = true;
+ }
+ };
+
+ this.handleClick = async (e) => {
+ const target =
+ e.target instanceof Element
+ ? e.target
+ : e.target?.parentElement || firstElementInEventPath(e);
+
+ if (this.suppressNextClickFromPickerDismiss) {
+ this.suppressNextClickFromPickerDismiss = false;
+ return;
+ }
+
+ if (!target) return;
+
+ if (
+ eventPathContainsSelector(
+ e,
+ "[data-portal-dropdown-root], [data-portal-dropdown-trigger], [data-portal-dropdown-menu], [data-reaction-picker-root]",
+ ) ||
+ target.closest(
+ "[data-portal-dropdown-root], [data-portal-dropdown-trigger], [data-portal-dropdown-menu], [data-reaction-picker-root]",
+ )
+ )
+ return;
+
+ if (
+ eventPathContainsSelector(
+ e,
+ "a, button, input, textarea, select, option, label, form, .dropdown, details",
+ ) ||
+ target.closest(
+ "a, button, input, textarea, select, option, label, form, .dropdown, details",
+ )
+ )
+ return;
+
+ const nestedPostClick = target.closest('[phx-hook="PostClick"]');
+ if (nestedPostClick && nestedPostClick !== this.el) return;
+
+ const closestPhxClick = target.closest("[phx-click]");
+ if (closestPhxClick && closestPhxClick !== this.el) return;
+
+ const selection = window.getSelection();
+ if (selection && selection.toString().length > 0) return;
+
+ const navLink = this.el.querySelector("[data-post-nav-link]");
+
+ if (navLink) {
+ await flushDwellBatchBeforeNavigation(this);
+
+ if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey || e.button !== 0) {
+ navLink.dispatchEvent(
+ new MouseEvent("click", {
+ bubbles: true,
+ cancelable: true,
+ metaKey: e.metaKey,
+ ctrlKey: e.ctrlKey,
+ shiftKey: e.shiftKey,
+ altKey: e.altKey,
+ button: e.button,
+ }),
+ );
+ } else {
+ navLink.click();
+ }
+
+ return;
+ }
+ };
+
+ this.el.addEventListener("pointerdown", this.handlePointerDown);
+ this.el.addEventListener("click", this.handleClick);
+
+ // Dwell time tracking
+ if (this.el.dataset.trackDwell === "false") return;
+
+ this.postId = this.el.dataset.postId;
+ if (!this.postId) return;
+
+ this.source = this.el.dataset.source || "timeline";
+ this.startTime = null;
+ this.totalDwellTime = 0;
+ this.lastReportedDwellTime = 0;
+ this.maxScrollDepth = 0;
+ this.isVisible = false;
+ this.wasExpanded = false;
+
+ this.observer = new IntersectionObserver(
+ (entries) => this.handleIntersection(entries),
+ { root: null, rootMargin: "0px", threshold: [0, 0.25, 0.5, 0.75, 1.0] },
+ );
+ this.observer.observe(this.el);
+ registerDwellTracker(this);
+
+ this.handleVisibilityChange = () => {
+ if (document.hidden && this.isVisible) this.pauseTracking();
+ else if (!document.hidden && this.isVisible) this.resumeTracking();
+ };
+ document.addEventListener("visibilitychange", this.handleVisibilityChange);
+ },
+
+ destroyed() {
+ if (this.handlePointerDown)
+ this.el.removeEventListener("pointerdown", this.handlePointerDown);
+ if (this.handleClick)
+ this.el.removeEventListener("click", this.handleClick);
+ if (this.observer) this.observer.disconnect();
+ if (this.handleVisibilityChange) {
+ document.removeEventListener(
+ "visibilitychange",
+ this.handleVisibilityChange,
+ );
+ }
+ unregisterDwellTracker(this);
+ if (this.postId && !navigationFlushInProgress) {
+ this.pauseTracking();
+ this.sendDwellData();
+ }
+ },
+
+ handleIntersection(entries) {
+ const entry = entries[0];
+ if (entry.isIntersecting) {
+ if (!this.isVisible) {
+ this.isVisible = true;
+ this.resumeTracking();
+ }
+ if (entry.intersectionRatio > this.maxScrollDepth) {
+ this.maxScrollDepth = entry.intersectionRatio;
+ }
+ } else if (this.isVisible) {
+ this.isVisible = false;
+ this.pauseTracking();
+ if (
+ this.totalDwellTime < SCROLL_PAST_THRESHOLD_MS &&
+ this.totalDwellTime > 0
+ ) {
+ this.recordScrollPast();
+ }
+ }
+ },
+
+ resumeTracking() {
+ if (!this.startTime) this.startTime = Date.now();
+ },
+
+ pauseTracking() {
+ if (this.startTime) {
+ this.totalDwellTime += Date.now() - this.startTime;
+ this.startTime = null;
+ this.bufferDwellData();
+ }
+ },
+
+ bufferDwellData() {
+ const pendingDwellTime = this.totalDwellTime - this.lastReportedDwellTime;
+ const payload = buildDwellPayload(this, pendingDwellTime);
+
+ if (!payload) return;
+
+ queueDwellPayload(this.postId, payload);
+ this.lastReportedDwellTime = this.totalDwellTime;
+ this.scheduleBatchSend();
+ },
+
+ scheduleBatchSend() {
+ if (!batchTimeout) {
+ batchTimeout = setTimeout(() => {
+ this.sendBatch();
+ batchTimeout = null;
+ }, BATCH_INTERVAL_MS);
+ }
+ },
+
+ sendBatch() {
+ if (dwellTimeBuffer.size === 0) return;
+ const data = Array.from(dwellTimeBuffer.values());
+ dwellTimeBuffer.clear();
+ void safePushEvent(this, "record_dwell_times", { views: data });
+ },
+
+ sendDwellData() {
+ const queuedPayload = takeQueuedDwellPayload(this.postId);
+ const pendingDwellTime = this.totalDwellTime - this.lastReportedDwellTime;
+ const directPayload = buildDwellPayload(this, pendingDwellTime);
+ const payload = mergeDwellPayloads(queuedPayload, directPayload);
+
+ if (!payload) return;
+
+ if (directPayload) this.lastReportedDwellTime = this.totalDwellTime;
+
+ void safePushEvent(this, "record_dwell_time", payload);
+ },
+
+ recordScrollPast() {
+ if (this.postId) {
+ void safePushEvent(this, "record_dismissal", {
+ post_id: this.postId,
+ type: "scrolled_past",
+ dwell_time_ms: this.totalDwellTime,
+ });
+ }
+ },
+};
+
+/**
+ * Feed pagination hook.
+ * Preserves scroll position around manual "load more" updates.
+ * Auto-loading is opt-in with data-auto-load="true".
+ */
+// Holds a feed's scroll position across a "load more".
+//
+// Pagination itself is a plain `phx-click` button — this hook does not fetch
+// anything. It snapshots the post nearest the viewport before LiveView patches
+// the list in and scrolls back to it afterwards, because a patch that grows the
+// list can otherwise land the reader somewhere else.
+export const ManualPagination = {
+ mounted() {
+ this.pending = false;
+ this.restoreRAF = null;
+ this.restoreTimeouts = [];
+ this.prePatchMode = null;
+ this.loadingMore = this.el.dataset.loadingMore === "true";
+ this.prePatchShouldPreserve = false;
+ this.prePatchScrollY = null;
+ this.prePatchAnchor = null;
+ this.loadCycleStartY = null;
+ this.lastKnownScrollY = currentScrollY();
+ this.previousScrollRestoration = null;
+
+ this.disableNativeScrollRestoration();
+ this.resetReloadScrollPosition();
+ this.clearGlobalRestoreSnapshot();
+
+ this.handleScroll = () => {
+ this.lastKnownScrollY = currentScrollY();
+ this.cancelSettledRestore();
+ };
+ window.addEventListener("scroll", this.handleScroll, { passive: true });
+
+ this.handleClick = (event) => {
+ const loadMoreTrigger = event.target?.closest?.(
+ '[phx-click="load-more"], [phx-click="load_more_posts"], [phx-click="load_more_overview"], [data-load-more-trigger]',
+ );
+ if (!loadMoreTrigger || !this.el.contains(loadMoreTrigger)) return;
+
+ this.beginLoadCycle();
+ };
+ this.el.addEventListener("click", this.handleClick);
+ },
+
+ beforeUpdate() {
+ this.prePatchMode = this.pending || this.loadingMore ? "load-cycle" : null;
+ this.prePatchShouldPreserve = this.prePatchMode !== null;
+
+ if (this.prePatchShouldPreserve) {
+ this.prePatchScrollY = this.getStableScrollY();
+ this.prePatchAnchor = this.findVisiblePostAnchor();
+ this.storeGlobalRestoreSnapshot(this.prePatchAnchor, this.prePatchScrollY);
+ } else {
+ this.prePatchScrollY = null;
+ this.prePatchAnchor = null;
+ }
+ },
+
+ disableNativeScrollRestoration() {
+ if (!window.history || !("scrollRestoration" in window.history)) return;
+
+ this.previousScrollRestoration = window.history.scrollRestoration;
+ window.history.scrollRestoration = "manual";
+ },
+
+ resetReloadScrollPosition() {
+ const navigation = performance.getEntriesByType?.("navigation")?.[0];
+ const isReload = navigation?.type === "reload";
+
+ if (isReload && currentScrollY() > 0) {
+ window.scrollTo({ top: 0, behavior: "auto" });
+ this.lastKnownScrollY = 0;
+ }
+ },
+
+ beginLoadCycle() {
+ if (this.pending) return;
+
+ this.pending = true;
+ this.lastKnownScrollY = currentScrollY();
+ this.loadCycleStartY = this.lastKnownScrollY;
+ this.prePatchMode = "load-cycle";
+ this.prePatchShouldPreserve = true;
+ this.prePatchScrollY = this.lastKnownScrollY;
+ this.prePatchAnchor = this.findVisiblePostAnchor();
+ this.storeGlobalRestoreSnapshot(this.prePatchAnchor, this.prePatchScrollY);
+ },
+
+ updated() {
+ this.restoreFromGlobalSnapshotIfNeeded();
+
+ this.loadingMore = this.el.dataset.loadingMore === "true";
+
+ if (this.prePatchShouldPreserve) {
+ this.restoreAnchorPosition(this.prePatchAnchor, this.prePatchScrollY);
+
+ if (this.prePatchMode === "load-cycle") {
+ this.scheduleSettledRestore(
+ this.prePatchAnchor,
+ this.prePatchScrollY,
+ true,
+ );
+ }
+ }
+
+ this.prePatchMode = null;
+ this.prePatchShouldPreserve = false;
+ this.prePatchScrollY = null;
+ this.prePatchAnchor = null;
+ this.lastKnownScrollY = currentScrollY();
+ if (!this.loadingMore) {
+ this.clearGlobalRestoreSnapshot();
+ this.loadCycleStartY = null;
+ this.pending = false;
+ }
+ },
+
+ findVisiblePostAnchor() {
+ return findVisiblePostAnchor(this.el);
+ },
+
+ getStableScrollY() {
+ const scrollY = currentScrollY();
+
+ if (scrollY === 0 && this.lastKnownScrollY > 0) {
+ return this.lastKnownScrollY;
+ }
+
+ this.lastKnownScrollY = scrollY;
+ return scrollY;
+ },
+
+ findPostById(postId) {
+ if (!postId) return null;
+
+ const escapedPostId =
+ typeof CSS !== "undefined" && CSS.escape ? CSS.escape(postId) : postId;
+ return this.el.querySelector(`[data-post-id="${escapedPostId}"]`);
+ },
+
+ restoreAnchorPosition(anchorSnapshot, fallbackScrollY) {
+ let restored = false;
+ const currentY = currentScrollY();
+
+ if (anchorSnapshot?.postId) {
+ const anchor = this.findPostById(anchorSnapshot.postId);
+
+ if (anchor) {
+ const newTop = anchor.getBoundingClientRect().top;
+ const delta = newTop - anchorSnapshot.top;
+
+ if (delta !== 0) window.scrollBy(0, delta);
+ restored = true;
+ }
+ }
+
+ const shouldUseFallbackScroll = (targetY) => {
+ if (!Number.isFinite(targetY) || targetY <= 0) return false;
+
+ return currentY === 0 || currentY + 120 < targetY;
+ };
+
+ if (!restored && shouldUseFallbackScroll(fallbackScrollY)) {
+ window.scrollTo({ top: fallbackScrollY, behavior: "auto" });
+ restored = true;
+ } else if (!restored && shouldUseFallbackScroll(this.loadCycleStartY)) {
+ window.scrollTo({ top: this.loadCycleStartY, behavior: "auto" });
+ restored = true;
+ }
+
+ return restored;
+ },
+
+ cancelSettledRestore() {
+ if (this.restoreRAF) cancelAnimationFrame(this.restoreRAF);
+ this.restoreTimeouts.forEach((timeoutId) => clearTimeout(timeoutId));
+ this.restoreRAF = null;
+ this.restoreTimeouts = [];
+ },
+
+ scheduleSettledRestore(
+ anchorSnapshot,
+ fallbackScrollY,
+ includeTimeout = false,
+ ) {
+ this.cancelSettledRestore();
+
+ this.restoreRAF = requestAnimationFrame(() => {
+ this.restoreRAF = null;
+ this.restoreAnchorPosition(anchorSnapshot, fallbackScrollY);
+ });
+
+ if (includeTimeout) {
+ [120, 280, 520].forEach((delay) => {
+ const timeoutId = setTimeout(() => {
+ this.restoreTimeouts = this.restoreTimeouts.filter(
+ (id) => id !== timeoutId,
+ );
+ this.restoreAnchorPosition(anchorSnapshot, fallbackScrollY);
+ }, delay);
+
+ this.restoreTimeouts.push(timeoutId);
+ });
+ }
+ },
+
+ storeGlobalRestoreSnapshot(anchorSnapshot, fallbackScrollY) {
+ infiniteScrollRestoreSnapshot = {
+ rootId: this.el.id || null,
+ anchorSnapshot: anchorSnapshot || null,
+ fallbackScrollY,
+ capturedAt: Date.now(),
+ };
+ },
+
+ restoreFromGlobalSnapshotIfNeeded(fromMount = false) {
+ const snapshot = infiniteScrollRestoreSnapshot;
+
+ if (!snapshot) return;
+ if (snapshot.rootId && this.el.id && snapshot.rootId !== this.el.id) return;
+ if (Date.now() - snapshot.capturedAt > 5000) {
+ this.clearGlobalRestoreSnapshot();
+ return;
+ }
+
+ const shouldRestore =
+ currentScrollY() === 0 &&
+ Number.isFinite(snapshot.fallbackScrollY) &&
+ snapshot.fallbackScrollY > 0;
+
+ if (!shouldRestore) return;
+
+ this.restoreAnchorPosition(snapshot.anchorSnapshot, snapshot.fallbackScrollY);
+
+ if (!fromMount) {
+ this.scheduleSettledRestore(
+ snapshot.anchorSnapshot,
+ snapshot.fallbackScrollY,
+ true,
+ );
+ }
+ },
+
+ clearGlobalRestoreSnapshot() {
+ if (!infiniteScrollRestoreSnapshot) return;
+ if (
+ infiniteScrollRestoreSnapshot.rootId &&
+ this.el.id &&
+ infiniteScrollRestoreSnapshot.rootId !== this.el.id
+ ) {
+ return;
+ }
+
+ infiniteScrollRestoreSnapshot = null;
+ },
+
+ destroyed() {
+ this.cancelSettledRestore();
+ if (this.handleScroll)
+ window.removeEventListener("scroll", this.handleScroll);
+ if (this.handleClick) this.el.removeEventListener("click", this.handleClick);
+ if (
+ this.previousScrollRestoration &&
+ window.history &&
+ "scrollRestoration" in window.history
+ ) {
+ window.history.scrollRestoration = this.previousScrollRestoration;
+ }
+ },
+};
diff --git a/apps/elektrine/assets/js/hooks/timeline_media_hooks.js b/apps/elektrine/assets/js/hooks/timeline_media_hooks.js
new file mode 100644
index 0000000..a746abf
--- /dev/null
+++ b/apps/elektrine/assets/js/hooks/timeline_media_hooks.js
@@ -0,0 +1,157 @@
+import { OverlayPortal } from "../utils/overlay_portal";
+import { registerUserHoverCardHook, unregisterUserHoverCardHook } from "../utils/user_hover_card_scroll";
+
+/**
+ * User Hover Card Hook
+ * Shows a profile preview card when hovering over usernames/avatars
+ */
+export const UserHoverCard = {
+ mounted() {
+ this.card = this.el.querySelector("[data-hover-card]");
+ if (!this.card) return;
+
+ this.trigger = this.el.querySelector("[data-hover-trigger]") || this.el;
+ this.card.__userHoverCard = this;
+ this.portal = new OverlayPortal(this.card, {
+ portalRoot: this.el.closest("[data-phx-main]") || document.body,
+ });
+ this.showTimeout = null;
+ this.hideTimeout = null;
+ this.isCardHovered = false;
+ this.isVisible = false;
+ registerUserHoverCardHook(this);
+
+ this.handleMouseEnter = () => {
+ clearTimeout(this.hideTimeout);
+ this.showTimeout = setTimeout(() => this.showCard(), 400);
+ };
+
+ this.handleMouseLeave = () => {
+ clearTimeout(this.showTimeout);
+ this.hideTimeout = setTimeout(() => {
+ if (!this.isCardHovered) this.hideCard();
+ }, 200);
+ };
+
+ this.handleCardEnter = () => {
+ this.isCardHovered = true;
+ clearTimeout(this.hideTimeout);
+ };
+
+ this.handleCardLeave = () => {
+ this.isCardHovered = false;
+ this.hideTimeout = setTimeout(() => this.hideCard(), 200);
+ };
+
+ this.trigger.addEventListener("mouseenter", this.handleMouseEnter);
+ this.trigger.addEventListener("mouseleave", this.handleMouseLeave);
+ this.card.addEventListener("mouseenter", this.handleCardEnter);
+ this.card.addEventListener("mouseleave", this.handleCardLeave);
+ },
+
+ showCard() {
+ if (this.card) {
+ document.querySelectorAll("[data-hover-card]").forEach((card) => {
+ if (card !== this.card) {
+ if (card.__userHoverCard) {
+ card.__userHoverCard.hideCard();
+ } else {
+ card.classList.remove("visible", "scale-100");
+ card.classList.add("invisible", "scale-95");
+ }
+ }
+ });
+
+ this.portal.mount();
+ this.portal.positionNear(this.trigger);
+ this.card.classList.remove("invisible", "scale-95");
+ this.card.classList.add("visible", "scale-100");
+ this.isVisible = true;
+ }
+ },
+
+ hideCard() {
+ this.isVisible = false;
+ if (this.card) {
+ this.card.classList.remove("visible", "scale-100");
+ this.card.classList.add("invisible", "scale-95");
+ this.portal.restore();
+ }
+ },
+
+ updated() {
+ this.trigger = this.el.querySelector("[data-hover-trigger]") || this.el;
+
+ if (this.isVisible && this.card) {
+ this.portal.positionNear(this.trigger);
+ }
+ },
+
+ dismissForScroll() {
+ if (!this.showTimeout && !this.hideTimeout && !this.isVisible) return;
+
+ clearTimeout(this.showTimeout);
+ clearTimeout(this.hideTimeout);
+ this.showTimeout = null;
+ this.hideTimeout = null;
+ this.isCardHovered = false;
+ this.hideCard();
+ },
+
+ destroyed() {
+ clearTimeout(this.showTimeout);
+ clearTimeout(this.hideTimeout);
+ unregisterUserHoverCardHook(this);
+ this.card?.removeEventListener("mouseenter", this.handleCardEnter);
+ this.card?.removeEventListener("mouseleave", this.handleCardLeave);
+ this.trigger?.removeEventListener("mouseenter", this.handleMouseEnter);
+ this.trigger?.removeEventListener("mouseleave", this.handleMouseLeave);
+ this.portal?.restore();
+ if (this.card) this.card.__userHoverCard = null;
+ },
+};
+
+/**
+ * Image Modal Hook
+ * Adds keyboard and scroll navigation support for image galleries
+ */
+export const ImageModal = {
+ mounted() {
+ this.handleKeyDown = (e) => {
+ if (e.key === "Escape") this.pushEvent("close_image_modal", {});
+ else if (e.key === "ArrowLeft") this.pushEvent("prev_image", {});
+ else if (e.key === "ArrowRight") this.pushEvent("next_image", {});
+ else if (e.key === "ArrowUp") {
+ e.preventDefault();
+ this.pushEvent("prev_media_post", {});
+ } else if (e.key === "ArrowDown") {
+ e.preventDefault();
+ this.pushEvent("next_media_post", {});
+ }
+ };
+
+ this.lastScrollTime = 0;
+ this.scrollThrottle = 200;
+
+ this.handleWheel = (e) => {
+ if (!this.el.contains(e.target)) return;
+ const now = Date.now();
+ if (now - this.lastScrollTime < this.scrollThrottle) return;
+ if (Math.abs(e.deltaY) < 10) return;
+
+ this.lastScrollTime = now;
+ e.preventDefault();
+
+ if (e.deltaY < 0) this.pushEvent("prev_image", {});
+ else this.pushEvent("next_image", {});
+ };
+
+ document.addEventListener("keydown", this.handleKeyDown);
+ document.addEventListener("wheel", this.handleWheel, { passive: false });
+ },
+
+ destroyed() {
+ document.removeEventListener("keydown", this.handleKeyDown);
+ document.removeEventListener("wheel", this.handleWheel);
+ },
+};
diff --git a/apps/elektrine/assets/js/hooks/timeline_preservation_hooks.js b/apps/elektrine/assets/js/hooks/timeline_preservation_hooks.js
new file mode 100644
index 0000000..d671149
--- /dev/null
+++ b/apps/elektrine/assets/js/hooks/timeline_preservation_hooks.js
@@ -0,0 +1,306 @@
+function currentScrollY() {
+ return Math.max(
+ window.scrollY || 0,
+ window.pageYOffset || 0,
+ document.documentElement?.scrollTop || 0,
+ document.body?.scrollTop || 0,
+ );
+}
+
+function postAnchorFromElement(card) {
+ const postId = card?.dataset?.postId;
+ if (!postId) return null;
+
+ return { postId, top: card.getBoundingClientRect().top };
+}
+
+function findVisiblePostAnchor(root = document) {
+ const viewportHeight =
+ window.innerHeight || document.documentElement.clientHeight;
+ const viewportWidth =
+ window.innerWidth || document.documentElement.clientWidth;
+
+ if (viewportHeight <= 0 || viewportWidth <= 0) return null;
+
+ const rootRect = root.getBoundingClientRect?.();
+ const rootCenterX = rootRect
+ ? rootRect.left + rootRect.width / 2
+ : viewportWidth / 2;
+ const sampleX = Math.max(0, Math.min(viewportWidth - 1, rootCenterX));
+ const sampleYs = [
+ Math.min(96, viewportHeight - 1),
+ Math.floor(viewportHeight * 0.25),
+ Math.floor(viewportHeight * 0.5),
+ Math.floor(viewportHeight * 0.75),
+ ].filter((y) => y >= 0 && y < viewportHeight);
+
+ for (const sampleY of sampleYs) {
+ const target = document.elementFromPoint(sampleX, sampleY);
+ const card = target?.closest?.("[data-post-id]");
+ if (card && root.contains(card)) {
+ const anchor = postAnchorFromElement(card);
+ if (anchor) return anchor;
+ }
+ }
+
+ const lowerBound = -Math.max(viewportHeight * 1.5, 1200);
+ const belowBreak = viewportHeight + Math.max(viewportHeight, 1200);
+ const postCards = root.querySelectorAll("[data-post-id]");
+
+ for (const card of postCards) {
+ const rect = card.getBoundingClientRect();
+ if (rect.bottom <= lowerBound) continue;
+ if (rect.top >= belowBreak) break;
+ if (rect.bottom <= 0 || rect.top >= viewportHeight) continue;
+
+ const anchor = postAnchorFromElement(card);
+ if (anchor) return anchor;
+ }
+
+ return null;
+}
+
+function findPostById(root, postId) {
+ if (!postId) return null;
+
+ const escapedPostId =
+ typeof CSS !== "undefined" && CSS.escape ? CSS.escape(postId) : postId;
+ return root.querySelector(`[data-post-id="${escapedPostId}"]`);
+}
+
+let streamRestoreSnapshot = null;
+
+/**
+ * Stream Anchor Hook
+ * Preserves the visible post position when stream patches prepend/update entries.
+ */
+export const PreserveStreamAnchor = {
+ mounted() {
+ this.prePatchAnchor = null;
+ this.prePatchScrollY = null;
+ this.restoreRAF = null;
+ this.restoreTimeouts = [];
+ this.lastKnownScrollY = currentScrollY();
+ this.clearGlobalRestoreSnapshot();
+
+ this.handleScroll = () => {
+ this.lastKnownScrollY = currentScrollY();
+ this.cancelPendingRestore();
+ };
+ window.addEventListener("scroll", this.handleScroll, { passive: true });
+ },
+
+ beforeUpdate() {
+ const stableScrollY = this.getStableScrollY();
+
+ if (stableScrollY <= 0) {
+ this.prePatchAnchor = null;
+ this.prePatchScrollY = null;
+ return;
+ }
+
+ this.prePatchAnchor = this.findVisiblePostAnchor();
+ this.prePatchScrollY = stableScrollY;
+ this.storeGlobalRestoreSnapshot(this.prePatchAnchor, this.prePatchScrollY);
+ },
+
+ updated() {
+ this.restoreFromGlobalSnapshotIfNeeded();
+
+ if (!this.prePatchAnchor?.postId && !Number.isFinite(this.prePatchScrollY)) return;
+
+ this.restoreAnchorPosition(this.prePatchAnchor, this.prePatchScrollY);
+ this.schedulePendingRestore(this.prePatchAnchor, this.prePatchScrollY);
+ this.prePatchAnchor = null;
+ this.prePatchScrollY = null;
+ this.clearGlobalRestoreSnapshot();
+ },
+
+ findVisiblePostAnchor() {
+ return findVisiblePostAnchor(this.el);
+ },
+
+ getStableScrollY() {
+ const scrollY = currentScrollY();
+
+ if (scrollY === 0 && this.lastKnownScrollY > 0) {
+ return this.lastKnownScrollY;
+ }
+
+ this.lastKnownScrollY = scrollY;
+ return scrollY;
+ },
+
+ findPostById(postId) {
+ return findPostById(this.el, postId);
+ },
+
+ restoreAnchorPosition(anchorSnapshot, fallbackScrollY) {
+ if (anchorSnapshot?.postId) {
+ const anchor = this.findPostById(anchorSnapshot.postId);
+
+ if (anchor) {
+ const delta = anchor.getBoundingClientRect().top - anchorSnapshot.top;
+ if (delta !== 0) window.scrollBy(0, delta);
+ return;
+ }
+ }
+
+ if (
+ Number.isFinite(fallbackScrollY) &&
+ fallbackScrollY > 0 &&
+ currentScrollY() === 0
+ ) {
+ window.scrollTo({ top: fallbackScrollY, behavior: "auto" });
+ }
+ },
+
+ cancelPendingRestore() {
+ if (this.restoreRAF) cancelAnimationFrame(this.restoreRAF);
+ this.restoreTimeouts.forEach((timeoutId) => clearTimeout(timeoutId));
+ this.restoreRAF = null;
+ this.restoreTimeouts = [];
+ },
+
+ schedulePendingRestore(anchorSnapshot, fallbackScrollY) {
+ this.cancelPendingRestore();
+
+ this.restoreRAF = requestAnimationFrame(() => {
+ this.restoreRAF = null;
+ this.restoreAnchorPosition(anchorSnapshot, fallbackScrollY);
+ });
+ [120, 280, 520].forEach((delay) => {
+ const timeoutId = setTimeout(() => {
+ this.restoreTimeouts = this.restoreTimeouts.filter(
+ (id) => id !== timeoutId,
+ );
+ this.restoreAnchorPosition(anchorSnapshot, fallbackScrollY);
+ }, delay);
+
+ this.restoreTimeouts.push(timeoutId);
+ });
+ },
+
+ storeGlobalRestoreSnapshot(anchorSnapshot, fallbackScrollY) {
+ streamRestoreSnapshot = {
+ rootId: this.el.id || null,
+ anchorSnapshot: anchorSnapshot || null,
+ fallbackScrollY,
+ capturedAt: Date.now(),
+ };
+ },
+
+ restoreFromGlobalSnapshotIfNeeded(fromMount = false) {
+ const snapshot = streamRestoreSnapshot;
+
+ if (!snapshot) return;
+ if (snapshot.rootId && this.el.id && snapshot.rootId !== this.el.id) return;
+ if (Date.now() - snapshot.capturedAt > 5000) {
+ this.clearGlobalRestoreSnapshot();
+ return;
+ }
+
+ const shouldRestore =
+ currentScrollY() === 0 &&
+ Number.isFinite(snapshot.fallbackScrollY) &&
+ snapshot.fallbackScrollY > 0;
+
+ if (!shouldRestore) return;
+
+ this.restoreAnchorPosition(snapshot.anchorSnapshot, snapshot.fallbackScrollY);
+
+ if (!fromMount) {
+ this.schedulePendingRestore(snapshot.anchorSnapshot, snapshot.fallbackScrollY);
+ }
+ },
+
+ clearGlobalRestoreSnapshot() {
+ if (!streamRestoreSnapshot) return;
+ if (
+ streamRestoreSnapshot.rootId &&
+ this.el.id &&
+ streamRestoreSnapshot.rootId !== this.el.id
+ ) {
+ return;
+ }
+
+ streamRestoreSnapshot = null;
+ },
+
+ destroyed() {
+ this.cancelPendingRestore();
+ this.clearGlobalRestoreSnapshot();
+ if (this.handleScroll)
+ window.removeEventListener("scroll", this.handleScroll);
+ },
+};
+
+/**
+ * "Show N new posts" — Twitter-style: after the stream prepends, jump to the
+ * top of the feed so the newly inserted posts are actually visible.
+ * (Previously preserved scroll, which left new posts above the viewport.)
+ */
+export const PreserveQueuedPostsButtonScroll = {
+ mounted() {
+ this.restoreRAF = null;
+ this.restoreTimeouts = [];
+ this.pendingScrollTop = false;
+
+ this.handleClick = () => {
+ this.pendingScrollTop = true;
+ };
+
+ this.el.addEventListener("click", this.handleClick);
+ },
+
+ updated() {
+ if (!this.pendingScrollTop) return;
+ this.pendingScrollTop = false;
+ this.scrollFeedToTop();
+ },
+
+ destroyed() {
+ if (this.handleClick)
+ this.el.removeEventListener("click", this.handleClick);
+
+ // Button unmounts once the queue is empty — still scroll so new posts show.
+ if (this.pendingScrollTop) {
+ this.pendingScrollTop = false;
+ this.scrollFeedToTop();
+ }
+
+ this.restoreTimeouts.forEach((id) => clearTimeout(id));
+ this.restoreTimeouts = [];
+ if (this.restoreRAF) cancelAnimationFrame(this.restoreRAF);
+ },
+
+ scrollFeedToTop() {
+ const scrollTop = () => {
+ const target =
+ document.getElementById("timeline-posts-stream") ||
+ document.getElementById("timeline-infinite-scroll");
+
+ if (target && typeof target.scrollIntoView === "function") {
+ target.scrollIntoView({ behavior: "smooth", block: "start" });
+ }
+
+ window.scrollTo({ top: 0, behavior: "smooth" });
+ };
+
+ this.restoreRAF = requestAnimationFrame(() => {
+ this.restoreRAF = null;
+ scrollTop();
+ });
+
+ // Stream patches can lag a frame; re-assert a couple times.
+ [80, 200].forEach((delay) => {
+ const timeoutId = setTimeout(() => {
+ this.restoreTimeouts = this.restoreTimeouts.filter(
+ (id) => id !== timeoutId,
+ );
+ scrollTop();
+ }, delay);
+ this.restoreTimeouts.push(timeoutId);
+ });
+ },
+};
diff --git a/apps/elektrine/assets/js/hooks/timeline_session_continuity.js b/apps/elektrine/assets/js/hooks/timeline_session_continuity.js
new file mode 100644
index 0000000..bdd22c9
--- /dev/null
+++ b/apps/elektrine/assets/js/hooks/timeline_session_continuity.js
@@ -0,0 +1,112 @@
+const TIMELINE_LAST_VISIT_KEY = "timeline-last-visit-at";
+const COMMUNITY_LAST_VISIT_KEY = "community-last-visit-at";
+const COMMUNITY_PREFERENCES_KEY = "community-view-preferences";
+
+function safeJsonParse(value, fallback) {
+ if (!value) return fallback;
+
+ try {
+ return JSON.parse(value);
+ } catch {
+ return fallback;
+ }
+}
+
+function readStorageMap(key) {
+ return safeJsonParse(window.localStorage?.getItem(key), {});
+}
+
+function writeStorageMap(key, value) {
+ try {
+ window.localStorage?.setItem(key, JSON.stringify(value));
+ } catch {
+ // Ignore storage failures so LiveView stays functional.
+ }
+}
+
+export const SessionContinuity = {
+ mounted() {
+ this.scope = this.el.dataset.scope || "timeline";
+ this.userId = this.el.dataset.userId || null;
+
+ if (!this.userId) return;
+
+ if (this.scope === "timeline") this.restoreTimelineContinuity();
+ if (this.scope === "community") this.restoreCommunityPreferences();
+
+ this.handlePageHide = () => this.persistCurrentState();
+ window.addEventListener("pagehide", this.handlePageHide);
+ },
+
+ updated() {
+ if (!this.userId) return;
+ if (this.scope === "community") this.persistCommunityPreferences();
+ },
+
+ destroyed() {
+ this.persistCurrentState();
+ window.removeEventListener("pagehide", this.handlePageHide);
+ },
+
+ persistCurrentState() {
+ if (!this.userId) return;
+
+ if (this.scope === "timeline") {
+ try {
+ window.localStorage?.setItem(
+ TIMELINE_LAST_VISIT_KEY,
+ String(Date.now()),
+ );
+ } catch {
+ // Ignore storage failures.
+ }
+ return;
+ }
+
+ if (this.scope === "community") {
+ const communityId = this.el.dataset.communityId;
+ if (!communityId) return;
+
+ const visits = readStorageMap(COMMUNITY_LAST_VISIT_KEY);
+ visits[communityId] = Date.now();
+ writeStorageMap(COMMUNITY_LAST_VISIT_KEY, visits);
+ this.persistCommunityPreferences();
+ }
+ },
+
+ restoreTimelineContinuity() {
+ const lastTimelineVisitAt = window.localStorage?.getItem(
+ TIMELINE_LAST_VISIT_KEY,
+ );
+ const communityLastVisitedAt = readStorageMap(COMMUNITY_LAST_VISIT_KEY);
+
+ this.pushEvent("restore_session_continuity", {
+ last_timeline_visit_at: lastTimelineVisitAt,
+ community_last_visited_at: communityLastVisitedAt,
+ });
+ },
+
+ restoreCommunityPreferences() {
+ const communityId = this.el.dataset.communityId;
+ if (!communityId) return;
+
+ const preferences = readStorageMap(COMMUNITY_PREFERENCES_KEY);
+ const communityPreferences = preferences[communityId];
+
+ if (communityPreferences) {
+ this.pushEvent("restore_community_preferences", communityPreferences);
+ }
+ },
+
+ persistCommunityPreferences() {
+ const communityId = this.el.dataset.communityId;
+ if (!communityId) return;
+
+ const preferences = readStorageMap(COMMUNITY_PREFERENCES_KEY);
+ preferences[communityId] = {
+ view: this.el.dataset.currentView || "posts",
+ sort: this.el.dataset.sortBy || "hot",
+ };
+ writeStorageMap(COMMUNITY_PREFERENCES_KEY, preferences);
+ },
+};
diff --git a/apps/elektrine/assets/js/hooks/timeline_status_hooks.js b/apps/elektrine/assets/js/hooks/timeline_status_hooks.js
new file mode 100644
index 0000000..918ccbd
--- /dev/null
+++ b/apps/elektrine/assets/js/hooks/timeline_status_hooks.js
@@ -0,0 +1,195 @@
+function parseCount(value) {
+ const parsed = Number.parseInt(String(value ?? "0").replace(/[^\d-]/g, ""), 10);
+ return Number.isFinite(parsed) ? parsed : 0;
+}
+
+function formatCount(value) {
+ return String(value);
+}
+
+export const AnimatedCount = {
+ mounted() {
+ this.animation = null;
+ this.currentCount = parseCount(this.el.dataset.count ?? this.el.textContent);
+ this.prepareElement();
+ this.setCount(this.currentCount);
+ },
+
+ updated() {
+ const nextCount = parseCount(this.el.dataset.count);
+ const fromCount = this.currentCount ?? parseCount(this.el.textContent);
+
+ if (nextCount === fromCount) {
+ this.setCount(nextCount);
+ return;
+ }
+
+ if (window.matchMedia?.("(prefers-reduced-motion: reduce)")?.matches) {
+ this.setCount(nextCount);
+ return;
+ }
+
+ this.animateCountChange(fromCount, nextCount);
+ },
+
+ destroyed() {
+ this.animation?.cancel();
+ },
+
+ prepareElement() {
+ this.el.style.overflow = "hidden";
+ this.el.style.position = "relative";
+ this.el.style.verticalAlign = "bottom";
+ this.el.style.fontVariantNumeric = "tabular-nums";
+ },
+
+ // A zero count is noise next to an icon — an action row would otherwise read
+ // "0 0 0 0". Collapsing the element (rather than emptying it) also drops it
+ // out of the button's flex gap, so the icon isn't left with dead space.
+ //
+ // Opt-in via data-hide-zero: counts that carry a text label ("3 comments")
+ // must keep their zero, or the label is left stranded on its own.
+ syncVisibility(count) {
+ const hideZero = this.el.dataset.hideZero === "true";
+ this.el.style.display = hideZero && count === 0 ? "none" : "inline-block";
+ },
+
+ setCount(count) {
+ this.animation?.cancel();
+ this.animation = null;
+ this.currentCount = count;
+ this.el.textContent = "";
+ this.el.appendChild(this.buildCountSpan(count));
+ this.syncVisibility(count);
+ },
+
+ buildCountSpan(count) {
+ const span = document.createElement("span");
+ span.textContent = formatCount(count);
+ span.style.display = "block";
+ return span;
+ },
+
+ animateCountChange(fromCount, toCount) {
+ this.animation?.cancel();
+
+ const direction = toCount > fromCount ? -1 : 1;
+ const currentSpan = this.buildCountSpan(toCount);
+ const previousSpan = this.buildCountSpan(fromCount);
+
+ currentSpan.style.transform = `translateY(${100 * direction}%)`;
+ previousSpan.style.position = "absolute";
+ previousSpan.style.inset = "0";
+
+ this.el.textContent = "";
+ this.el.append(currentSpan, previousSpan);
+ // Both ends of the roll have to be on screen for it to read as a roll;
+ // setCount() re-collapses the element afterwards if it landed on zero.
+ this.el.style.display = "inline-block";
+
+ const currentAnimation = currentSpan.animate(
+ [
+ { transform: `translateY(${100 * direction}%)` },
+ { transform: "translateY(0%)" },
+ ],
+ { duration: 200, easing: "ease-out", fill: "forwards" },
+ );
+
+ const previousAnimation = previousSpan.animate(
+ [
+ { transform: "translateY(0%)" },
+ { transform: `translateY(${-100 * direction}%)` },
+ ],
+ { duration: 200, easing: "ease-out", fill: "forwards" },
+ );
+
+ this.animation = currentAnimation;
+ this.currentCount = toCount;
+
+ Promise.allSettled([currentAnimation.finished, previousAnimation.finished]).then(() => {
+ if (this.animation === currentAnimation) this.setCount(toCount);
+ });
+ },
+};
+
+const REMOTE_FOLLOW_BUTTON_CLASSES = [
+ "btn-ghost",
+ "btn-secondary",
+ "btn-primary",
+ "btn-disabled",
+ "border",
+ "border-base-300",
+ "hover:border-secondary",
+ "hover:text-secondary",
+ "phx-click-loading:bg-base-200",
+ "phx-click-loading:text-base-content",
+];
+
+// Keep `timeline.none` in step with the server-rendered class list in
+// timeline_post_footer.ex — this hook re-applies it after every state change.
+const REMOTE_FOLLOW_BUTTON_VARIANTS = {
+ timeline: {
+ following: { add: ["btn-ghost"], disabled: false },
+ pending: { add: ["btn-ghost"], disabled: false },
+ none: {
+ add: [
+ "btn-ghost",
+ "border",
+ "border-base-300",
+ "hover:border-secondary",
+ "hover:text-secondary",
+ "phx-click-loading:bg-base-200",
+ "phx-click-loading:text-base-content",
+ ],
+ disabled: false,
+ },
+ },
+ "hover-card": {
+ following: { add: ["btn-ghost"], disabled: false },
+ pending: { add: ["btn-disabled"], disabled: true },
+ none: {
+ add: [
+ "btn-primary",
+ "phx-click-loading:bg-base-200",
+ "phx-click-loading:text-base-content",
+ ],
+ disabled: false,
+ },
+ },
+};
+
+export const RemoteFollowButton = {
+ mounted() {
+ this.remoteActorId = String(this.el.dataset.remoteActorId || "");
+
+ this.handleEvent("remote_follow_state_changed", ({ remote_actor_id, state }) => {
+ if (String(remote_actor_id) !== this.remoteActorId) return;
+
+ this.el.dataset.followState = state;
+ this.syncState();
+ });
+
+ this.syncState();
+ },
+
+ updated() {
+ this.syncState();
+ },
+
+ syncState() {
+ const state = this.el.dataset.followState || "none";
+ const variantName = this.el.dataset.followVariant || "timeline";
+ const variant =
+ REMOTE_FOLLOW_BUTTON_VARIANTS[variantName] ||
+ REMOTE_FOLLOW_BUTTON_VARIANTS.timeline;
+ const config = variant[state] || variant.none;
+
+ this.el.classList.remove(...REMOTE_FOLLOW_BUTTON_CLASSES);
+ this.el.classList.add(...config.add);
+ this.el.disabled = !!config.disabled;
+
+ this.el.querySelectorAll("[data-follow-display]").forEach((display) => {
+ display.classList.toggle("hidden", display.dataset.followDisplay !== state);
+ });
+ },
+};
diff --git a/apps/elektrine/assets/js/hooks/ui_hooks.js b/apps/elektrine/assets/js/hooks/ui_hooks.js
new file mode 100644
index 0000000..b447d89
--- /dev/null
+++ b/apps/elektrine/assets/js/hooks/ui_hooks.js
@@ -0,0 +1,535 @@
+import './portal_dropdowns'
+
+function postAnchorFromElement(card) {
+ const postId = card?.dataset?.postId
+ if (!postId) return null
+
+ return { postId, top: card.getBoundingClientRect().top }
+}
+
+function findVisiblePostAnchor(root = document) {
+ const viewportHeight = window.innerHeight || document.documentElement.clientHeight
+ const viewportWidth = window.innerWidth || document.documentElement.clientWidth
+
+ if (viewportHeight <= 0 || viewportWidth <= 0) return null
+
+ const rootRect = root.getBoundingClientRect?.()
+ const rootCenterX = rootRect ? rootRect.left + rootRect.width / 2 : viewportWidth / 2
+ const sampleX = Math.max(0, Math.min(viewportWidth - 1, rootCenterX))
+ const sampleYs = [
+ Math.min(96, viewportHeight - 1),
+ Math.floor(viewportHeight * 0.25),
+ Math.floor(viewportHeight * 0.5),
+ Math.floor(viewportHeight * 0.75)
+ ].filter((y) => y >= 0 && y < viewportHeight)
+
+ for (const sampleY of sampleYs) {
+ const target = document.elementFromPoint(sampleX, sampleY)
+ const card = target?.closest?.('[data-post-id]')
+ if (card && root.contains(card)) {
+ const anchor = postAnchorFromElement(card)
+ if (anchor) return anchor
+ }
+ }
+
+ const lowerBound = -Math.max(viewportHeight * 1.5, 1200)
+ const belowBreak = viewportHeight + Math.max(viewportHeight, 1200)
+ const postCards = root.querySelectorAll('[data-post-id]')
+
+ for (const card of postCards) {
+ const rect = card.getBoundingClientRect()
+ if (rect.bottom <= lowerBound) continue
+ if (rect.top >= belowBreak) break
+ if (rect.bottom <= 0 || rect.top >= viewportHeight) continue
+
+ const anchor = postAnchorFromElement(card)
+ if (anchor) return anchor
+ }
+
+ return null
+}
+
+// General UI-related LiveView hooks
+/**
+ * UI Hooks
+ * General-purpose UI hooks for common interactions like copying to clipboard,
+ * focus management, flash messages, scrolling, and visual effects.
+ */
+
+export const PreserveFocus = {
+ mounted() {
+ this.focusedId = null
+
+ this.handleEvent("restore_focus", () => {
+ // Try to restore focus to the previously focused element
+ if (this.focusedId) {
+ const element = document.getElementById(this.focusedId)
+ if (element) {
+ element.focus()
+ }
+ }
+ })
+
+ // Track focus changes
+ this.focusInHandler = (e) => {
+ if (e.target.id) {
+ this.focusedId = e.target.id
+ }
+ }
+
+ document.addEventListener('focusin', this.focusInHandler)
+ },
+
+ destroyed() {
+ if (this.focusInHandler) {
+ document.removeEventListener('focusin', this.focusInHandler)
+ }
+ }
+}
+
+export const PreserveSearchFocus = {
+ mounted() {
+ this.snapshot = null
+ },
+
+ beforeUpdate() {
+ const active = document.activeElement
+
+ if (!(active instanceof HTMLInputElement) || !this.el.contains(active)) {
+ this.snapshot = null
+ return
+ }
+
+ this.snapshot = {
+ id: active.id,
+ name: active.name,
+ value: active.value,
+ selectionStart: active.selectionStart,
+ selectionEnd: active.selectionEnd
+ }
+ },
+
+ updated() {
+ if (!this.snapshot) return
+
+ const { id, name, value, selectionStart, selectionEnd } = this.snapshot
+ const selector = id ? `#${CSS.escape(id)}` : `input[name="${CSS.escape(name)}"]`
+ const input = this.el.querySelector(selector)
+
+ if (!(input instanceof HTMLInputElement)) return
+
+
+ input.value = value
+ input.focus({ preventScroll: true })
+
+ if (selectionStart !== null && selectionEnd !== null) {
+ input.setSelectionRange(selectionStart, selectionEnd)
+ }
+ }
+}
+
+export const FlashAutoDismiss = {
+ mounted() {
+ this.schedule()
+ },
+
+ updated() {
+ this.schedule()
+ },
+
+ schedule() {
+ if (window.initAutoDismissFlashes) {
+ window.initAutoDismissFlashes(this.el)
+ }
+ }
+}
+
+export const TimelineReply = {
+ mounted() {
+ this.replyFocusPending = false
+ this.queuedAnchor = null
+ this.pendingInteractionAnchor = null
+ this.pendingInteractionScrollY = null
+ this.prePatchAnchor = null
+ this.prePatchScrollY = null
+ this.prePatchShouldPreserve = false
+
+ this.handleFeedClick = (event) => {
+ // "Show N new posts" should jump to the top of the feed (Twitter-style),
+ // not pin the previous viewport. PreserveQueuedPostsButtonScroll owns that.
+ const queuedBtn = event.target.closest('[data-load-queued-posts]')
+ if (queuedBtn) {
+ this.queuedAnchor = null
+ this.pendingInteractionAnchor = null
+ this.pendingInteractionScrollY = null
+ return
+ }
+
+ const interactiveTarget = event.target.closest('[phx-click]')
+ if (!this.shouldTrackFeedInteraction(interactiveTarget)) return
+
+ this.pendingInteractionAnchor = this.findVisiblePostAnchor()
+ this.pendingInteractionScrollY = window.scrollY
+ }
+
+ this.el.addEventListener('click', this.handleFeedClick)
+
+ // Focus and gently scroll active reply form into view without jumping around.
+ this.handleEvent("focus_reply_form", ({ textarea_id, container_id }) => {
+ this.replyFocusPending = true
+ setTimeout(() => {
+ const textarea = document.getElementById(textarea_id)
+ const container = container_id ? document.getElementById(container_id) : null
+
+ if (container) {
+ const rect = container.getBoundingClientRect()
+ const topGuard = 96
+ const bottomGuard = 24
+ const needsScroll =
+ rect.top < topGuard || rect.bottom > (window.innerHeight - bottomGuard)
+
+ if (needsScroll) {
+ container.scrollIntoView({ behavior: 'smooth', block: 'center' })
+ }
+ }
+
+ if (textarea) {
+ textarea.focus()
+ if (textarea.value) {
+ textarea.setSelectionRange(textarea.value.length, textarea.value.length)
+ }
+ }
+ }, 100)
+ })
+ },
+
+ beforeUpdate() {
+ this.prePatchShouldPreserve = this.shouldPreserveFeedPatch()
+
+ if (this.prePatchShouldPreserve) {
+ this.prePatchAnchor = this.pendingInteractionAnchor || this.findVisiblePostAnchor()
+ this.prePatchScrollY =
+ typeof this.pendingInteractionScrollY === 'number'
+ ? this.pendingInteractionScrollY
+ : window.scrollY
+ } else {
+ this.prePatchAnchor = null
+ this.prePatchScrollY = null
+ }
+ },
+
+ updated() {
+ if (this.queuedAnchor) {
+ this.restoreAnchorPosition(this.queuedAnchor, null)
+
+ this.queuedAnchor = null
+ this.pendingInteractionAnchor = null
+ this.pendingInteractionScrollY = null
+ this.prePatchAnchor = null
+ this.prePatchScrollY = null
+ this.prePatchShouldPreserve = false
+ this.replyFocusPending = false
+ return
+ }
+
+ if (this.prePatchShouldPreserve) {
+ this.restoreAnchorPosition(this.prePatchAnchor, this.prePatchScrollY)
+ }
+
+ this.pendingInteractionAnchor = null
+ this.pendingInteractionScrollY = null
+ this.prePatchAnchor = null
+ this.prePatchScrollY = null
+ this.prePatchShouldPreserve = false
+ this.replyFocusPending = false
+ },
+
+ findVisiblePostAnchor() {
+ return findVisiblePostAnchor(this.el)
+ },
+
+ restoreAnchorPosition(anchorSnapshot, fallbackScrollY) {
+ if (anchorSnapshot?.postId) {
+ const anchor = this.findPostById(anchorSnapshot.postId)
+
+ if (anchor) {
+ const newTop = anchor.getBoundingClientRect().top
+ const delta = newTop - anchorSnapshot.top
+ if (delta !== 0) window.scrollBy(0, delta)
+ return
+ }
+ }
+
+ if (typeof fallbackScrollY === 'number') {
+ window.scrollTo({ top: fallbackScrollY, behavior: 'auto' })
+ }
+ },
+
+ findPostById(postId) {
+ if (!postId) return null
+
+ const escapedPostId = typeof CSS !== 'undefined' && CSS.escape ? CSS.escape(postId) : postId
+ return this.el.querySelector(`[data-post-id="${escapedPostId}"]`)
+ },
+
+ shouldPreserveFeedPatch() {
+ if (this.timelineLoadMoreActive()) return false
+
+ if (this.pendingInteractionScrollY == null || this.pendingInteractionScrollY < 200) return false
+
+ return this.pendingInteractionAnchor !== null
+ },
+
+ shouldTrackFeedInteraction(target) {
+ if (!target || !target.closest('[data-post-id]')) return false
+
+ return [
+ 'like_post',
+ 'unlike_post',
+ 'boost_post',
+ 'unboost_post',
+ 'save_post',
+ 'unsave_post',
+ 'react_to_post',
+ 'vote',
+ 'vote_post',
+ 'vote_comment'
+ ].includes(target.getAttribute('phx-click'))
+ },
+
+ timelineLoadMoreActive() {
+ const infiniteScrollRoot = this.el.querySelector('#timeline-infinite-scroll')
+ return infiniteScrollRoot?.dataset?.loadingMore === 'true'
+ },
+
+ destroyed() {
+ this.el.removeEventListener('click', this.handleFeedClick)
+ }
+}
+
+// Scroll to top button that appears when scrolled down
+export const ScrollToTop = {
+ mounted() {
+ this.scrollThreshold = 400
+ this.scrollContainer = this.getScrollContainer()
+ this.scrollTarget = this.scrollContainer === window ? window : this.scrollContainer
+ this.ticking = false
+
+ this.handleScroll = () => {
+ if (this.ticking) return
+
+ this.ticking = true
+ window.requestAnimationFrame(() => {
+ this.ticking = false
+ this.syncVisibility()
+ })
+ }
+
+ this.handleClick = () => {
+ const target = this.scrollContainer === window
+ ? window
+ : (this.scrollContainer || document.scrollingElement || document.documentElement)
+
+ target.scrollTo({
+ top: 0,
+ behavior: 'smooth'
+ })
+ }
+
+ this.el.addEventListener('click', this.handleClick)
+ this.scrollTarget.addEventListener('scroll', this.handleScroll, { passive: true })
+ window.addEventListener('resize', this.handleScroll, { passive: true })
+ this.syncVisibility()
+ },
+
+ updated() {
+ const nextScrollContainer = this.getScrollContainer()
+
+ if (nextScrollContainer === this.scrollContainer) {
+ this.syncVisibility()
+ return
+ }
+
+ this.scrollTarget?.removeEventListener('scroll', this.handleScroll)
+ this.scrollContainer = nextScrollContainer
+ this.scrollTarget = this.scrollContainer === window ? window : this.scrollContainer
+ this.scrollTarget.addEventListener('scroll', this.handleScroll, { passive: true })
+ this.syncVisibility()
+ },
+
+ destroyed() {
+ this.scrollTarget?.removeEventListener('scroll', this.handleScroll)
+ window.removeEventListener('resize', this.handleScroll)
+ this.el.removeEventListener('click', this.handleClick)
+ },
+
+ getScrollContainer() {
+ const rootId = this.el.dataset.scrollRoot
+ const root = rootId ? document.getElementById(rootId) : this.el.parentElement
+ let current = root
+
+ while (current && current !== document.body) {
+ if (this.isScrollable(current)) return current
+ current = current.parentElement
+ }
+
+ return window
+ },
+
+ isScrollable(element) {
+ if (!element) return false
+
+ const styles = window.getComputedStyle(element)
+ const canScroll = ['auto', 'scroll', 'overlay'].includes(styles.overflowY)
+
+ return canScroll && element.scrollHeight > element.clientHeight + 1
+ },
+
+ getScrollTop() {
+ if (this.scrollContainer === window) {
+ return window.scrollY || document.documentElement.scrollTop || document.body.scrollTop || 0
+ }
+
+ return this.scrollContainer?.scrollTop || 0
+ },
+
+ syncVisibility() {
+ if (this.getScrollTop() > this.scrollThreshold) {
+ this.el.classList.remove('opacity-0', 'pointer-events-none')
+ this.el.classList.add('opacity-100', 'pointer-events-auto')
+ } else {
+ this.el.classList.remove('opacity-100', 'pointer-events-auto')
+ this.el.classList.add('opacity-0', 'pointer-events-none')
+ }
+ }
+}
+
+export const RemoteProfileStickyFollow = {
+ mounted() {
+ this.panel = this.el.querySelector('.remote-user-sticky-follow-panel') || this.el
+ this.target = this.resolveTarget()
+ this.ticking = false
+
+ this.syncVisibility = this.syncVisibility.bind(this)
+ this.scheduleSync = this.scheduleSync.bind(this)
+
+ window.addEventListener('scroll', this.scheduleSync, { passive: true })
+ window.addEventListener('resize', this.scheduleSync, { passive: true })
+
+ if (this.target && 'IntersectionObserver' in window) {
+ this.observer = new IntersectionObserver(this.scheduleSync, { threshold: 0 })
+ this.observer.observe(this.target)
+ }
+
+ this.syncVisibility()
+ },
+
+ updated() {
+ const nextTarget = this.resolveTarget()
+
+ if (nextTarget !== this.target) {
+ if (this.observer && this.target) this.observer.unobserve(this.target)
+ this.target = nextTarget
+ if (this.observer && this.target) this.observer.observe(this.target)
+ }
+
+ this.panel = this.el.querySelector('.remote-user-sticky-follow-panel') || this.el
+ this.syncVisibility()
+ },
+
+ destroyed() {
+ window.removeEventListener('scroll', this.scheduleSync)
+ window.removeEventListener('resize', this.scheduleSync)
+ if (this.observer) this.observer.disconnect()
+ },
+
+ resolveTarget() {
+ const targetId = this.el.dataset.followTarget
+ return targetId ? document.getElementById(targetId) : null
+ },
+
+ scheduleSync() {
+ if (this.ticking) return
+
+ this.ticking = true
+ window.requestAnimationFrame(() => {
+ this.ticking = false
+ this.syncVisibility()
+ })
+ },
+
+ syncVisibility() {
+ if (!this.target || !this.panel) return
+
+ const targetRect = this.target.getBoundingClientRect()
+ const showOffset = Number.parseInt(this.el.dataset.showOffset || '0', 10) || 0
+ const shouldShow = targetRect.bottom <= -showOffset
+
+ this.el.setAttribute('aria-hidden', shouldShow ? 'false' : 'true')
+
+ if (shouldShow) {
+ this.panel.classList.remove('hidden', 'opacity-0', 'pointer-events-none')
+ this.panel.classList.add('flex', 'opacity-100', 'pointer-events-auto')
+ } else {
+ this.panel.classList.remove('flex', 'opacity-100', 'pointer-events-auto')
+ this.panel.classList.add('hidden', 'opacity-0', 'pointer-events-none')
+ }
+ }
+}
+
+/**
+ * ImageFallback - Handles image load errors without inline event handlers
+ * Hides the image and shows a fallback element when image fails to load
+ *
+ * Usage:
+ *
+ *
+ *
+ * Fallback content
+ *
+ * The hook will hide the configured target and show the next sibling with [data-fallback-icon]
+ */
+export const ImageFallback = {
+ mounted() {
+ this.onError = () => {
+ const hideTarget = this.resolveHideTarget()
+ if (hideTarget) {
+ hideTarget.style.display = 'none'
+ }
+
+ const fallback = this.el.nextElementSibling
+ if (fallback && fallback.hasAttribute('data-fallback-icon')) {
+ fallback.style.display = 'flex'
+ fallback.classList.remove('hidden')
+ }
+ }
+
+ this.el.addEventListener('error', this.onError)
+
+ // Also handle case where image is already broken (cached error)
+ if (this.el.complete && this.el.naturalHeight === 0) {
+ this.el.dispatchEvent(new Event('error'))
+ }
+ },
+
+ destroyed() {
+ if (this.onError) {
+ this.el.removeEventListener('error', this.onError)
+ }
+ },
+
+ resolveHideTarget() {
+ const hideTarget = this.el.dataset.hideTarget || 'self'
+
+ if (hideTarget === 'parent') {
+ return this.el.parentElement
+ }
+
+ if (hideTarget === 'closest') {
+ const selector = this.el.dataset.hideSelector
+ return selector ? this.el.closest(selector) : this.el
+ }
+
+ return this.el
+ }
+}
diff --git a/apps/elektrine/assets/js/hooks/vault_crypto.js b/apps/elektrine/assets/js/hooks/vault_crypto.js
new file mode 100644
index 0000000..b91f5ec
--- /dev/null
+++ b/apps/elektrine/assets/js/hooks/vault_crypto.js
@@ -0,0 +1,178 @@
+/**
+ * Vault crypto - shared zero-knowledge primitives for the Elektrine master
+ * password. One passphrase unlocks a random Master Data Key (MDK); per-feature
+ * keys (Nerve, Kairo, email) are derived from the MDK via HKDF. The server only
+ * ever stores wrapped blobs and ciphertext - never the passphrase, recovery
+ * code, or MDK.
+ *
+ * Two payload shapes:
+ * - "wrapped" (server: account_master_keys): a secret-wrapped value carrying
+ * its own KDF params - {version, algorithm, kdf, iterations, salt, iv, ciphertext}.
+ * - "value" (feature data, keyed by an HKDF subkey): {version, algorithm, iv, ciphertext}.
+ */
+
+export const VERSION = 2
+export const ALGORITHM = "AES-GCM"
+export const KDF = "PBKDF2-SHA256"
+export const PBKDF2_ITERATIONS = 600000
+export const MIN_PASSPHRASE_LENGTH = 14
+
+const HKDF_PREFIX = "elektrine-vault:"
+
+const encoder = new TextEncoder()
+const decoder = new TextDecoder()
+
+export function randomBytes(length) {
+ const bytes = new Uint8Array(length)
+ crypto.getRandomValues(bytes)
+ return bytes
+}
+
+export function bytesToBase64(bytes) {
+ let binary = ""
+ bytes.forEach((byte) => {
+ binary += String.fromCharCode(byte)
+ })
+ return btoa(binary)
+}
+
+export function base64ToBytes(value) {
+ const binary = atob(value)
+ const bytes = new Uint8Array(binary.length)
+ for (let i = 0; i < binary.length; i += 1) {
+ bytes[i] = binary.charCodeAt(i)
+ }
+ return bytes
+}
+
+function stableJson(value) {
+ if (!value || typeof value !== "object" || Array.isArray(value)) return JSON.stringify(value)
+ return JSON.stringify(
+ Object.keys(value)
+ .sort()
+ .reduce((acc, key) => {
+ acc[key] = value[key]
+ return acc
+ }, {})
+ )
+}
+
+function aesGcmParams(iv, associatedData) {
+ if (!associatedData) return { name: ALGORITHM, iv }
+ return { name: ALGORITHM, iv, additionalData: encoder.encode(stableJson(associatedData)) }
+}
+
+// --- Secret (passphrase / recovery code) wrapping ------------------------------
+
+async function deriveWrappingKey(secret, salt, iterations) {
+ const baseKey = await crypto.subtle.importKey(
+ "raw",
+ encoder.encode(secret),
+ { name: "PBKDF2" },
+ false,
+ ["deriveKey"]
+ )
+
+ return crypto.subtle.deriveKey(
+ { name: "PBKDF2", salt, iterations, hash: "SHA-256" },
+ baseKey,
+ { name: ALGORITHM, length: 256 },
+ false,
+ ["encrypt", "decrypt"]
+ )
+}
+
+/** Wrap raw bytes (e.g. the MDK) under a key derived from a secret string. */
+export async function wrapWithSecret(plaintextBytes, secret) {
+ const salt = randomBytes(16)
+ const iv = randomBytes(12)
+ const key = await deriveWrappingKey(secret, salt, PBKDF2_ITERATIONS)
+ const ciphertext = await crypto.subtle.encrypt({ name: ALGORITHM, iv }, key, plaintextBytes)
+
+ return {
+ version: VERSION,
+ algorithm: ALGORITHM,
+ kdf: KDF,
+ iterations: PBKDF2_ITERATIONS,
+ salt: bytesToBase64(salt),
+ iv: bytesToBase64(iv),
+ ciphertext: bytesToBase64(new Uint8Array(ciphertext))
+ }
+}
+
+/** Unwrap a secret-wrapped payload back to raw bytes. Throws on wrong secret. */
+export async function unwrapWithSecret(payload, secret) {
+ const salt = base64ToBytes(payload.salt)
+ const iv = base64ToBytes(payload.iv)
+ const ciphertext = base64ToBytes(payload.ciphertext)
+ const key = await deriveWrappingKey(secret, salt, Number(payload.iterations))
+ const plaintext = await crypto.subtle.decrypt({ name: ALGORITHM, iv }, key, ciphertext)
+ return new Uint8Array(plaintext)
+}
+
+// --- Master Data Key + per-feature subkeys ------------------------------------
+
+export function generateMdk() {
+ return randomBytes(32)
+}
+
+async function importHkdfBase(mdkBytes) {
+ return crypto.subtle.importKey("raw", mdkBytes, { name: "HKDF" }, false, ["deriveKey"])
+}
+
+/** Derive a feature's AES-256-GCM key from the MDK. */
+export async function deriveFeatureKey(mdkBytes, feature) {
+ const base = await importHkdfBase(mdkBytes)
+ return crypto.subtle.deriveKey(
+ { name: "HKDF", hash: "SHA-256", salt: new Uint8Array(0), info: encoder.encode(HKDF_PREFIX + feature) },
+ base,
+ { name: ALGORITHM, length: 256 },
+ false,
+ ["encrypt", "decrypt"]
+ )
+}
+
+// --- Feature value encryption (keyed by a subkey, with optional AAD) ----------
+
+export async function encryptValue(plaintext, key, associatedData = null) {
+ const iv = randomBytes(12)
+ const ciphertext = await crypto.subtle.encrypt(
+ aesGcmParams(iv, associatedData),
+ key,
+ encoder.encode(plaintext)
+ )
+
+ return {
+ version: VERSION,
+ algorithm: ALGORITHM,
+ iv: bytesToBase64(iv),
+ ciphertext: bytesToBase64(new Uint8Array(ciphertext))
+ }
+}
+
+export async function decryptValue(payload, key, associatedData = null) {
+ const iv = base64ToBytes(payload.iv)
+ const ciphertext = base64ToBytes(payload.ciphertext)
+ const plaintext = await crypto.subtle.decrypt(aesGcmParams(iv, associatedData), key, ciphertext)
+ return decoder.decode(plaintext)
+}
+
+// --- Recovery code ------------------------------------------------------------
+
+const RECOVERY_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
+
+/** 256-bit one-time recovery code, grouped for readability (e.g. ABCD-EFGH-...). */
+export function generateRecoveryCode() {
+ const bytes = randomBytes(32)
+ let out = ""
+ bytes.forEach((byte) => {
+ out += RECOVERY_ALPHABET[byte & 31]
+ out += RECOVERY_ALPHABET[(byte >> 3) & 31]
+ })
+ return out.match(/.{1,4}/g).join("-")
+}
+
+/** Normalize a recovery code the user typed back to its canonical form. */
+export function normalizeRecoveryCode(value) {
+ return (value || "").toUpperCase().replace(/[^A-Z0-9]/g, "")
+}
diff --git a/apps/elektrine/assets/js/hooks/vault_hooks.js b/apps/elektrine/assets/js/hooks/vault_hooks.js
new file mode 100644
index 0000000..3b0a7b2
--- /dev/null
+++ b/apps/elektrine/assets/js/hooks/vault_hooks.js
@@ -0,0 +1,304 @@
+/**
+ * VaultManager - browser side of the account-password vault page.
+ * Generates/wraps the Master Data Key on setup, unwraps it on unlock, and
+ * shares the unlocked key via vault_session so every feature unlocks at once.
+ */
+
+import {
+ generateMdk,
+ wrapWithSecret,
+ unwrapWithSecret,
+ generateRecoveryCode,
+ normalizeRecoveryCode,
+ MIN_PASSPHRASE_LENGTH
+} from "./vault_crypto"
+import * as vaultSession from "./vault_session"
+
+export const VaultManager = {
+ mounted() {
+ this.unsubscribe = vaultSession.subscribe(() => this.renderState())
+ this.bind()
+ this.renderState()
+ },
+
+ updated() {
+ this.renderState()
+ this.renderResetState()
+ },
+
+ destroyed() {
+ this.unsubscribe && this.unsubscribe()
+ },
+
+ bind() {
+ if (this.bound) return
+ this.bound = true
+
+ this.el.addEventListener("click", (event) => this.handleClick(event))
+ this.el.addEventListener("submit", (event) => this.handleSubmit(event))
+ this.el.addEventListener("input", (event) => this.handleInput(event))
+ this.renderResetState()
+ },
+
+ handleClick(event) {
+ if (event.target.closest("[data-vault-setup]")) {
+ event.preventDefault()
+ this.setup()
+ return
+ }
+
+ if (event.target.closest("[data-vault-setup-finish]")) {
+ event.preventDefault()
+ this.finishSetup()
+ return
+ }
+
+ if (event.target.closest("[data-vault-unlock]")) {
+ event.preventDefault()
+ this.unlock()
+ return
+ }
+
+ if (event.target.closest("[data-vault-lock]")) {
+ event.preventDefault()
+ vaultSession.lock()
+ return
+ }
+
+ if (event.target.closest("[data-vault-reset-button]")) {
+ vaultSession.lock()
+ return
+ }
+
+ if (event.target.closest("[data-vault-recovery]")) {
+ event.preventDefault()
+ this.recover()
+ return
+ }
+
+ if (event.target.closest("[data-vault-recovery-finish]")) {
+ event.preventDefault()
+ this.finishRecovery()
+ }
+ },
+
+ handleSubmit(event) {
+ const setupForm = event.target.closest("[data-vault-setup-form]")
+ if (setupForm) {
+ this.handleSetupSubmit(event, setupForm)
+ return
+ }
+
+ const recoveryForm = event.target.closest("[data-vault-recovery-form]")
+ if (recoveryForm) {
+ this.handleRecoverySubmit(event, recoveryForm)
+ }
+ },
+
+ handleInput(event) {
+ if (event.target.closest("[data-vault-reset-confirm]")) {
+ this.renderResetState()
+ }
+ },
+
+ setError(message) {
+ const el = this.el.querySelector("[data-vault-error]")
+ if (el) el.textContent = message || ""
+ },
+
+ setRecoveryError(message) {
+ const el = this.el.querySelector("[data-vault-recovery-error]")
+ if (el) el.textContent = message || ""
+ },
+
+ renderState() {
+ const unlocked = vaultSession.isUnlocked()
+
+ const status = this.el.querySelector("[data-vault-status]")
+ if (status) {
+ status.textContent = unlocked
+ ? status.dataset.unlockedLabel || "Unlocked"
+ : status.dataset.lockedLabel || "Locked"
+ status.classList.toggle("badge-success", unlocked)
+ }
+
+ const locked = this.el.querySelector("[data-vault-locked-section]")
+ const unlockedSection = this.el.querySelector("[data-vault-unlocked-section]")
+ if (locked) locked.classList.toggle("hidden", unlocked)
+ if (unlockedSection) unlockedSection.classList.toggle("hidden", !unlocked)
+ },
+
+ renderResetState() {
+ const input = this.el.querySelector("[data-vault-reset-confirm]")
+ const button = this.el.querySelector("[data-vault-reset-button]")
+ if (!input || !button) return
+
+ const expected = "RESET ENCRYPTED DATA"
+ button.disabled = input.value.trim() !== expected
+ },
+
+ async setup() {
+ this.setError("")
+ const passphrase = this.value("[data-vault-setup-input]")
+ const confirmInput = this.el.querySelector("[data-vault-setup-confirm]")
+ const confirm = confirmInput ? confirmInput.value : passphrase
+
+ if (!passphrase) {
+ return this.setError("Enter your current password.")
+ }
+ if (!this.accountPasswordMode() && passphrase.length < MIN_PASSPHRASE_LENGTH) {
+ return this.setError(`Use at least ${MIN_PASSPHRASE_LENGTH} characters.`)
+ }
+ if (passphrase !== confirm) {
+ return this.setError("Passwords do not match.")
+ }
+
+ try {
+ const mdk = generateMdk()
+ const recoveryCode = generateRecoveryCode()
+
+ const wrappedDek = await wrapWithSecret(mdk, passphrase)
+ const wrappedRecovery = await wrapWithSecret(mdk, normalizeRecoveryCode(recoveryCode))
+
+ this.setValue("[data-vault-wrapped-dek-input]", JSON.stringify(wrappedDek))
+ this.setValue("[data-vault-wrapped-dek-recovery-input]", JSON.stringify(wrappedRecovery))
+
+ // Unlock the session now so the user is unlocked the moment setup saves.
+ vaultSession.unlock(mdk)
+
+ const output = this.el.querySelector("[data-vault-recovery-output]")
+ if (output) output.textContent = recoveryCode
+ this.el.querySelector("[data-vault-recovery-panel]")?.classList.remove("hidden")
+ this.el.querySelector("[data-vault-setup]")?.classList.add("hidden")
+ } catch (_error) {
+ this.setError("Could not set up encryption in this browser.")
+ }
+ },
+
+ handleSetupSubmit(event, form = event.currentTarget) {
+ if (form?.dataset.vaultSetupSubmitting === "true") return
+
+ event.preventDefault()
+ this.setup()
+ },
+
+ async recover() {
+ this.setRecoveryError("")
+
+ const recoveryCode = normalizeRecoveryCode(this.value("[data-vault-recovery-code]"))
+ const passphrase = this.value("[data-vault-recovery-new-input]")
+ const confirmInput = this.el.querySelector("[data-vault-recovery-new-confirm]")
+ const confirm = confirmInput ? confirmInput.value : passphrase
+ const wrapped = this.wrappedRecoveryDek()
+
+ if (!wrapped) {
+ return this.setRecoveryError("No recovery key found.")
+ }
+ if (!recoveryCode) {
+ return this.setRecoveryError("Enter your recovery code.")
+ }
+ if (!passphrase) {
+ return this.setRecoveryError("Enter your current password.")
+ }
+ if (!this.accountPasswordMode() && passphrase.length < MIN_PASSPHRASE_LENGTH) {
+ return this.setRecoveryError(`Use at least ${MIN_PASSPHRASE_LENGTH} characters.`)
+ }
+ if (passphrase !== confirm) {
+ return this.setRecoveryError("Passwords do not match.")
+ }
+
+ try {
+ const mdk = await unwrapWithSecret(wrapped, recoveryCode)
+ const newRecoveryCode = generateRecoveryCode()
+ const wrappedDek = await wrapWithSecret(mdk, passphrase)
+ const wrappedRecovery = await wrapWithSecret(mdk, normalizeRecoveryCode(newRecoveryCode))
+
+ this.setValue("[data-vault-recovery-wrapped-dek-input]", JSON.stringify(wrappedDek))
+ this.setValue(
+ "[data-vault-recovery-wrapped-dek-recovery-input]",
+ JSON.stringify(wrappedRecovery)
+ )
+
+ vaultSession.unlock(mdk)
+
+ const output = this.el.querySelector("[data-vault-recovery-new-output]")
+ if (output) output.textContent = newRecoveryCode
+ this.el.querySelector("[data-vault-recovery-new-panel]")?.classList.remove("hidden")
+ this.el.querySelector("[data-vault-recovery]")?.classList.add("hidden")
+ } catch (_error) {
+ this.setRecoveryError("Recovery code is incorrect or cannot unlock this account.")
+ }
+ },
+
+ handleRecoverySubmit(event, form = event.currentTarget) {
+ if (form?.dataset.vaultRecoverySubmitting === "true") return
+
+ event.preventDefault()
+ this.recover()
+ },
+
+ finishRecovery() {
+ const form = this.el.querySelector("[data-vault-recovery-form]")
+ if (form) {
+ form.dataset.vaultRecoverySubmitting = "true"
+ form.requestSubmit()
+ }
+ },
+
+ finishSetup() {
+ const form = this.el.querySelector("[data-vault-setup-form]")
+ if (form) {
+ form.dataset.vaultSetupSubmitting = "true"
+ form.requestSubmit()
+ }
+ },
+
+ async unlock() {
+ this.setError("")
+ const passphrase = this.value("[data-vault-unlock-input]")
+ const wrapped = this.wrappedDek()
+ if (!wrapped) return this.setError("No encrypted data key found.")
+
+ try {
+ const mdk = await unwrapWithSecret(wrapped, passphrase)
+ vaultSession.unlock(mdk)
+ this.setValue("[data-vault-unlock-input]", "")
+ } catch (_error) {
+ this.setError("Incorrect password.")
+ }
+ },
+
+ accountPasswordMode() {
+ return this.el.dataset.vaultSecretMode === "account_password"
+ },
+
+ wrappedDek() {
+ const raw = this.el.dataset.vaultWrappedDek
+ if (!raw) return null
+ try {
+ return JSON.parse(raw)
+ } catch (_error) {
+ return null
+ }
+ },
+
+ wrappedRecoveryDek() {
+ const raw = this.el.dataset.vaultWrappedDekRecovery
+ if (!raw) return null
+ try {
+ return JSON.parse(raw)
+ } catch (_error) {
+ return null
+ }
+ },
+
+ value(selector) {
+ const el = this.el.querySelector(selector)
+ return el ? el.value : ""
+ },
+
+ setValue(selector, value) {
+ const el = this.el.querySelector(selector)
+ if (el) el.value = value
+ }
+}
diff --git a/apps/elektrine/assets/js/hooks/vault_session.js b/apps/elektrine/assets/js/hooks/vault_session.js
new file mode 100644
index 0000000..409e9f7
--- /dev/null
+++ b/apps/elektrine/assets/js/hooks/vault_session.js
@@ -0,0 +1,100 @@
+/**
+ * Vault session - the unlocked encrypted data key for this browser tab.
+ *
+ * Holds the Master Data Key (MDK) for this browser tab/session, so unlocking
+ * once unlocks Nerve, Kairo, and email private storage together even when
+ * navigation crosses LiveView/app boundaries. The MDK is stored in
+ * sessionStorage, not localStorage: it clears on tab close and explicit lock.
+ * Per-feature keys are derived lazily and cached in memory.
+ */
+
+import { base64ToBytes, bytesToBase64, deriveFeatureKey } from "./vault_crypto"
+
+const SESSION_KEY = "elektrine:vault-session:mdk"
+
+let mdk = readStoredMdk()
+const featureKeys = new Map()
+const listeners = new Set()
+
+function storage() {
+ try {
+ return typeof window !== "undefined" ? window.sessionStorage : null
+ } catch (_error) {
+ return null
+ }
+}
+
+function readStoredMdk() {
+ try {
+ const value = storage()?.getItem(SESSION_KEY)
+ if (!value) return null
+
+ const bytes = base64ToBytes(value)
+ return bytes.length === 32 ? bytes : null
+ } catch (_error) {
+ clearStoredMdk()
+ return null
+ }
+}
+
+function storeMdk(mdkBytes) {
+ try {
+ storage()?.setItem(SESSION_KEY, bytesToBase64(mdkBytes))
+ } catch (_error) {
+ // Session persistence is best-effort; memory unlock still works.
+ }
+}
+
+function clearStoredMdk() {
+ try {
+ storage()?.removeItem(SESSION_KEY)
+ } catch (_error) {
+ // Ignore storage failures.
+ }
+}
+
+function notify() {
+ const unlocked = isUnlocked()
+ listeners.forEach((fn) => {
+ try {
+ fn(unlocked)
+ } catch (_error) {
+ // a listener throwing must not break the others
+ }
+ })
+}
+
+export function isUnlocked() {
+ return mdk !== null
+}
+
+/** Store the unlocked MDK (a Uint8Array) for the tab and notify subscribers. */
+export function unlock(mdkBytes) {
+ mdk = mdkBytes
+ storeMdk(mdkBytes)
+ featureKeys.clear()
+ notify()
+}
+
+/** Forget the MDK and all derived keys. */
+export function lock() {
+ mdk = null
+ clearStoredMdk()
+ featureKeys.clear()
+ notify()
+}
+
+/** AES-GCM key for a feature ("nerve" | "kairo" | "email"). Throws if locked. */
+export async function featureKey(feature) {
+ if (!mdk) throw new Error("vault-locked")
+ if (!featureKeys.has(feature)) {
+ featureKeys.set(feature, await deriveFeatureKey(mdk, feature))
+ }
+ return featureKeys.get(feature)
+}
+
+/** Subscribe to lock/unlock changes; returns an unsubscribe function. */
+export function subscribe(fn) {
+ listeners.add(fn)
+ return () => listeners.delete(fn)
+}
diff --git a/apps/elektrine/assets/js/hooks/voice_channel_hook.js b/apps/elektrine/assets/js/hooks/voice_channel_hook.js
new file mode 100644
index 0000000..dd3a072
--- /dev/null
+++ b/apps/elektrine/assets/js/hooks/voice_channel_hook.js
@@ -0,0 +1,403 @@
+/**
+ * LiveView hook for community voice channels (full-mesh WebRTC audio).
+ *
+ * The LiveView pushes "voice_join" / "voice_leave" / "voice_toggle_mute"
+ * events; this hook owns the media session: one Phoenix channel on
+ * `voice:`, one RTCPeerConnection per remote occupant, and
+ * one hidden element per remote stream (appended to the hook root).
+ *
+ * Initiator rule: for each pair of peers, the one with the greater
+ * (joined_at, user_id) presence tuple initiates the offer. Newcomers
+ * therefore offer to everyone already present, and concurrent joins are
+ * broken deterministically without glare.
+ */
+
+import { Socket } from "phoenix"
+
+function mapMediaError(error) {
+ if (!error) return "Unable to access microphone."
+
+ const message = error.message || "Unable to access microphone."
+
+ if (error.name === "NotAllowedError" || message.includes("Permission denied")) {
+ return "Microphone access denied. Please allow access in your browser settings and try again."
+ }
+
+ if (error.name === "NotFoundError") {
+ return "No microphone found. Please connect one and try again."
+ }
+
+ if (message.includes("secure origin")) {
+ return "Voice channels require HTTPS. Please use a secure connection."
+ }
+
+ return message
+}
+
+export const VoiceChannel = {
+ mounted() {
+ this.session = null
+
+ this.handleEvent("voice_join", (payload) => {
+ this.join(payload)
+ })
+
+ this.handleEvent("voice_leave", () => {
+ this.teardown()
+ this.safePushEvent("voice_left", {})
+ })
+
+ this.handleEvent("voice_toggle_mute", () => {
+ this.toggleMute()
+ })
+ },
+
+ destroyed() {
+ this.teardown()
+ },
+
+ // The LiveView loses its assigns on reconnect while the media session (on
+ // its own socket) keeps running; re-sync the connected bar state.
+ reconnected() {
+ const session = this.session
+
+ if (session) {
+ this.safePushEvent("voice_rejoined", {
+ conversation_id: session.conversationId,
+ muted: session.muted,
+ })
+ }
+ },
+
+ safePushEvent(event, payload) {
+ try {
+ this.pushEvent(event, payload)
+ } catch (_error) {
+ // Hook may already be detached (e.g. navigation away).
+ }
+ },
+
+ async join({ conversation_id, user_id, user_token, ice_servers }) {
+ this.teardown()
+
+ let localStream
+ try {
+ localStream = await navigator.mediaDevices.getUserMedia({ audio: true })
+ } catch (error) {
+ this.safePushEvent("voice_error", { reason: mapMediaError(error) })
+ return
+ }
+
+ const socket = new Socket("/socket", { params: { token: user_token } })
+ socket.connect()
+
+ const channel = socket.channel(`voice:${conversation_id}`, {})
+
+ this.session = {
+ conversationId: conversation_id,
+ userId: user_id,
+ iceServers: ice_servers || [],
+ socket,
+ channel,
+ localStream,
+ peers: new Map(),
+ myJoinedAt: Infinity,
+ muted: false,
+ }
+
+ channel.on("presence_state", (state) => this.handlePresenceState(state))
+ channel.on("presence_diff", (diff) => this.handlePresenceDiff(diff))
+ channel.on("signal", (payload) => this.handleSignal(payload))
+
+ channel
+ .join()
+ .receive("ok", () => {
+ this.safePushEvent("voice_joined", { conversation_id })
+ })
+ .receive("error", (resp) => {
+ this.teardown()
+ this.safePushEvent("voice_error", { reason: resp?.reason || "join_failed" })
+ })
+ .receive("timeout", () => {
+ this.teardown()
+ this.safePushEvent("voice_error", { reason: "Connection timed out" })
+ })
+ },
+
+ presenceEntries(state) {
+ return Object.entries(state || {}).map(([key, value]) => {
+ const meta = (value.metas && value.metas[0]) || {}
+ return {
+ userId: meta.user_id || parseInt(key, 10),
+ joinedAt: meta.joined_at ?? 0,
+ }
+ })
+ },
+
+ // True when the remote peer's presence tuple sorts before ours, meaning we
+ // are the newer side of the pair and must initiate the offer.
+ shouldInitiateTo(peer) {
+ const session = this.session
+ if (!session) return false
+
+ if (peer.joinedAt !== session.myJoinedAt) {
+ return peer.joinedAt < session.myJoinedAt
+ }
+
+ return peer.userId < session.userId
+ },
+
+ handlePresenceState(state) {
+ const session = this.session
+ if (!session) return
+
+ const entries = this.presenceEntries(state)
+ const me = entries.find((entry) => entry.userId === session.userId)
+
+ if (me) {
+ session.myJoinedAt = me.joinedAt
+ }
+
+ entries
+ .filter((entry) => entry.userId !== session.userId)
+ .filter((entry) => this.shouldInitiateTo(entry))
+ .forEach((entry) => {
+ this.createPeer(entry.userId, true)
+ })
+ },
+
+ handlePresenceDiff(diff) {
+ const session = this.session
+ if (!session) return
+
+ const joins = diff?.joins || {}
+ const leaves = diff?.leaves || {}
+
+ // Presence meta updates (e.g. mute) emit the same key in joins+leaves.
+ Object.keys(leaves).forEach((key) => {
+ if (key in joins) return
+
+ const meta = (leaves[key].metas && leaves[key].metas[0]) || {}
+ const userId = meta.user_id || parseInt(key, 10)
+
+ if (userId !== session.userId) {
+ this.closePeer(userId)
+ }
+ })
+
+ // Concurrent joiners may not have been in our presence_state snapshot;
+ // if their tuple sorts before ours we still own the offer.
+ this.presenceEntries(joins)
+ .filter((entry) => entry.userId !== session.userId)
+ .filter((entry) => !session.peers.has(entry.userId))
+ .filter((entry) => this.shouldInitiateTo(entry))
+ .forEach((entry) => {
+ this.createPeer(entry.userId, true)
+ })
+ },
+
+ getOrCreatePeer(peerId) {
+ const session = this.session
+ if (!session) return null
+
+ if (session.peers.has(peerId)) {
+ return session.peers.get(peerId)
+ }
+
+ const pc = new RTCPeerConnection({ iceServers: session.iceServers })
+ const peer = { pc, audioEl: null, pendingCandidates: [] }
+
+ session.localStream.getTracks().forEach((track) => {
+ pc.addTrack(track, session.localStream)
+ })
+
+ pc.onicecandidate = (event) => {
+ if (event.candidate) {
+ this.pushSignal(peerId, "ice", event.candidate.toJSON())
+ }
+ }
+
+ pc.ontrack = (event) => {
+ const stream = event.streams[0] || new MediaStream([event.track])
+ this.attachAudio(peer, peerId, stream)
+ }
+
+ pc.onconnectionstatechange = () => {
+ if (pc.connectionState === "failed" || pc.connectionState === "closed") {
+ this.closePeer(peerId)
+ }
+ }
+
+ session.peers.set(peerId, peer)
+ return peer
+ },
+
+ async createPeer(peerId, initiator) {
+ const peer = this.getOrCreatePeer(peerId)
+ if (!peer || !initiator) return
+
+ try {
+ const offer = await peer.pc.createOffer()
+ await peer.pc.setLocalDescription(offer)
+ this.pushSignal(peerId, "offer", peer.pc.localDescription.toJSON())
+ } catch (_error) {
+ this.closePeer(peerId)
+ }
+ },
+
+ pushSignal(peerId, kind, payload) {
+ const channel = this.session?.channel
+
+ if (channel && channel.state === "joined") {
+ channel.push("signal", { to: peerId, kind, payload })
+ }
+ },
+
+ async handleSignal({ from, kind, payload }) {
+ const session = this.session
+ if (!session || from === session.userId) return
+
+ try {
+ if (kind === "offer") {
+ await this.handleOffer(from, payload)
+ } else if (kind === "answer") {
+ await this.handleAnswer(from, payload)
+ } else if (kind === "ice") {
+ await this.handleRemoteCandidate(from, payload)
+ }
+ } catch (_error) {
+ // A broken negotiation with one peer should not take the session down.
+ }
+ },
+
+ async handleOffer(peerId, sdp) {
+ const peer = this.getOrCreatePeer(peerId)
+ if (!peer) return
+
+ await peer.pc.setRemoteDescription(new RTCSessionDescription(sdp))
+ await this.flushCandidates(peer)
+
+ const answer = await peer.pc.createAnswer()
+ await peer.pc.setLocalDescription(answer)
+ this.pushSignal(peerId, "answer", peer.pc.localDescription.toJSON())
+ },
+
+ async handleAnswer(peerId, sdp) {
+ const peer = this.session?.peers.get(peerId)
+ if (!peer) return
+
+ await peer.pc.setRemoteDescription(new RTCSessionDescription(sdp))
+ await this.flushCandidates(peer)
+ },
+
+ async handleRemoteCandidate(peerId, candidate) {
+ const peer = this.getOrCreatePeer(peerId)
+ if (!peer) return
+
+ if (peer.pc.remoteDescription) {
+ await peer.pc.addIceCandidate(new RTCIceCandidate(candidate))
+ } else {
+ peer.pendingCandidates.push(candidate)
+ }
+ },
+
+ async flushCandidates(peer) {
+ const pending = peer.pendingCandidates.splice(0)
+
+ for (const candidate of pending) {
+ try {
+ await peer.pc.addIceCandidate(new RTCIceCandidate(candidate))
+ } catch (_error) {
+ // Ignore malformed candidates from a remote peer.
+ }
+ }
+ },
+
+ attachAudio(peer, peerId, stream) {
+ if (!peer.audioEl) {
+ const audio = document.createElement("audio")
+ audio.id = `voice-audio-${peerId}`
+ audio.autoplay = true
+ this.el.appendChild(audio)
+ peer.audioEl = audio
+ }
+
+ peer.audioEl.srcObject = stream
+ peer.audioEl.play().catch(() => {})
+ },
+
+ closePeer(peerId) {
+ const peer = this.session?.peers.get(peerId)
+ if (!peer) return
+
+ this.session.peers.delete(peerId)
+
+ try {
+ peer.pc.onicecandidate = null
+ peer.pc.ontrack = null
+ peer.pc.onconnectionstatechange = null
+ peer.pc.close()
+ } catch (_error) {
+ // Already closed.
+ }
+
+ if (peer.audioEl) {
+ peer.audioEl.srcObject = null
+ peer.audioEl.remove()
+ }
+ },
+
+ toggleMute() {
+ const session = this.session
+ if (!session) return
+
+ session.muted = !session.muted
+
+ session.localStream.getAudioTracks().forEach((track) => {
+ track.enabled = !session.muted
+ })
+
+ if (session.channel && session.channel.state === "joined") {
+ session.channel.push("set_muted", { muted: session.muted })
+ }
+
+ this.safePushEvent("voice_mute_changed", { muted: session.muted })
+ },
+
+ teardown() {
+ const session = this.session
+ if (!session) return
+
+ this.session = null
+
+ Array.from(session.peers.keys()).forEach((peerId) => {
+ const peer = session.peers.get(peerId)
+ session.peers.delete(peerId)
+
+ try {
+ peer.pc.close()
+ } catch (_error) {
+ // Already closed.
+ }
+
+ if (peer.audioEl) {
+ peer.audioEl.srcObject = null
+ peer.audioEl.remove()
+ }
+ })
+
+ session.localStream?.getTracks().forEach((track) => track.stop())
+
+ try {
+ session.channel?.leave()
+ } catch (_error) {
+ // Channel may never have joined.
+ }
+
+ try {
+ session.socket?.disconnect()
+ } catch (_error) {
+ // Socket may never have connected.
+ }
+ },
+}
diff --git a/apps/elektrine/assets/js/hooks/web_push_hook.js b/apps/elektrine/assets/js/hooks/web_push_hook.js
new file mode 100644
index 0000000..91a059c
--- /dev/null
+++ b/apps/elektrine/assets/js/hooks/web_push_hook.js
@@ -0,0 +1,98 @@
+// Browser Web Push subscription hook.
+// Registers the service worker, reports per-browser subscription state, and
+// handles subscribe/unsubscribe requests pushed from the LiveView.
+
+function urlBase64ToUint8Array(base64String) {
+ const padding = "=".repeat((4 - (base64String.length % 4)) % 4)
+ const base64 = (base64String + padding).replace(/-/g, "+").replace(/_/g, "/")
+ const rawData = window.atob(base64)
+
+ return Uint8Array.from([...rawData].map((char) => char.charCodeAt(0)))
+}
+
+export const WebPushManager = {
+ mounted() {
+ this.vapidKey = this.el.dataset.vapidPublicKey || ""
+
+ this.handleEvent("web_push_subscribe", () => this.subscribe())
+ this.handleEvent("web_push_unsubscribe", () => this.unsubscribe())
+
+ this.reportState()
+ },
+
+ supported() {
+ return (
+ "serviceWorker" in navigator &&
+ "PushManager" in window &&
+ "Notification" in window
+ )
+ },
+
+ registration() {
+ return navigator.serviceWorker.register("/sw.js")
+ },
+
+ async reportState() {
+ if (!this.supported() || !this.vapidKey) {
+ this.pushEvent("web_push_state", { supported: false })
+ return
+ }
+
+ try {
+ const registration = await this.registration()
+ const subscription = await registration.pushManager.getSubscription()
+
+ this.pushEvent("web_push_state", {
+ supported: true,
+ subscribed: !!subscription,
+ permission: Notification.permission,
+ })
+ } catch (error) {
+ this.pushEvent("web_push_error", {
+ reason: error?.message || "registration_failed",
+ })
+ }
+ },
+
+ async subscribe() {
+ try {
+ const permission = await Notification.requestPermission()
+
+ if (permission !== "granted") {
+ this.pushEvent("web_push_error", { reason: "permission_denied" })
+ return
+ }
+
+ const registration = await this.registration()
+
+ const subscription = await registration.pushManager.subscribe({
+ userVisibleOnly: true,
+ applicationServerKey: urlBase64ToUint8Array(this.vapidKey),
+ })
+
+ this.pushEvent("web_push_subscribed", { subscription: subscription.toJSON() })
+ } catch (error) {
+ this.pushEvent("web_push_error", {
+ reason: error?.message || "subscribe_failed",
+ })
+ }
+ },
+
+ async unsubscribe() {
+ try {
+ const registration = await this.registration()
+ const subscription = await registration.pushManager.getSubscription()
+ const endpoint = subscription ? subscription.endpoint : null
+
+ if (subscription) {
+ await subscription.unsubscribe()
+ }
+
+ this.pushEvent("web_push_unsubscribed", { endpoint })
+ } catch (error) {
+ this.pushEvent("web_push_error", {
+ reason: error?.message || "unsubscribe_failed",
+ })
+ }
+ },
+}
diff --git a/apps/elektrine/assets/js/ip_lookup.js b/apps/elektrine/assets/js/ip_lookup.js
new file mode 100644
index 0000000..d6bdaa0
--- /dev/null
+++ b/apps/elektrine/assets/js/ip_lookup.js
@@ -0,0 +1,120 @@
+import { spinnerSvg } from './utils/spinner';
+
+export function initIpLookup() {
+ // Add event listeners to all IP lookup buttons
+ document.addEventListener('click', function(e) {
+ const button = e.target.closest('[data-ip-lookup]');
+ if (!button) return;
+
+ e.preventDefault();
+ const ip = button.dataset.ipLookup;
+ lookupIP(ip);
+ });
+}
+
+async function lookupIP(ip) {
+ const modal = document.getElementById('ip-lookup-modal');
+ const content = document.getElementById('ip-lookup-content');
+
+ if (!modal || !content) return;
+
+ // Show modal with loading state
+ content.innerHTML = `${spinnerSvg({ size: 'lg' })}
`;
+ modal.showModal();
+
+ try {
+ const response = await fetch(`/pripyat/lookup-ip/${encodeURIComponent(ip)}`);
+ const result = await response.json();
+
+ if (result.success) {
+ const data = result.data;
+ content.innerHTML = `
+
+ `;
+ populateLookupResult(content, data);
+ } else {
+ content.innerHTML = `
+
+ `;
+ const errorEl = document.getElementById('ip-lookup-error-message');
+ if (errorEl) errorEl.textContent = `Failed to lookup IP: ${result.error || 'Unknown error'}`;
+ }
+ } catch (error) {
+ content.innerHTML = `
+
+
+
Network error occurred
+
+ `;
+ }
+}
+
+function populateLookupResult(content, data) {
+ setText(content, '[data-ip-field="ip"]', data.ip)
+ setText(content, '[data-ip-field="country"]', `${data.country} (${data.country_code})`)
+ toggleSection(content, 'city', data.city, `${data.city}${data.zip ? ', ' + data.zip : ''}`)
+ toggleSection(content, 'region', data.region, data.region)
+ toggleSection(content, 'timezone', data.timezone, data.timezone)
+ toggleSection(content, 'coordinates', data.latitude && data.longitude, `${data.latitude}, ${data.longitude}`)
+ toggleSection(content, 'isp', data.isp, data.isp)
+ toggleSection(content, 'org', data.org, data.org)
+ toggleSection(content, 'as', data.as, data.as)
+}
+
+function toggleSection(content, key, visible, value) {
+ const section = content.querySelector(`[data-ip-section="${key}"]`)
+ if (!section) return
+
+ section.classList.toggle('hidden', !visible)
+ if (visible) {
+ const valueEl = section.querySelector('[data-ip-value]')
+ if (valueEl) valueEl.textContent = value
+ }
+}
+
+function setText(content, selector, value) {
+ const el = content.querySelector(selector)
+ if (el) el.textContent = value || ''
+}
diff --git a/apps/elektrine/assets/js/live_clock.js b/apps/elektrine/assets/js/live_clock.js
new file mode 100644
index 0000000..490537d
--- /dev/null
+++ b/apps/elektrine/assets/js/live_clock.js
@@ -0,0 +1,28 @@
+// Live server time clock for footer
+export function initLiveClock() {
+ const clockElements = document.querySelectorAll('[data-live-clock]');
+
+ if (clockElements.length === 0) return;
+
+ function updateClocks() {
+ const now = new Date();
+ const utcTime = now.toISOString().slice(11, 19) + ' UTC';
+
+ clockElements.forEach(element => {
+ element.textContent = element.textContent.replace(/\d{2}:\d{2}:\d{2} UTC/, utcTime);
+ });
+ }
+
+ // Update every second
+ setInterval(updateClocks, 1000);
+}
+
+// Initialize when DOM is ready
+document.addEventListener('DOMContentLoaded', () => {
+ initLiveClock();
+});
+
+// Also initialize when navigating via LiveView
+document.addEventListener('phx:page-loading-stop', () => {
+ setTimeout(initLiveClock, 100);
+});
\ No newline at end of file
diff --git a/apps/elektrine/assets/js/markdown_editor.js b/apps/elektrine/assets/js/markdown_editor.js
new file mode 100644
index 0000000..4d5a7a0
--- /dev/null
+++ b/apps/elektrine/assets/js/markdown_editor.js
@@ -0,0 +1,258 @@
+export function initMarkdownEditor() {
+ document.addEventListener('click', (e) => {
+ const tabButton = e.target.closest('[data-show-tab]');
+ if (tabButton) {
+ e.preventDefault();
+ const tab = tabButton.getAttribute('data-show-tab');
+ showMarkdownTab(tab);
+ }
+ });
+
+ document.addEventListener('input', (e) => {
+ if (e.target.matches('[data-markdown-preview-input]')) {
+ updateMarkdownPreview();
+ }
+ });
+}
+
+function showMarkdownTab(tab) {
+ const editTab = document.getElementById('edit-tab');
+ const previewTab = document.getElementById('preview-tab');
+ const editDiv = document.getElementById('markdown-edit');
+ const previewDiv = document.getElementById('markdown-preview');
+
+ if (tab === 'edit') {
+ editTab.classList.add('tab-active');
+ previewTab.classList.remove('tab-active');
+ editDiv.classList.remove('hidden');
+ previewDiv.classList.add('hidden');
+ } else {
+ editTab.classList.remove('tab-active');
+ previewTab.classList.add('tab-active');
+ editDiv.classList.add('hidden');
+ previewDiv.classList.remove('hidden');
+ updateMarkdownPreview();
+ }
+}
+
+function updateMarkdownPreview() {
+ const textarea = document.getElementById('description-textarea');
+ const previewContent = document.getElementById('preview-content');
+
+ if (!textarea || !previewContent) return;
+
+ const markdown = textarea.value.trim();
+
+ if (!markdown) {
+ previewContent.innerHTML = 'Preview will appear here...
';
+ return;
+ }
+
+ const html = renderMarkdownPreview(markdown);
+ previewContent.innerHTML = html;
+}
+
+function renderMarkdownPreview(markdown) {
+ const blocks = [];
+ const lines = markdown.split('\n');
+ let listItems = [];
+ let codeLines = [];
+ let inCodeBlock = false;
+
+ const flushList = () => {
+ if (listItems.length === 0) return;
+
+ blocks.push(``);
+ listItems = [];
+ };
+
+ const flushCodeBlock = () => {
+ if (codeLines.length === 0) return;
+
+ blocks.push(
+ `${codeLines.join('\n')} `
+ );
+ codeLines = [];
+ };
+
+ for (const line of lines) {
+ if (line.startsWith('```')) {
+ flushList();
+
+ if (inCodeBlock) {
+ flushCodeBlock();
+ inCodeBlock = false;
+ } else {
+ inCodeBlock = true;
+ }
+
+ continue;
+ }
+
+ if (inCodeBlock) {
+ codeLines.push(escapeHtml(line));
+ continue;
+ }
+
+ if (line.trim() === '') {
+ flushList();
+ blocks.push(' ');
+ continue;
+ }
+
+ const headingLevel = headingLevelForLine(line);
+
+ if (headingLevel !== null) {
+ flushList();
+ const content = renderInlineMarkdown(line.slice(headingLevel + 1));
+ blocks.push(headingTag(headingLevel, content));
+ continue;
+ }
+
+ if (line.startsWith('> ')) {
+ flushList();
+ blocks.push(
+ `${renderInlineMarkdown(line.slice(2))} `
+ );
+ continue;
+ }
+
+ if (line.startsWith('- ') || line.startsWith('* ')) {
+ listItems.push(`${renderInlineMarkdown(line.slice(2))} `);
+ continue;
+ }
+
+ const orderedPrefixLength = orderedListPrefixLength(line);
+
+ if (orderedPrefixLength !== null) {
+ listItems.push(`${renderInlineMarkdown(line.slice(orderedPrefixLength))} `);
+ continue;
+ }
+
+ flushList();
+ blocks.push(`${renderInlineMarkdown(line)}
`);
+ }
+
+ if (inCodeBlock) {
+ flushCodeBlock();
+ }
+
+ flushList();
+
+ return blocks.join('\n');
+}
+
+function headingLevelForLine(line) {
+ if (line.startsWith('### ')) return 3;
+ if (line.startsWith('## ')) return 2;
+ if (line.startsWith('# ')) return 1;
+ return null;
+}
+
+function headingTag(level, content) {
+ if (level === 1) return `${content} `;
+ if (level === 2) return `${content} `;
+ return `${content} `;
+}
+
+function orderedListPrefixLength(line) {
+ let index = 0;
+
+ while (index < line.length && isDigit(line[index])) {
+ index += 1;
+ }
+
+ if (index === 0) return null;
+ if (line[index] !== '.' || line[index + 1] !== ' ') return null;
+
+ return index + 2;
+}
+
+function isDigit(character) {
+ return character >= '0' && character <= '9';
+}
+
+function renderInlineMarkdown(text) {
+ let output = '';
+ let index = 0;
+
+ while (index < text.length) {
+ if (text.startsWith('**', index)) {
+ const end = text.indexOf('**', index + 2);
+
+ if (end !== -1) {
+ const content = renderInlineMarkdown(text.slice(index + 2, end));
+ output += `${content} `;
+ index = end + 2;
+ continue;
+ }
+ }
+
+ if (text[index] === '*') {
+ const end = text.indexOf('*', index + 1);
+
+ if (end !== -1) {
+ const content = renderInlineMarkdown(text.slice(index + 1, end));
+ output += `${content} `;
+ index = end + 1;
+ continue;
+ }
+ }
+
+ if (text[index] === '`') {
+ const end = text.indexOf('`', index + 1);
+
+ if (end !== -1) {
+ const content = escapeHtml(text.slice(index + 1, end));
+ output += `${content}`;
+ index = end + 1;
+ continue;
+ }
+ }
+
+ if (text[index] === '[') {
+ const closeLabel = text.indexOf(']', index + 1);
+
+ if (closeLabel !== -1 && text[closeLabel + 1] === '(') {
+ const closeUrl = text.indexOf(')', closeLabel + 2);
+
+ if (closeUrl !== -1) {
+ const label = text.slice(index + 1, closeLabel);
+ const href = text.slice(closeLabel + 2, closeUrl).trim();
+
+ if (isSafeHref(href)) {
+ output += `${renderInlineMarkdown(label)} `;
+ } else {
+ output += escapeHtml(text.slice(index, closeUrl + 1));
+ }
+
+ index = closeUrl + 1;
+ continue;
+ }
+ }
+ }
+
+ output += escapeHtml(text[index]);
+ index += 1;
+ }
+
+ return output;
+}
+
+function isSafeHref(href) {
+ return (
+ href.startsWith('http://') ||
+ href.startsWith('https://') ||
+ href.startsWith('mailto:') ||
+ (href.startsWith('/') && !href.startsWith('//'))
+ );
+}
+
+function escapeHtml(text) {
+ return text
+ .replaceAll('&', '&')
+ .replaceAll('<', '<')
+ .replaceAll('>', '>')
+ .replaceAll('"', '"')
+ .replaceAll("'", ''');
+}
diff --git a/apps/elektrine/assets/js/markdown_helpers.js b/apps/elektrine/assets/js/markdown_helpers.js
new file mode 100644
index 0000000..2c84f24
--- /dev/null
+++ b/apps/elektrine/assets/js/markdown_helpers.js
@@ -0,0 +1,178 @@
+// Markdown helper functions
+
+export function insertMarkdownFormat(format, textarea) {
+ const start = textarea.selectionStart
+ const end = textarea.selectionEnd
+ const selectedText = textarea.value.substring(start, end)
+ let replacement = ''
+
+ switch (format) {
+ case 'bold':
+ replacement = `**${selectedText || 'bold text'}**`
+ break
+ case 'italic':
+ replacement = `*${selectedText || 'italic text'}*`
+ break
+ case 'underline':
+ replacement = `${selectedText || 'underlined text'} `
+ break
+ case 'heading':
+ replacement = `# ${selectedText || 'Heading'}`
+ break
+ case 'link':
+ const url = selectedText.startsWith('http') ? selectedText : 'https://example.com'
+ const linkText = selectedText.startsWith('http') ? 'link text' : selectedText || 'link text'
+ replacement = `[${linkText}](${url})`
+ break
+ case 'list':
+ replacement = `- ${selectedText || 'list item'}`
+ break
+ case 'quote':
+ replacement = `> ${selectedText || 'quoted text'}`
+ break
+ }
+
+ textarea.value = textarea.value.substring(0, start) + replacement + textarea.value.substring(end)
+ textarea.focus()
+
+ // Set cursor position
+ const newPos = start + replacement.length
+ textarea.setSelectionRange(newPos, newPos)
+}
+
+export function toggleMarkdownPreview(textarea, targetId) {
+ let previewPanelId = 'preview-panel'
+ let previewContentId = 'preview-content'
+
+ // Use reply preview for reply/forward mode
+ if (targetId === 'new-message-area') {
+ previewPanelId = 'reply-preview-panel'
+ previewContentId = 'reply-preview-content'
+ }
+
+ const previewPanel = document.getElementById(previewPanelId)
+ const previewContent = document.getElementById(previewContentId)
+
+ if (!previewPanel || !previewContent) return
+
+ if (previewPanel.classList.contains('hidden')) {
+ // Show preview
+ const markdown = textarea.value
+ const html = markdownToHtml(markdown)
+ previewContent.innerHTML = html
+ previewPanel.classList.remove('hidden')
+ } else {
+ // Hide preview
+ previewPanel.classList.add('hidden')
+ }
+}
+
+export function sanitizeMarkdownHref(href) {
+ if (typeof href !== 'string') return null
+
+ const trimmedHref = href.trim()
+ if (trimmedHref === '') return null
+
+ if (trimmedHref.startsWith('#')) {
+ return trimmedHref
+ }
+
+ if (trimmedHref.startsWith('/')) {
+ return trimmedHref.startsWith('//') ? null : trimmedHref
+ }
+
+ // Normalize away ASCII whitespace/control characters for scheme checks.
+ const normalizedHref = trimmedHref.replace(/[\u0000-\u001F\u007F\s]+/g, '')
+ const schemeMatch = normalizedHref.match(/^([a-zA-Z][a-zA-Z\d+.-]*):/)
+
+ if (!schemeMatch) return null
+
+ const scheme = schemeMatch[1].toLowerCase()
+ if (scheme === 'http' || scheme === 'https' || scheme === 'mailto') {
+ return trimmedHref
+ }
+
+ return null
+}
+
+export function markdownToHtml(markdown) {
+ if (!markdown) return ''
+
+ const lines = markdown.split('\n')
+ const htmlLines = []
+ let inCodeBlock = false
+ let codeBlockLines = []
+
+ for (const line of lines) {
+ if (line.startsWith('```')) {
+ if (inCodeBlock) {
+ htmlLines.push(`${codeBlockLines.join('\n')} `)
+ codeBlockLines = []
+ inCodeBlock = false
+ } else {
+ inCodeBlock = true
+ }
+ continue
+ }
+
+ if (inCodeBlock) {
+ codeBlockLines.push(escapeHtml(line))
+ continue
+ }
+
+ let processedLine = ''
+
+ if (line.startsWith('### ')) {
+ processedLine = `${escapeHtml(line.substring(4))} `
+ } else if (line.startsWith('## ')) {
+ processedLine = `${escapeHtml(line.substring(3))} `
+ } else if (line.startsWith('# ')) {
+ processedLine = `${escapeHtml(line.substring(2))} `
+ } else if (line.startsWith('> ')) {
+ processedLine = `${processMarkdownInline(line.substring(2))} `
+ } else if (line.startsWith('- ') || line.startsWith('* ')) {
+ processedLine = `${processMarkdownInline(line.substring(2))} `
+ } else if (/^\d+\. /.test(line)) {
+ processedLine = `${processMarkdownInline(line.replace(/^\d+\. /, ''))} `
+ } else if (line.trim() !== '') {
+ processedLine = `${processMarkdownInline(line)}
`
+ } else {
+ processedLine = ' '
+ }
+
+ htmlLines.push(processedLine)
+ }
+
+ if (inCodeBlock && codeBlockLines.length > 0) {
+ htmlLines.push(`${codeBlockLines.join('\n')} `)
+ }
+
+ return htmlLines.join('\n').replace(/(.*<\/li>\n?)+/g, (match) => {
+ return ``
+ })
+}
+
+function processMarkdownInline(text) {
+ return escapeHtml(text)
+ .replace(/\*\*([^*]+)\*\*/g, '$1 ')
+ .replace(/\*([^*]+)\*/g, '$1 ')
+ .replace(/`([^`]+)`/g, '$1')
+ .replace(/\[([^\]]+)\]\(([^\)]+)\)/g, (match, label, href) => {
+ const safeHref = sanitizeMarkdownHref(href)
+
+ if (!safeHref) {
+ return match
+ }
+
+ return `${label} `
+ })
+}
+
+function escapeHtml(text) {
+ return text
+ .replaceAll('&', '&')
+ .replaceAll('<', '<')
+ .replaceAll('>', '>')
+ .replaceAll('"', '"')
+ .replaceAll("'", ''')
+}
diff --git a/apps/elektrine/assets/js/markdown_toolbar.js b/apps/elektrine/assets/js/markdown_toolbar.js
new file mode 100644
index 0000000..4ec1974
--- /dev/null
+++ b/apps/elektrine/assets/js/markdown_toolbar.js
@@ -0,0 +1,281 @@
+import { sanitizeMarkdownHref } from "./markdown_helpers";
+
+// Markdown toolbar module for email compose
+export function initMarkdownToolbar() {
+ const syncSplitPreview = (event) => {
+ const textarea = event.target.closest('#html-editor')
+ if (!textarea) return
+
+ const previewTab = document.querySelector('[data-editor-tab].tab-active')
+ if (!previewTab || previewTab.dataset.editorTab !== 'split') return
+
+ const previewContent = document.getElementById('preview-content')
+ if (previewContent) {
+ updatePreview(textarea.value, previewContent)
+ }
+ }
+
+ document.addEventListener('input', syncSplitPreview)
+
+ // Handle tab switching
+ document.addEventListener('click', (e) => {
+ const tab = e.target.closest('[data-editor-tab]');
+ if (tab) {
+ e.preventDefault();
+ const mode = tab.dataset.editorTab;
+
+ // Update tab states
+ document.querySelectorAll('[data-editor-tab]').forEach(t => {
+ t.classList.remove('tab-active');
+ });
+ tab.classList.add('tab-active');
+
+ const container = document.getElementById('editor-container');
+ const writeDiv = document.getElementById('editor-write');
+ const previewDiv = document.getElementById('editor-preview');
+ const toolbar = document.getElementById('formatting-toolbar');
+ const textarea = document.getElementById('html-editor');
+ const previewContent = document.getElementById('preview-content');
+
+ // Reset container classes
+ container.classList.remove('flex', 'gap-2');
+ writeDiv.classList.remove('hidden', 'w-1/2');
+ previewDiv.classList.remove('hidden', 'w-1/2');
+
+ if (mode === 'write') {
+ writeDiv.classList.remove('hidden');
+ previewDiv.classList.add('hidden');
+ toolbar.classList.remove('hidden');
+ } else if (mode === 'preview') {
+ writeDiv.classList.add('hidden');
+ previewDiv.classList.remove('hidden');
+ toolbar.classList.add('hidden');
+
+ // Update preview
+ if (previewContent && textarea) {
+ updatePreview(textarea.value, previewContent);
+ }
+ } else if (mode === 'split') {
+ // Split view
+ container.classList.add('flex', 'gap-2');
+ writeDiv.classList.remove('hidden');
+ writeDiv.classList.add('w-1/2');
+ previewDiv.classList.remove('hidden');
+ previewDiv.classList.add('w-1/2');
+ toolbar.classList.remove('hidden');
+
+ // Update preview
+ if (previewContent && textarea) {
+ updatePreview(textarea.value, previewContent);
+ }
+ }
+ }
+ });
+
+ // Handle markdown format buttons
+ document.addEventListener('click', (e) => {
+ const formatBtn = e.target.closest('[data-markdown-format]');
+ if (!formatBtn) return;
+
+ e.preventDefault();
+ const format = formatBtn.dataset.markdownFormat;
+ const targetId = formatBtn.dataset.target || 'html-editor';
+ const textarea = document.getElementById(targetId);
+
+ if (!textarea) return;
+
+ const start = textarea.selectionStart;
+ const end = textarea.selectionEnd;
+ const selectedText = textarea.value.substring(start, end);
+ const beforeText = textarea.value.substring(0, start);
+ const afterText = textarea.value.substring(end);
+
+ let newText = '';
+ let cursorOffset = 0;
+
+ switch(format) {
+ case 'bold':
+ newText = selectedText ? `**${selectedText}**` : '**bold text**';
+ cursorOffset = selectedText ? 2 + selectedText.length : 2;
+ break;
+
+ case 'italic':
+ newText = selectedText ? `*${selectedText}*` : '*italic text*';
+ cursorOffset = selectedText ? 1 + selectedText.length : 1;
+ break;
+
+ case 'code':
+ newText = selectedText ? `\`${selectedText}\`` : '`code`';
+ cursorOffset = selectedText ? 1 + selectedText.length : 1;
+ break;
+
+ case 'code-block':
+ newText = selectedText ? `\`\`\`\n${selectedText}\n\`\`\`` : '```\ncode block\n```';
+ cursorOffset = selectedText ? 4 : 4;
+ break;
+
+ case 'link':
+ newText = selectedText ? `[${selectedText}](url)` : '[link text](url)';
+ cursorOffset = selectedText ? 3 + selectedText.length : 11;
+ break;
+
+ case 'list-bullet':
+ const lines = selectedText ? selectedText.split('\n') : [''];
+ newText = lines.map(line => `- ${line}`).join('\n');
+ cursorOffset = 2;
+ break;
+
+ case 'list-number':
+ const numberedLines = selectedText ? selectedText.split('\n') : [''];
+ newText = numberedLines.map((line, i) => `${i + 1}. ${line}`).join('\n');
+ cursorOffset = 3;
+ break;
+
+ case 'quote':
+ const quoteLines = selectedText ? selectedText.split('\n') : [''];
+ newText = quoteLines.map(line => `> ${line}`).join('\n');
+ cursorOffset = 2;
+ break;
+
+ case 'h1':
+ newText = selectedText ? `# ${selectedText}` : '# Heading 1';
+ cursorOffset = selectedText ? 2 : 2;
+ break;
+
+ case 'h2':
+ newText = selectedText ? `## ${selectedText}` : '## Heading 2';
+ cursorOffset = selectedText ? 3 : 3;
+ break;
+
+ case 'h3':
+ newText = selectedText ? `### ${selectedText}` : '### Heading 3';
+ cursorOffset = selectedText ? 4 : 4;
+ break;
+
+ case 'heading':
+ newText = selectedText ? `## ${selectedText}` : '## Heading';
+ cursorOffset = selectedText ? 3 : 3;
+ break;
+
+ default:
+ return;
+ }
+
+ textarea.value = beforeText + newText + afterText;
+ textarea.selectionStart = start + cursorOffset;
+ textarea.selectionEnd = start + cursorOffset;
+ textarea.focus();
+
+ // Trigger input event for any listeners
+ textarea.dispatchEvent(new Event('input', { bubbles: true }));
+ });
+}
+
+// Convert markdown to HTML for preview
+function updatePreview(markdown, previewElement) {
+ if (!previewElement) {
+ return;
+ }
+
+ if (!markdown || markdown.trim() === '') {
+ previewElement.innerHTML = 'Nothing to preview yet...
';
+ return;
+ }
+
+ // Process markdown line by line for better handling
+ const lines = markdown.split('\n');
+ const htmlLines = [];
+ let inCodeBlock = false;
+ let codeBlockLines = [];
+
+ for (let line of lines) {
+ // Handle code blocks
+ if (line.startsWith('```')) {
+ if (inCodeBlock) {
+ htmlLines.push(`${codeBlockLines.join('\n')} `);
+ codeBlockLines = [];
+ inCodeBlock = false;
+ } else {
+ inCodeBlock = true;
+ }
+ continue;
+ }
+
+ if (inCodeBlock) {
+ codeBlockLines.push(escapeHtml(line));
+ continue;
+ }
+
+ // Process individual line
+ let processedLine = line;
+
+ // Headers (must be at line start)
+ if (line.startsWith('### ')) {
+ processedLine = `${escapeHtml(line.substring(4))} `;
+ } else if (line.startsWith('## ')) {
+ processedLine = `${escapeHtml(line.substring(3))} `;
+ } else if (line.startsWith('# ')) {
+ processedLine = `${escapeHtml(line.substring(2))} `;
+ }
+ // Blockquotes
+ else if (line.startsWith('> ')) {
+ processedLine = `${processMarkdownInline(line.substring(2))} `;
+ }
+ // Unordered lists
+ else if (line.startsWith('- ') || line.startsWith('* ')) {
+ processedLine = ` ${processMarkdownInline(line.substring(2))} `;
+ }
+ // Ordered lists
+ else if (/^\d+\. /.test(line)) {
+ processedLine = `${processMarkdownInline(line.replace(/^\d+\. /, ''))} `;
+ }
+ // Regular paragraph
+ else if (line.trim() !== '') {
+ processedLine = `${processMarkdownInline(line)}
`;
+ } else {
+ processedLine = ' ';
+ }
+
+ htmlLines.push(processedLine);
+ }
+
+ // Handle any unclosed code block
+ if (inCodeBlock && codeBlockLines.length > 0) {
+ htmlLines.push(`${codeBlockLines.join('\n')} `);
+ }
+
+ // Wrap consecutive list items in ul tags
+ const finalHtml = htmlLines.join('\n').replace(/(.*<\/li>\n)+/g, (match) => {
+ return ``;
+ });
+
+ previewElement.innerHTML = finalHtml;
+}
+
+// Process inline markdown (bold, italic, code, links)
+function processMarkdownInline(text) {
+ return escapeHtml(text)
+ // Bold (must come before italic)
+ .replace(/\*\*([^*]+)\*\*/g, '$1 ')
+ // Italic
+ .replace(/\*([^*]+)\*/g, '$1 ')
+ // Inline code
+ .replace(/`([^`]+)`/g, '$1')
+ // Links
+ .replace(/\[([^\]]+)\]\(([^\)]+)\)/g, (match, label, href) => {
+ const safeHref = sanitizeMarkdownHref(href);
+
+ if (!safeHref) {
+ return match;
+ }
+
+ return `${label} `;
+ });
+}
+
+// Escape HTML to prevent XSS
+function escapeHtml(text) {
+ const div = document.createElement('div');
+ div.textContent = text;
+ return div.innerHTML;
+}
diff --git a/apps/elektrine/assets/js/modal_controls.js b/apps/elektrine/assets/js/modal_controls.js
new file mode 100644
index 0000000..b36603c
--- /dev/null
+++ b/apps/elektrine/assets/js/modal_controls.js
@@ -0,0 +1,17 @@
+import { submitFormPreservingEvents } from './utils/form_submission'
+
+// Modal controls module for handling modal interactions
+export function initModalControls() {
+ // Note: Confirmation dialogs should be handled by Phoenix.JS or LiveView
+ // This is kept minimal for older clients only
+ document.addEventListener('click', (e) => {
+ const confirmBtn = e.target.closest('[data-confirm]:not([phx-click])');
+ if (confirmBtn && confirmBtn.form) {
+ e.preventDefault();
+ const message = confirmBtn.dataset.confirm;
+ if (confirm(message)) {
+ submitFormPreservingEvents(confirmBtn.form, confirmBtn);
+ }
+ }
+ });
+}
diff --git a/apps/elektrine/assets/js/notification_system.js b/apps/elektrine/assets/js/notification_system.js
new file mode 100644
index 0000000..ea73f1a
--- /dev/null
+++ b/apps/elektrine/assets/js/notification_system.js
@@ -0,0 +1,523 @@
+// Global notification system for reliable notifications
+
+import { spinnerSvg } from './utils/spinner'
+
+function appendHtml(parent, html) {
+ const template = document.createElement('template')
+ template.innerHTML = html.trim()
+ parent.appendChild(template.content)
+}
+
+function notificationToneClass(type) {
+ return {
+ success: 'app-flash--success',
+ info: 'app-flash--info',
+ warning: 'app-flash--warning',
+ error: 'app-flash--error',
+ loading: 'app-flash--loading'
+ }[type] || 'app-flash--info'
+}
+
+function flashGroup() {
+ let group = document.getElementById('flash-group')
+
+ if (!group) {
+ group = document.createElement('div')
+ group.id = 'flash-group'
+ group.className = 'fixed bottom-4 left-1/2 z-[1000] w-full max-w-sm -translate-x-1/2 px-4 sm:left-auto sm:right-4 sm:translate-x-0 sm:px-0 space-y-2 pointer-events-none'
+ document.body.appendChild(group)
+ }
+
+ return group
+}
+
+/**
+ * Clone the flash blueprint that `flash_group/1` renders.
+ *
+ * This is the whole point of the template: the markup of a notification is
+ * defined once, in `core_components.ex`, and this side only fills it in. The
+ * alternative — building the same structure with createElement — is what let
+ * the two drift into different icon sets.
+ *
+ * Returns null when no blueprint is on the page. Every layout renders
+ * `flash_group/1`, so that means something is wrong rather than that a toast
+ * should be invented from scratch here.
+ */
+function cloneFlash(type, durationMs) {
+ const template = document.getElementById('flash-template')
+
+ if (!template) {
+ console.warn('notification: #flash-template is missing; is flash_group/1 rendered?')
+ return null
+ }
+
+ const el = template.content.firstElementChild.cloneNode(true)
+
+ // The blueprint is rendered as an :info flash. Re-tone it, and drop the id so
+ // clones cannot collide with the server-rendered flashes beside them.
+ el.removeAttribute('id')
+ el.classList.remove('app-flash--info')
+ el.classList.add(notificationToneClass(type))
+ el.dataset.flashKind = type
+ el.dataset.flashAutoDismissMs = String(durationMs)
+ el.style.setProperty('--flash-auto-dismiss', `${durationMs}ms`)
+
+ // `lv:clear-flash` belongs to a flash the server is holding. This toast has no
+ // server-side counterpart, and pushing it would clear an unrelated flash.
+ el.querySelector('[data-flash-dismiss]')?.removeAttribute('phx-click')
+
+ return el
+}
+
+/**
+ * Show a toast notification with various options
+ * @param {string} message - The notification message
+ * @param {string} type - Notification type: 'success', 'info', 'warning', 'error', 'loading'
+ * @param {string|Object} titleOrOptions - Title string or options object
+ * @param {Object} options - Additional options (if title is a string)
+ * - title: Custom title
+ * - duration: Auto-dismiss time in ms (default: 5000, use 0 or 'persistent' for no auto-dismiss)
+ * - persistent: If true, notification won't auto-dismiss
+ * - actions: Array of action buttons [{label, event, class}]
+ * - progress: Show progress bar (countdown)
+ * - undoEvent: Event name for undo action (adds Undo button)
+ * - undoData: Data to pass with undo event
+ */
+export function showNotification(message, type = 'info', titleOrOptions = null, options = {}) {
+ // Handle different parameter signatures
+ let config = {
+ title: null,
+ duration: 5000,
+ persistent: false,
+ actions: [],
+ progress: false,
+ undoEvent: null,
+ undoData: {},
+ ...options
+ }
+
+ if (typeof titleOrOptions === 'string') {
+ config.title = titleOrOptions
+ } else if (typeof titleOrOptions === 'object' && titleOrOptions !== null) {
+ config = { ...config, ...titleOrOptions }
+ }
+
+ // Handle persistent option
+ if (config.persistent || config.duration === 'persistent') {
+ config.duration = 0
+ }
+
+ // The same solid heroicons the server-rendered flash uses, by the same route
+ // — a `ui-icon` span the Tailwind plugin fills with a mask. These used to be
+ // hand-written outline paths in an inline , which made a JS toast and a
+ // `put_flash` sitting next to each other visibly different weights.
+ //
+ // Written as whole class names on purpose: tailwind.config.mjs scans
+ // ./js/**/*.js, so it only generates the ones it can read here literally.
+ const icons = {
+ 'success': 'hero-check-circle-solid',
+ 'info': 'hero-check-circle-solid',
+ 'warning': 'hero-exclamation-triangle-solid',
+ 'error': 'hero-exclamation-circle-solid',
+ 'loading': null // Will use spinner instead
+ }
+
+ const iconName = icons[type]
+
+ // Default titles based on type
+ const defaultTitle = config.title || {
+ 'success': 'Success!',
+ 'info': 'Info',
+ 'warning': 'Warning',
+ 'error': 'Error!',
+ 'loading': 'Loading...'
+ }[type] || ''
+
+ const allActions = [...config.actions]
+
+ // Add undo button if undoEvent is specified
+ if (config.undoEvent) {
+ allActions.push({
+ label: 'Undo',
+ event: config.undoEvent,
+ data: config.undoData,
+ class: 'btn-ghost btn-xs'
+ })
+ }
+
+ const notification = cloneFlash(type, config.duration || 0)
+ if (!notification) return null
+
+ const closeBtn = notification.querySelector('[data-flash-dismiss]')
+ closeBtn.classList.add('notification-close-btn')
+
+ const iconSlot = notification.querySelector('[data-flash-icon]')
+ if (type === 'loading') {
+ iconSlot.className = 'flash-message__icon h-5 w-5 flex-shrink-0'
+ appendHtml(iconSlot, spinnerSvg())
+ } else {
+ iconSlot.className = `ui-icon ${iconName} h-5 w-5 flex-shrink-0`
+ }
+
+ const titleEl = notification.querySelector('[data-flash-title]')
+ if (defaultTitle) {
+ titleEl.textContent = defaultTitle
+ } else {
+ titleEl.remove()
+ }
+
+ const messageEl = notification.querySelector('[data-flash-message]')
+ messageEl.textContent = message
+
+ // Actions have no counterpart on a server flash, so they are appended to the
+ // cloned body rather than being part of the blueprint.
+ if (allActions.length > 0) {
+ const actions = document.createElement('div')
+ actions.className = 'notification-actions flex gap-2 mt-2'
+
+ allActions.forEach((action, idx) => {
+ const button = document.createElement('button')
+ button.className = `btn ${action.class || 'btn-ghost btn-xs'}`
+ button.dataset.actionIdx = String(idx)
+ button.textContent = action.label
+ actions.appendChild(button)
+ })
+
+ messageEl.parentNode.appendChild(actions)
+ }
+
+ if (config.progress && config.duration > 0) {
+ const progress = document.createElement('div')
+ progress.className = 'notification-progress absolute bottom-0 left-0 h-1 bg-current opacity-30'
+ progress.style.width = '100%'
+ progress.style.transition = `width ${config.duration}ms linear`
+ notification.appendChild(progress)
+ }
+
+ // Add to DOM
+ flashGroup().appendChild(notification)
+
+ let autoRemoveTimer = null
+
+ // Function to remove the notification
+ const removeNotification = () => {
+ if (autoRemoveTimer) {
+ clearTimeout(autoRemoveTimer)
+ }
+ notification.classList.add('is-dismissing')
+ setTimeout(() => {
+ if (notification.parentNode) {
+ notification.remove()
+ }
+ }, 260)
+ }
+
+ // Add event listener to close button
+ closeBtn.addEventListener('click', () => {
+ removeNotification()
+ })
+
+ // Add event listeners to action buttons
+ if (allActions.length > 0) {
+ const actionBtns = notification.querySelectorAll('[data-action-idx]')
+ actionBtns.forEach(btn => {
+ btn.addEventListener('click', () => {
+ const idx = parseInt(btn.dataset.actionIdx)
+ const action = allActions[idx]
+ if (action && action.event) {
+ // Dispatch custom event for LiveView to handle
+ window.dispatchEvent(new CustomEvent('phx:notification_action', {
+ detail: { event: action.event, data: action.data || {} }
+ }))
+ // Also push to LiveView if available
+ if (window.liveSocket) {
+ const mainEl = document.querySelector('[data-phx-main]')
+ if (mainEl && mainEl._liveView) {
+ mainEl._liveView.pushEvent(action.event, action.data || {})
+ }
+ }
+ }
+ if (action.callback) {
+ action.callback()
+ }
+ // Dismiss notification after action
+ removeNotification()
+ })
+ })
+ }
+
+ // Use the flash manager for consistent positioning and animation
+ // Start progress bar animation
+ if (config.progress && config.duration > 0) {
+ const progressBar = notification.querySelector('.notification-progress')
+ if (progressBar) {
+ // Trigger reflow before starting animation
+ progressBar.offsetWidth
+ requestAnimationFrame(() => {
+ progressBar.style.width = '0%'
+ })
+ }
+ }
+
+ // Auto-remove after duration (if not persistent)
+ if (config.duration > 0) {
+ autoRemoveTimer = setTimeout(() => {
+ removeNotification()
+ }, config.duration)
+ }
+
+ // Click to dismiss (on the notification itself, not the close button or action buttons)
+ notification.addEventListener('click', (e) => {
+ // Don't dismiss if clicking the close button or action buttons
+ if (!e.target.closest('.notification-close-btn') && !e.target.closest('[data-action-idx]')) {
+ if (autoRemoveTimer) {
+ clearTimeout(autoRemoveTimer)
+ }
+ removeNotification()
+ }
+ })
+
+ // Return notification element and dismiss function for programmatic control
+ notification.dismiss = removeNotification
+ return notification
+}
+
+/**
+ * Normalizes LiveView-style payloads into notification options and renders them.
+ * @param {Object} payload - Notification payload from push_event/window events
+ * @returns {HTMLElement|null}
+ */
+export function showNotificationFromPayload(payload = {}) {
+ const {
+ message,
+ type,
+ title,
+ duration,
+ persistent,
+ progress,
+ undoEvent,
+ undoData,
+ actions
+ } = payload
+
+ if (!message) return null
+
+ const options = {}
+ if (title) options.title = title
+ if (duration !== undefined) options.duration = duration
+ if (persistent) options.persistent = persistent
+ if (progress) options.progress = progress
+ if (undoEvent) options.undoEvent = undoEvent
+ if (undoData) options.undoData = undoData
+ if (actions && actions.length > 0) options.actions = actions
+
+ return showNotification(message, type || 'info', options)
+}
+
+/**
+ * Show a loading notification that can be updated or dismissed
+ * @param {string} message - Loading message
+ * @param {Object} options - Additional options
+ * @returns {Object} - Object with update() and dismiss() methods
+ */
+export function showLoadingNotification(message, options = {}) {
+ const notification = showNotification(message, 'loading', {
+ ...options,
+ persistent: true,
+ title: options.title || 'Loading...'
+ })
+
+ return {
+ element: notification,
+ update: (newMessage, newTitle = null) => {
+ const msgEl = notification.querySelector('.text-sm')
+ if (msgEl) msgEl.textContent = newMessage
+ if (newTitle) {
+ const titleEl = notification.querySelector('h3')
+ if (titleEl) titleEl.textContent = newTitle
+ }
+ },
+ dismiss: () => notification.dismiss(),
+ success: (msg, title = 'Success!') => {
+ notification.dismiss()
+ showNotification(msg, 'success', { title })
+ },
+ error: (msg, title = 'Error!') => {
+ notification.dismiss()
+ showNotification(msg, 'error', { title })
+ }
+ }
+}
+
+/**
+ * Show a notification with an undo action
+ * @param {string} message - The notification message
+ * @param {string} undoEvent - Event name to trigger on undo
+ * @param {Object} undoData - Data to pass with undo event
+ * @param {Object} options - Additional options
+ */
+export function showUndoNotification(message, undoEvent, undoData = {}, options = {}) {
+ return showNotification(message, options.type || 'info', {
+ ...options,
+ duration: options.duration || 8000, // Longer duration for undo
+ undoEvent,
+ undoData
+ })
+}
+
+/**
+ * Show a confirmation notification with confirm/cancel actions
+ * @param {string} message - The notification message
+ * @param {Function} onConfirm - Callback on confirm
+ * @param {Function} onCancel - Callback on cancel
+ * @param {Object} options - Additional options
+ */
+export function showConfirmNotification(message, onConfirm, onCancel = null, options = {}) {
+ return showNotification(message, options.type || 'warning', {
+ title: options.title || 'Confirm',
+ persistent: true,
+ actions: [
+ {
+ label: options.confirmLabel || 'Confirm',
+ class: 'btn-primary btn-xs',
+ callback: onConfirm
+ },
+ {
+ label: options.cancelLabel || 'Cancel',
+ class: 'btn-ghost btn-xs',
+ callback: onCancel || (() => {})
+ }
+ ]
+ })
+}
+
+// Global function to show keyboard shortcuts (called from buttons)
+export function showKeyboardShortcuts() {
+
+ // Check if modal already exists to prevent duplicates
+ if (document.getElementById('keyboard-shortcuts-modal')) {
+ return
+ }
+
+ const modal = document.createElement('div')
+ modal.id = 'keyboard-shortcuts-modal'
+ modal.className = 'shortcut-overlay fixed inset-0 flex items-center justify-center z-50'
+
+ // Create modal content container
+ const modalContent = document.createElement('div')
+ modalContent.className = 'shortcut-dialog rounded-lg p-6 max-w-2xl w-full mx-4 max-h-96 overflow-y-auto'
+ modalContent.innerHTML = `
+
+
Keyboard Shortcuts
+
+
+
+
+
+
+
+
Navigation
+
+
+ c
+ Compose
+
+
+ g
+ Go to menu
+
+
+ /
+ Search
+
+
+ j
+ Next message
+
+
+ k
+ Previous message
+
+
+ Enter
+ Open message
+
+
+
+
+
+
Actions
+
+
+ e
+ Archive
+
+
+ r
+ Reply
+
+
+ f
+ Forward
+
+
+ #
+ Delete
+
+
+ !
+ Mark as spam
+
+
+ Shift + /
+ Show this help (?)
+
+
+
+
+
+
+
Go to shortcuts (press 'g' then):
+
+
i Inbox
+
s Sent
+
t Search
+
a Archive
+
p Spam
+
+
+ `
+
+ modal.appendChild(modalContent)
+
+ // Function to close modal
+ const closeModal = () => {
+ modal.remove()
+ }
+
+ // Close modal when clicking outside the content area
+ modal.addEventListener('click', (e) => {
+ if (!modalContent.contains(e.target)) {
+ closeModal()
+ }
+ })
+
+ // Add event listener to close button
+ const closeBtn = modalContent.querySelector('.modal-close-btn')
+ if (closeBtn) {
+ closeBtn.addEventListener('click', closeModal)
+ }
+
+ // Close on Escape key
+ const escapeHandler = (e) => {
+ if (e.key === 'Escape') {
+ closeModal()
+ document.removeEventListener('keydown', escapeHandler)
+ }
+ }
+ document.addEventListener('keydown', escapeHandler)
+
+ document.body.appendChild(modal)
+
+ return modal
+}
diff --git a/apps/elektrine/assets/js/profile_static.js b/apps/elektrine/assets/js/profile_static.js
new file mode 100644
index 0000000..868d399
--- /dev/null
+++ b/apps/elektrine/assets/js/profile_static.js
@@ -0,0 +1,644 @@
+/**
+ * Profile Static Page Handler
+ *
+ * This module handles interactive functionality for static (non-LiveView) profile pages.
+ * Used when profiles are accessed via subdomains (e.g., username.example.com) where LiveView
+ * websockets don't work properly due to cross-origin restrictions.
+ *
+ * Features:
+ * - Followers/Following modals with async data fetching
+ * - Follow/Unfollow functionality
+ * - Friend request actions (send, accept, cancel, unfriend)
+ * - Share modal with clipboard copy
+ * - Timeline drawer toggle
+ * - Profile hooks initialization (typewriter, video background)
+ */
+
+import { TypewriterHook, TabTitleTypewriter, VideoBackground } from "./hooks/profile_hooks"
+
+// =============================================================================
+// Constants
+// =============================================================================
+
+const SELECTORS = {
+ container: "#profile-container",
+ csrfToken: 'meta[name="csrf-token"]',
+
+ // Modals (outside container, at document level)
+ followersModal: '[data-modal="followers"]',
+ followingModal: '[data-modal="following"]',
+ shareModal: '[data-modal="share"]',
+ widgetImageModal: '[data-modal="widget-image"]',
+
+ // Modal content
+ followersList: '[data-role="followers-list"]',
+ followersEmpty: '[data-role="followers-empty"]',
+ followingList: '[data-role="following-list"]',
+ followingEmpty: '[data-role="following-empty"]',
+ widgetImageContent: '[data-role="widget-image-content"]',
+
+ // Buttons (inside container)
+ showFollowers: '[data-action="show-followers"]',
+ showFollowing: '[data-action="show-following"]',
+ showShareModal: '[data-action="show-share-modal"]',
+ toggleFollow: '[data-action="toggle-follow"]',
+ toggleTimelineDrawer: '[data-action="toggle-timeline-drawer"]',
+ openWidgetImage: '[data-action="open-widget-image"]',
+
+ // Buttons (outside container, in modals)
+ closeModal: '[data-action="close-modal"]',
+ copyProfileUrl: '[data-action="copy-profile-url"]',
+ profileShareUrl: "#profile-share-url",
+
+ // Timeline drawer
+ timelineDrawer: '[data-role="timeline-drawer"]'
+}
+
+const ICONS = {
+ checkmark: ' ',
+ user: ' '
+}
+
+const profileContainer = document.querySelector(SELECTORS.container)
+const CONFIGURED_MAIN_DOMAIN = profileContainer?.dataset.mainDomain || ""
+const LOCAL_HANDLE_DOMAIN =
+ profileContainer?.dataset.localDomain || CONFIGURED_MAIN_DOMAIN || window.location.hostname
+
+// Main domain for absolute URLs (handles subdomains like username.example.com -> example.com)
+const MAIN_DOMAIN = getMainDomain()
+
+const FRIEND_ACTIONS = [
+ { selector: '[data-action="send-friend-request"]', method: "POST", endpoint: "friend-request" },
+ { selector: '[data-action="accept-friend-request"]', method: "POST", endpoint: "friend-request/accept" },
+ { selector: '[data-action="cancel-friend-request"]', method: "DELETE", endpoint: "friend-request" },
+ { selector: '[data-action="unfriend"]', method: "DELETE", endpoint: "friend" }
+]
+
+const COPY_FEEDBACK_DURATION = 2000
+
+// =============================================================================
+// Utility Functions
+// =============================================================================
+
+/**
+ * Get the main domain URL for absolute links.
+ * Converts subdomain URLs (e.g., username.example.com) to main domain (example.com).
+ * This ensures links in modals navigate to the main site, not the subdomain.
+ */
+function getMainDomain() {
+ const host = window.location.host.toLowerCase()
+ const protocol = window.location.protocol
+
+ const hostname = host.split(":")[0]
+
+ if (CONFIGURED_MAIN_DOMAIN) {
+ const onConfiguredDomain =
+ hostname === CONFIGURED_MAIN_DOMAIN ||
+ hostname === `www.${CONFIGURED_MAIN_DOMAIN}` ||
+ hostname.endsWith(`.${CONFIGURED_MAIN_DOMAIN}`)
+
+ if (onConfiguredDomain) {
+ return `${protocol}//${CONFIGURED_MAIN_DOMAIN}`
+ }
+ }
+
+ // Best effort fallback for unexpected domains: assume registrable domain is last 2 labels.
+ const parts = hostname.split(".")
+ if (parts.length > 2) {
+ return `${protocol}//${parts.slice(-2).join(".")}`
+ }
+
+ // On main domain or localhost, use current origin
+ return window.location.origin
+}
+
+/**
+ * Convert hex color to RGB object
+ */
+function hexToRgb(hex) {
+ if (!hex || !hex.startsWith("#") || hex.length < 7) {
+ return { r: 0, g: 0, b: 0 }
+ }
+ return {
+ r: parseInt(hex.slice(1, 3), 16) || 0,
+ g: parseInt(hex.slice(3, 5), 16) || 0,
+ b: parseInt(hex.slice(5, 7), 16) || 0
+ }
+}
+
+/**
+ * Determine if a color is light (for contrast calculations)
+ */
+function isLightColor(hex) {
+ const { r, g, b } = hexToRgb(hex)
+ const luminance = 0.2126 * (r / 255) + 0.7152 * (g / 255) + 0.0722 * (b / 255)
+ return luminance > 0.5
+}
+
+/**
+ * Get CSRF token from meta tag
+ */
+function getCsrfToken() {
+ return document.querySelector(SELECTORS.csrfToken)?.getAttribute("content") || ""
+}
+
+// =============================================================================
+// Hook Initialization (for non-LiveView pages)
+// =============================================================================
+
+/**
+ * Create a hook instance that mimics LiveView hook behavior
+ */
+function createHookInstance(hook, el) {
+ const instance = { el }
+ Object.keys(hook).forEach((key) => {
+ instance[key] = typeof hook[key] === 'function' ? hook[key].bind(instance) : hook[key]
+ })
+ return instance
+}
+
+/**
+ * Initialize a hook on all matching elements
+ */
+function initHook(hook, selector) {
+ document.querySelectorAll(selector).forEach((el) => {
+ const instance = createHookInstance(hook, el)
+ if (instance.mounted) instance.mounted()
+ })
+}
+
+/**
+ * Initialize all profile-related hooks
+ */
+function initProfileHooks() {
+ initHook(TypewriterHook, '[phx-hook="TypewriterHook"]')
+ initHook(TabTitleTypewriter, '[phx-hook="TabTitleTypewriter"]')
+ initHook(VideoBackground, '[phx-hook="VideoBackground"]')
+}
+
+// =============================================================================
+// API Functions
+// =============================================================================
+
+/**
+ * Fetch JSON from API with proper headers and error handling
+ */
+async function fetchJson(url, options = {}) {
+ const headers = {
+ "Accept": "application/json",
+ ...options.headers
+ }
+
+ const response = await fetch(url, { ...options, headers })
+
+ // Redirect to login if unauthorized
+ if (response.status === 401) {
+ window.location.href = `${MAIN_DOMAIN}/login`
+ return null
+ }
+
+ const data = await response.json().catch(() => ({}))
+
+ if (!response.ok) {
+ throw new Error(data.error || "Request failed")
+ }
+
+ return data
+}
+
+/**
+ * Make an authenticated API request
+ */
+async function apiRequest(url, method = "GET") {
+ return fetchJson(url, {
+ method,
+ headers: {
+ "Content-Type": "application/json",
+ "X-CSRF-Token": getCsrfToken()
+ }
+ })
+}
+
+// =============================================================================
+// Modal Functions
+// =============================================================================
+
+function openModal(modal) {
+ if (modal) modal.classList.add("modal-open")
+}
+
+function closeModal(modal) {
+ if (modal) modal.classList.remove("modal-open")
+}
+
+// =============================================================================
+// UI Component Builders
+// =============================================================================
+
+/**
+ * Build an avatar element for a user entry
+ */
+function buildAvatar(entry) {
+ const avatar = document.createElement("div")
+
+ if (entry.avatar_url) {
+ avatar.className = "w-10 h-10 rounded-full overflow-hidden bg-base-300 flex items-center justify-center"
+ const img = document.createElement("img")
+ img.src = entry.avatar_url
+ img.alt = entry.display_name || entry.username
+ img.className = "w-full h-full object-cover"
+ avatar.appendChild(img)
+ } else {
+ // Match the placeholder_avatar component: purple gradient with hero-user icon
+ avatar.className = "w-10 h-10 rounded-full flex items-center justify-center flex-shrink-0"
+ avatar.style.background = "linear-gradient(to bottom right, #9333ea, #6b21a8)"
+ const icon = document.createElement("div")
+ icon.className = "w-6 h-6 text-white"
+ icon.innerHTML = ICONS.user
+ avatar.appendChild(icon)
+ }
+
+ return avatar
+}
+
+/**
+ * Build a user row element for followers/following lists.
+ * Uses absolute URLs to ensure links go to the main domain, not subdomains.
+ */
+function buildUserRow(entry) {
+ const row = document.createElement("a")
+ row.className = "flex items-center gap-3 p-2 hover:bg-base-200 rounded-lg"
+
+ const isRemote = entry.type === "remote"
+ const displayHandle = isRemote
+ ? `@${entry.username}@${entry.domain}`
+ : `@${entry.handle || entry.username}@${LOCAL_HANDLE_DOMAIN}`
+
+ // Use absolute URLs to main domain so links work correctly from subdomains
+ row.href = isRemote
+ ? `${MAIN_DOMAIN}/remote/${entry.username}@${entry.domain}`
+ : `${MAIN_DOMAIN}/${entry.handle || entry.username}`
+
+ row.appendChild(buildAvatar(entry))
+
+ const text = document.createElement("div")
+ text.className = "flex-1 min-w-0"
+
+ const name = document.createElement("p")
+ name.className = "font-medium truncate"
+ name.textContent = entry.display_name || entry.username
+
+ const handle = document.createElement("p")
+ handle.className = "text-xs opacity-70 truncate"
+ handle.textContent = displayHandle
+
+ text.appendChild(name)
+ text.appendChild(handle)
+ row.appendChild(text)
+
+ return row
+}
+
+/**
+ * Render a list of followers/following into a container
+ */
+function renderFollowList(listEl, emptyEl, entries) {
+ if (!listEl || !emptyEl) return
+
+ listEl.innerHTML = ""
+ emptyEl.classList.add("hidden")
+
+ if (!entries || entries.length === 0) {
+ emptyEl.classList.remove("hidden")
+ return
+ }
+
+ const wrapper = document.createElement("div")
+ wrapper.className = "space-y-2"
+
+ entries.forEach((entry) => {
+ wrapper.appendChild(buildUserRow(entry))
+ })
+
+ listEl.appendChild(wrapper)
+}
+
+// =============================================================================
+// Button State Management
+// =============================================================================
+
+/**
+ * Update follow button appearance based on follow state
+ */
+function updateFollowButton(button, isFollowing, accentColor) {
+ if (!button) return
+
+ const textColor = isLightColor(accentColor) ? "#000000" : "#ffffff"
+ button.dataset.following = isFollowing ? "true" : "false"
+ button.textContent = isFollowing ? "Following" : "Follow"
+
+ if (isFollowing) {
+ button.classList.add("btn-outline")
+ button.style.borderColor = accentColor
+ button.style.color = accentColor
+ button.style.backgroundColor = ""
+ } else {
+ button.classList.remove("btn-outline")
+ button.style.backgroundColor = accentColor
+ button.style.color = textColor
+ button.style.borderColor = ""
+ }
+}
+
+/**
+ * Show success feedback on copy button
+ */
+function showCopySuccess(button, originalHTML) {
+ button.innerHTML = ICONS.checkmark
+ button.classList.add("btn-success")
+ button.classList.remove("btn-primary")
+
+ setTimeout(() => {
+ button.innerHTML = originalHTML
+ button.classList.remove("btn-success")
+ button.classList.add("btn-primary")
+ }, COPY_FEEDBACK_DURATION)
+}
+
+// =============================================================================
+// Event Handlers
+// =============================================================================
+
+/**
+ * Set up timeline drawer toggle
+ */
+function setupTimelineDrawer(container) {
+ const drawer = container.querySelector(SELECTORS.timelineDrawer)
+ const toggle = container.querySelector(SELECTORS.toggleTimelineDrawer)
+
+ if (!drawer || !toggle) return
+
+ const closedClasses = (drawer.dataset.closedClass || "").split(" ").filter(Boolean)
+ const openClasses = (drawer.dataset.openClass || "").split(" ").filter(Boolean)
+
+ toggle.addEventListener("click", (e) => {
+ e.preventDefault()
+ const isOpen = openClasses.some(cls => drawer.classList.contains(cls))
+
+ if (isOpen) {
+ openClasses.forEach(cls => drawer.classList.remove(cls))
+ closedClasses.forEach(cls => drawer.classList.add(cls))
+ } else {
+ closedClasses.forEach(cls => drawer.classList.remove(cls))
+ openClasses.forEach(cls => drawer.classList.add(cls))
+ }
+ })
+}
+
+/**
+ * Set up modal close buttons
+ */
+function setupModalCloseButtons() {
+ document.querySelectorAll(SELECTORS.closeModal).forEach((button) => {
+ button.addEventListener("click", () => {
+ closeModal(button.closest(".modal"))
+ })
+ })
+}
+
+/**
+ * Set up widget image modal for viewing images in widgets
+ */
+function setupWidgetImageModal(container, modal) {
+ if (!modal) return
+
+ const imageContent = modal.querySelector(SELECTORS.widgetImageContent)
+ if (!imageContent) return
+
+ // Find all widget image triggers
+ container.querySelectorAll(SELECTORS.openWidgetImage).forEach((trigger) => {
+ trigger.addEventListener("click", (e) => {
+ e.preventDefault()
+
+ const imageUrl = trigger.dataset.imageUrl
+ const imageAlt = trigger.dataset.imageAlt || "Widget image"
+
+ if (imageUrl) {
+ imageContent.src = imageUrl
+ imageContent.alt = imageAlt
+ openModal(modal)
+ }
+ })
+ })
+
+ // Close on Escape key
+ document.addEventListener("keydown", (e) => {
+ if (e.key === "Escape" && modal.classList.contains("modal-open")) {
+ closeModal(modal)
+ }
+ })
+}
+
+/**
+ * Set up followers modal
+ */
+function setupFollowersModal(container, modal, handle) {
+ const button = container.querySelector(SELECTORS.showFollowers)
+ if (!button || !modal) return
+
+ button.addEventListener("click", async (e) => {
+ e.preventDefault()
+ openModal(modal)
+
+ try {
+ const data = await fetchJson(`/profiles/${handle}/followers`)
+ if (!data) return
+
+ renderFollowList(
+ modal.querySelector(SELECTORS.followersList),
+ modal.querySelector(SELECTORS.followersEmpty),
+ data.followers || []
+ )
+ } catch (error) {
+ console.error("Failed to load followers:", error)
+ }
+ })
+}
+
+/**
+ * Set up following modal
+ */
+function setupFollowingModal(container, modal, handle) {
+ const button = container.querySelector(SELECTORS.showFollowing)
+ if (!button || !modal) return
+
+ button.addEventListener("click", async (e) => {
+ e.preventDefault()
+ openModal(modal)
+
+ try {
+ const data = await fetchJson(`/profiles/${handle}/following`)
+ if (!data) return
+
+ renderFollowList(
+ modal.querySelector(SELECTORS.followingList),
+ modal.querySelector(SELECTORS.followingEmpty),
+ data.following || []
+ )
+ } catch (error) {
+ console.error("Failed to load following:", error)
+ }
+ })
+}
+
+/**
+ * Set up share modal and copy functionality
+ */
+function setupShareModal(container, modal) {
+ const shareButton = container.querySelector(SELECTORS.showShareModal)
+ if (shareButton && modal) {
+ shareButton.addEventListener("click", (e) => {
+ e.preventDefault()
+ openModal(modal)
+ })
+ }
+
+ const copyButton = document.querySelector(SELECTORS.copyProfileUrl)
+ if (!copyButton) return
+
+ const originalHTML = copyButton.innerHTML
+
+ copyButton.addEventListener("click", async (e) => {
+ e.preventDefault()
+ const input = document.querySelector(SELECTORS.profileShareUrl)
+ const url = input?.value
+ if (!url) return
+
+ try {
+ await navigator.clipboard.writeText(url)
+ showCopySuccess(copyButton, originalHTML)
+ } catch (_) {
+ // Fallback for older browsers
+ if (input) {
+ input.select()
+ document.execCommand("copy")
+ showCopySuccess(copyButton, originalHTML)
+ }
+ }
+ })
+}
+
+/**
+ * Set up follow/unfollow button
+ */
+function setupFollowButton(container, handle, accentColor) {
+ const button = container.querySelector(SELECTORS.toggleFollow)
+ if (!button) return
+
+ const followersButton = container.querySelector(SELECTORS.showFollowers)
+ const currentUserId = container.dataset.currentUserId
+ const profileUserId = container.dataset.profileUserId
+
+ // Set initial state
+ updateFollowButton(button, button.dataset.following === "true", accentColor)
+
+ button.addEventListener("click", async (e) => {
+ e.preventDefault()
+ const isFollowing = button.dataset.following === "true"
+ const method = isFollowing ? "DELETE" : "POST"
+
+ try {
+ const result = await apiRequest(`/profiles/${handle}/follow`, method)
+ if (!result) return
+
+ const nextIsFollowing =
+ result.status === "followed" ? true : result.status === "unfollowed" ? false : !isFollowing
+
+ updateFollowButton(button, nextIsFollowing, accentColor)
+
+ // Update follower count display
+ const countEl = followersButton?.querySelector("span.font-bold")
+ if (countEl && currentUserId !== profileUserId) {
+ const count = parseInt(countEl.textContent, 10)
+ if (!Number.isNaN(count)) {
+ if (nextIsFollowing !== isFollowing) {
+ countEl.textContent = nextIsFollowing ? count + 1 : count - 1
+ }
+ }
+ }
+ } catch (error) {
+ if (method === "POST" && /has already been taken/i.test(error.message)) {
+ updateFollowButton(button, true, accentColor)
+ return
+ }
+
+ console.error("Follow action failed:", error)
+ }
+ })
+}
+
+/**
+ * Set up friend action buttons (add friend, accept, cancel, unfriend)
+ */
+function setupFriendActions(container, handle) {
+ FRIEND_ACTIONS.forEach(({ selector, method, endpoint }) => {
+ const button = container.querySelector(selector)
+ if (!button) return
+
+ button.addEventListener("click", async (e) => {
+ e.preventDefault()
+
+ try {
+ const result = await apiRequest(`/profiles/${handle}/${endpoint}`, method)
+ if (!result) return
+ window.location.reload()
+ } catch (error) {
+ console.error("Friend action failed:", error)
+ }
+ })
+ })
+}
+
+// =============================================================================
+// Main Initialization
+// =============================================================================
+
+/**
+ * Initialize all static profile functionality
+ *
+ * This function is called on page load for static profile pages.
+ * It sets up all event handlers and initializes hooks that would
+ * normally be handled by LiveView.
+ */
+export function initProfileStatic() {
+ const container = document.querySelector(SELECTORS.container)
+ if (!container) return
+
+ // Skip if this is a LiveView page or already initialized
+ if (container.dataset.profileStatic === "false") return
+ if (container.dataset.profileStaticInit === "true") return
+ container.dataset.profileStaticInit = "true"
+
+ // Initialize profile hooks (typewriter effects, video background)
+ initProfileHooks()
+
+ // Get profile data from container attributes
+ const handle = container.dataset.profileHandle
+ const accentColor = container.dataset.profileAccent || "#22d3ee"
+
+ // Get modals (they're outside the container for z-index reasons)
+ const followersModal = document.querySelector(SELECTORS.followersModal)
+ const followingModal = document.querySelector(SELECTORS.followingModal)
+ const shareModal = document.querySelector(SELECTORS.shareModal)
+ const widgetImageModal = document.querySelector(SELECTORS.widgetImageModal)
+
+ // Set up all interactive features
+ setupTimelineDrawer(container)
+ setupModalCloseButtons()
+ setupFollowersModal(container, followersModal, handle)
+ setupFollowingModal(container, followingModal, handle)
+ setupShareModal(container, shareModal)
+ setupWidgetImageModal(container, widgetImageModal)
+ setupFollowButton(container, handle, accentColor)
+ setupFriendActions(container, handle)
+}
diff --git a/apps/elektrine/assets/js/ringtone_player.js b/apps/elektrine/assets/js/ringtone_player.js
new file mode 100644
index 0000000..fecd727
--- /dev/null
+++ b/apps/elektrine/assets/js/ringtone_player.js
@@ -0,0 +1,225 @@
+/**
+ * Ringtone Player for Skype-like call sounds
+ * Generates ringtones using Web Audio API
+ */
+
+export class RingtonePlayer {
+ constructor() {
+ this.audioContext = null
+ this.oscillator = null
+ this.gainNode = null
+ this.isPlaying = false
+ this.ringtoneInterval = null
+ }
+
+ /**
+ * Initialize Audio Context (must be called after user interaction)
+ */
+ init() {
+ if (!this.audioContext) {
+ this.audioContext = new (window.AudioContext || window.webkitAudioContext)()
+ }
+ }
+
+ /**
+ * Play outgoing call ringtone (Skype-like beep-beep pattern)
+ */
+ playOutgoing() {
+ this.stop() // Stop any current ringtone
+ this.init()
+ this.isPlaying = true
+
+ const playTone = () => {
+ if (!this.isPlaying) return
+
+ // Create oscillator for the tone
+ this.oscillator = this.audioContext.createOscillator()
+ this.gainNode = this.audioContext.createGain()
+
+ this.oscillator.connect(this.gainNode)
+ this.gainNode.connect(this.audioContext.destination)
+
+ // Skype outgoing call tone: 440Hz
+ this.oscillator.frequency.value = 440
+ this.oscillator.type = 'sine'
+
+ // Volume
+ this.gainNode.gain.value = 0.2
+
+ // Play pattern: beep (400ms) - pause (200ms) - beep (400ms) - pause (3000ms)
+ const now = this.audioContext.currentTime
+
+ // First beep
+ this.oscillator.start(now)
+ this.gainNode.gain.setValueAtTime(0.2, now)
+ this.gainNode.gain.exponentialRampToValueAtTime(0.01, now + 0.4)
+
+ // Stop after pattern completes
+ this.oscillator.stop(now + 0.4)
+
+ // Schedule second beep
+ setTimeout(() => {
+ if (!this.isPlaying) return
+
+ const osc2 = this.audioContext.createOscillator()
+ const gain2 = this.audioContext.createGain()
+
+ osc2.connect(gain2)
+ gain2.connect(this.audioContext.destination)
+
+ osc2.frequency.value = 440
+ osc2.type = 'sine'
+
+ const now2 = this.audioContext.currentTime
+ osc2.start(now2)
+ gain2.gain.setValueAtTime(0.2, now2)
+ gain2.gain.exponentialRampToValueAtTime(0.01, now2 + 0.4)
+ osc2.stop(now2 + 0.4)
+ }, 600)
+ }
+
+ // Play immediately then repeat every 4 seconds
+ playTone()
+ this.ringtoneInterval = setInterval(playTone, 4000)
+ }
+
+ /**
+ * Play incoming call ringtone (Skype-like ring pattern)
+ */
+ playIncoming() {
+ this.stop() // Stop any current ringtone
+ this.init()
+ this.isPlaying = true
+
+ const playRing = () => {
+ if (!this.isPlaying) return
+
+ // Create two-tone ring (like a phone ring)
+ const playDualTone = (startTime) => {
+ // High frequency
+ const osc1 = this.audioContext.createOscillator()
+ const gain1 = this.audioContext.createGain()
+ osc1.connect(gain1)
+ gain1.connect(this.audioContext.destination)
+ osc1.frequency.value = 480
+ osc1.type = 'sine'
+ gain1.gain.value = 0.15
+
+ // Low frequency
+ const osc2 = this.audioContext.createOscillator()
+ const gain2 = this.audioContext.createGain()
+ osc2.connect(gain2)
+ gain2.connect(this.audioContext.destination)
+ osc2.frequency.value = 440
+ osc2.type = 'sine'
+ gain2.gain.value = 0.15
+
+ osc1.start(startTime)
+ osc2.start(startTime)
+ osc1.stop(startTime + 1)
+ osc2.stop(startTime + 1)
+ }
+
+ const now = this.audioContext.currentTime
+
+ // Ring pattern: ring (1s) - pause (0.5s) - ring (1s) - pause (3s)
+ playDualTone(now)
+ setTimeout(() => {
+ if (this.isPlaying) {
+ playDualTone(this.audioContext.currentTime)
+ }
+ }, 1500)
+ }
+
+ // Play immediately then repeat every 5.5 seconds
+ playRing()
+ this.ringtoneInterval = setInterval(playRing, 5500)
+ }
+
+ /**
+ * Play call connected sound (single short beep)
+ */
+ playConnected() {
+ this.init()
+
+ const osc = this.audioContext.createOscillator()
+ const gain = this.audioContext.createGain()
+
+ osc.connect(gain)
+ gain.connect(this.audioContext.destination)
+
+ osc.frequency.value = 800
+ osc.type = 'sine'
+
+ const now = this.audioContext.currentTime
+ osc.start(now)
+ gain.gain.setValueAtTime(0.2, now)
+ gain.gain.exponentialRampToValueAtTime(0.01, now + 0.2)
+ osc.stop(now + 0.2)
+ }
+
+ /**
+ * Play call ended sound (descending tone)
+ */
+ playEnded() {
+ this.stop()
+ this.init()
+
+ const osc = this.audioContext.createOscillator()
+ const gain = this.audioContext.createGain()
+
+ osc.connect(gain)
+ gain.connect(this.audioContext.destination)
+
+ osc.type = 'sine'
+
+ const now = this.audioContext.currentTime
+ osc.frequency.setValueAtTime(600, now)
+ osc.frequency.exponentialRampToValueAtTime(300, now + 0.5)
+
+ gain.gain.setValueAtTime(0.2, now)
+ gain.gain.exponentialRampToValueAtTime(0.01, now + 0.5)
+
+ osc.start(now)
+ osc.stop(now + 0.5)
+ }
+
+ /**
+ * Stop all ringtones
+ */
+ stop() {
+ this.isPlaying = false
+
+ if (this.ringtoneInterval) {
+ clearInterval(this.ringtoneInterval)
+ this.ringtoneInterval = null
+ }
+
+ if (this.oscillator) {
+ try {
+ this.oscillator.stop()
+ } catch (e) {
+ // Already stopped
+ }
+ this.oscillator = null
+ }
+
+ if (this.gainNode) {
+ this.gainNode = null
+ }
+ }
+
+ /**
+ * Cleanup
+ */
+ destroy() {
+ this.stop()
+ if (this.audioContext) {
+ this.audioContext.close()
+ this.audioContext = null
+ }
+ }
+}
+
+// Create a global instance
+export const globalRingtone = new RingtonePlayer()
diff --git a/apps/elektrine/assets/js/tagline_cycler.js b/apps/elektrine/assets/js/tagline_cycler.js
new file mode 100644
index 0000000..db333ce
--- /dev/null
+++ b/apps/elektrine/assets/js/tagline_cycler.js
@@ -0,0 +1,24 @@
+// Cycles through taglines on the homepage
+export function initTaglineCycler() {
+ const taglineElements = document.querySelectorAll('[data-taglines]')
+
+ taglineElements.forEach(element => {
+ const taglines = JSON.parse(element.getAttribute('data-taglines'))
+ let currentIndex = 0
+
+ // Cycle through taglines every 4 seconds
+ setInterval(() => {
+ currentIndex = (currentIndex + 1) % taglines.length
+
+ // Fade out
+ element.style.transition = 'opacity 0.3s ease-out'
+ element.style.opacity = '0'
+
+ // Change text and fade in
+ setTimeout(() => {
+ element.textContent = taglines[currentIndex]
+ element.style.opacity = '1'
+ }, 300)
+ }, 4000)
+ })
+}
diff --git a/apps/elektrine/assets/js/theme.js b/apps/elektrine/assets/js/theme.js
new file mode 100644
index 0000000..c829580
--- /dev/null
+++ b/apps/elektrine/assets/js/theme.js
@@ -0,0 +1,171 @@
+const STORAGE_KEY = "elektrine:theme"
+const VALID_THEMES = new Set(["light", "dark"])
+const VALID_MODES = new Set(["system", "light", "dark", "custom"])
+const THEME_OVERRIDE_PROPERTIES = [
+ "--theme-override-color-primary",
+ "--theme-override-color-primary-content",
+ "--theme-override-color-secondary",
+ "--theme-override-color-secondary-content",
+ "--theme-override-color-accent",
+ "--theme-override-color-accent-content",
+ "--theme-override-color-base-100",
+ "--theme-override-color-base-200",
+ "--theme-override-color-base-300",
+ "--theme-override-color-base-content",
+ "--theme-override-color-info",
+ "--theme-override-color-info-content",
+ "--theme-override-color-success",
+ "--theme-override-color-success-content",
+ "--theme-override-color-warning",
+ "--theme-override-color-warning-content",
+ "--theme-override-color-error",
+ "--theme-override-color-error-content"
+]
+
+function currentTheme() {
+ const theme = document.documentElement.dataset.theme
+ return VALID_THEMES.has(theme) ? theme : "light"
+}
+
+// The server-stored mode for signed-in users; absent for anonymous visitors,
+// whose light/dark choice lives in localStorage instead.
+function themeMode() {
+ const mode = document.documentElement.dataset.themeMode
+ return VALID_MODES.has(mode) ? mode : null
+}
+
+function storedTheme() {
+ try {
+ const theme = window.localStorage.getItem(STORAGE_KEY)
+ return VALID_THEMES.has(theme) ? theme : null
+ } catch (_error) {
+ return null
+ }
+}
+
+function systemTheme() {
+ return window.matchMedia?.("(prefers-color-scheme: light)")?.matches ? "light" : "dark"
+}
+
+function resolveTheme() {
+ const mode = themeMode()
+ if (mode === "light" || mode === "dark") return mode
+ if (mode === "custom") return currentTheme()
+ if (mode === "system") return systemTheme()
+ return storedTheme() || systemTheme()
+}
+
+function syncThemeColor() {
+ const themeColor = document.querySelector('meta[name="theme-color"]')
+ if (!themeColor) return
+
+ const baseColor = getComputedStyle(document.documentElement)
+ .getPropertyValue("--color-base-100")
+ .trim()
+
+ if (baseColor) themeColor.setAttribute("content", baseColor)
+}
+
+export function syncThemeControls() {
+ const theme = currentTheme()
+ const nextTheme = theme === "light" ? "dark" : "light"
+ const label = `Use ${nextTheme} theme`
+
+ document.querySelectorAll("[data-theme-toggle]").forEach((button) => {
+ button.setAttribute("aria-label", label)
+ button.setAttribute("title", label)
+ })
+
+ window.requestAnimationFrame(syncThemeColor)
+}
+
+function applyTheme(theme, persist = false) {
+ const resolvedTheme = VALID_THEMES.has(theme) ? theme : "light"
+ document.documentElement.dataset.theme = resolvedTheme
+
+ if (persist) {
+ try {
+ window.localStorage.setItem(STORAGE_KEY, resolvedTheme)
+ } catch (_error) {
+ // The selected theme still applies for this page when storage is unavailable.
+ }
+ }
+
+ syncThemeControls()
+ window.dispatchEvent(
+ new CustomEvent("elektrine:theme-changed", { detail: { theme: resolvedTheme } })
+ )
+}
+
+function persistThemeMode(mode) {
+ const csrfToken =
+ document.querySelector("meta[name='csrf-token']")?.getAttribute("content") || ""
+
+ fetch("/api/preferences/theme", {
+ method: "PUT",
+ headers: { "content-type": "application/json", "x-csrf-token": csrfToken },
+ body: JSON.stringify({ mode })
+ }).catch(() => {
+ // The theme still applies for this page; the preference syncs on the next save.
+ })
+}
+
+function clearOverrideProperties() {
+ THEME_OVERRIDE_PROPERTIES.forEach((property) => {
+ document.documentElement.style.removeProperty(property)
+ })
+}
+
+function applyThemeSettings({ style = "", mode = "system", theme = null } = {}) {
+ const root = document.documentElement
+ const parsedStyle = document.createElement("div").style
+ parsedStyle.cssText = typeof style === "string" ? style : ""
+
+ clearOverrideProperties()
+
+ THEME_OVERRIDE_PROPERTIES.forEach((property) => {
+ const value = parsedStyle.getPropertyValue(property).trim()
+ if (value) root.style.setProperty(property, value)
+ })
+
+ root.dataset.themeMode = VALID_MODES.has(mode) ? mode : "system"
+ applyTheme(VALID_THEMES.has(theme) ? theme : resolveTheme())
+}
+
+let initialized = false
+
+export function initThemeToggle() {
+ syncThemeControls()
+ if (initialized) return
+
+ initialized = true
+
+ window.addEventListener("phx:apply-theme-settings", (event) => {
+ applyThemeSettings(event.detail)
+ })
+
+ const colorScheme = window.matchMedia?.("(prefers-color-scheme: light)")
+ colorScheme?.addEventListener?.("change", () => {
+ const mode = themeMode()
+ if (mode === "system" || (!mode && !storedTheme())) applyTheme(systemTheme())
+ })
+
+ document.addEventListener("click", (event) => {
+ const toggle = event.target.closest("[data-theme-toggle]")
+ if (!toggle) return
+
+ event.preventDefault()
+ const nextTheme = currentTheme() === "light" ? "dark" : "light"
+
+ if (themeMode()) {
+ // Signed in: the toggle pins an explicit day/night mode server-side,
+ // moving off system or custom.
+ if (themeMode() === "custom") clearOverrideProperties()
+ document.documentElement.dataset.themeMode = nextTheme
+ applyTheme(nextTheme)
+ persistThemeMode(nextTheme)
+ } else {
+ applyTheme(nextTheme, true)
+ }
+ })
+}
diff --git a/apps/elektrine/assets/js/utils/clipboard.js b/apps/elektrine/assets/js/utils/clipboard.js
new file mode 100644
index 0000000..a0accf6
--- /dev/null
+++ b/apps/elektrine/assets/js/utils/clipboard.js
@@ -0,0 +1,152 @@
+/**
+ * Clipboard Utilities
+ * Handles copy to clipboard operations
+ */
+
+function coerceClipboardText(text) {
+ if (text === null || text === undefined) return ''
+ return typeof text === 'string' ? text : String(text)
+}
+
+function fallbackCopyToClipboard(text) {
+ let textarea = null
+ try {
+ textarea = document.createElement('textarea')
+ textarea.value = text
+ textarea.setAttribute('readonly', '')
+ textarea.style.position = 'fixed'
+ textarea.style.top = '-9999px'
+ textarea.style.left = '-9999px'
+ document.body.appendChild(textarea)
+ textarea.select()
+ textarea.setSelectionRange(0, textarea.value.length)
+ return document.execCommand('copy')
+ } catch (_err) {
+ return false
+ } finally {
+ if (textarea && textarea.parentNode) {
+ textarea.parentNode.removeChild(textarea)
+ }
+ }
+}
+
+/**
+ * Copy text to clipboard with notification.
+ *
+ * `announce: false` suppresses the success toast for callers whose success is
+ * already announced elsewhere — a LiveView that pairs `push_event` with a
+ * `put_flash` would otherwise raise two notifications for one click. Failure is
+ * always announced: an optimistic flash claims the copy worked, so nothing else
+ * would tell the reader when it did not.
+ */
+export function copyToClipboard(text, type = 'link', { announce = true } = {}) {
+ const textToCopy = coerceClipboardText(text)
+ const showSuccess = () => {
+ if (!announce) return
+
+ const message =
+ type === 'message'
+ ? 'Message copied to clipboard'
+ : type === 'selection'
+ ? 'Selection copied to clipboard'
+ : type === 'email'
+ ? 'Email address copied to clipboard'
+ : 'Link copied to clipboard'
+ if (window.showNotification) {
+ window.showNotification(message, 'info', 'Success!')
+ }
+ }
+ const showFailure = (err) => {
+ console.error('Failed to copy text to clipboard:', err)
+ if (window.showNotification) {
+ window.showNotification('Failed to copy to clipboard', 'error', 'Error!')
+ }
+ }
+
+ const canUseClipboardApi =
+ !!navigator.clipboard &&
+ typeof navigator.clipboard.writeText === 'function' &&
+ window.isSecureContext
+
+ if (canUseClipboardApi) {
+ return navigator.clipboard.writeText(textToCopy).then(() => {
+ showSuccess()
+ return true
+ }).catch(err => {
+ if (fallbackCopyToClipboard(textToCopy)) {
+ showSuccess()
+ return true
+ }
+ showFailure(err)
+ return false
+ })
+ }
+
+ if (fallbackCopyToClipboard(textToCopy)) {
+ showSuccess()
+ return Promise.resolve(true)
+ } else {
+ showFailure(new Error('Clipboard API unavailable and fallback copy failed'))
+ return Promise.resolve(false)
+ }
+}
+
+/**
+ * Open URL in new window
+ */
+export function openUrl(url) {
+ if (url) {
+ window.open(url, '_blank', 'noopener,noreferrer')
+ }
+}
+
+/**
+ * Resolve what a copy trigger wants copied.
+ *
+ * Triggers carry the text inline (`data-copy`), name another element to read
+ * it from (`data-copy-target`), or leave both off and fall back to the page's
+ * primary address. The `data-content`/`data-email` spellings are what the
+ * markup already used before the copy hooks were replaced.
+ */
+function resolveCopyText(el) {
+ if (!(el instanceof Element)) return ''
+
+ const inline = el.dataset.copy ?? el.dataset.content ?? el.dataset.email
+ if (inline) return inline
+
+ if (el.dataset.copyTarget) {
+ const target = document.getElementById(el.dataset.copyTarget)
+ if (!target) return ''
+
+ return 'value' in target && typeof target.value === 'string'
+ ? target.value
+ : target.textContent
+ }
+
+ return document.getElementById('email-address')?.textContent ?? ''
+}
+
+/**
+ * Initialize clipboard event handlers
+ */
+export function initClipboardHandlers() {
+ // Server-initiated: every LiveView that pushes this also puts a flash saying
+ // the same thing, so the copy happens quietly and the flash does the talking.
+ window.addEventListener("phx:copy_to_clipboard", (e) => {
+ copyToClipboard(e.detail.text, e.detail.type, { announce: false })
+ })
+
+ // Raised by `Elektrine.Components.CopyButton.copy/2`. One listener replaces
+ // the per-element CopyEmail/CopyToClipboard/CopyButton hooks — the success
+ // flash is a JS.transition in the markup, so nothing here holds a timer.
+ window.addEventListener("phx:copy", (e) => {
+ const trigger = e.target instanceof Element ? e.target : null
+ const text = e.detail?.text || resolveCopyText(trigger)
+
+ if (text) copyToClipboard(text, trigger?.dataset?.copyType || 'link')
+ })
+
+ window.addEventListener("phx:open_url", (e) => {
+ openUrl(e.detail.url)
+ })
+}
diff --git a/apps/elektrine/assets/js/utils/dropdown.js b/apps/elektrine/assets/js/utils/dropdown.js
new file mode 100644
index 0000000..50a14db
--- /dev/null
+++ b/apps/elektrine/assets/js/utils/dropdown.js
@@ -0,0 +1,49 @@
+/**
+ * Dropdown Management Utilities
+ * Handles closing dropdowns on various events
+ */
+
+/**
+ * Close all open dropdowns
+ */
+export function closeAllDropdowns() {
+ document.querySelectorAll('.dropdown').forEach(dropdown => {
+ dropdown.blur()
+ })
+ document.querySelectorAll('.dropdown [tabindex="0"]').forEach(trigger => {
+ trigger.blur()
+ const activeElement = trigger.querySelector(':focus')
+ if (activeElement) {
+ activeElement.blur()
+ }
+ })
+}
+
+/**
+ * Handle clicks that should close dropdowns
+ */
+function handleDropdownClick(e) {
+ const clickedDropdown = e.target.closest(".dropdown")
+ const clickedDropdownContent = e.target.closest(".dropdown-content")
+
+ // Keep dropdowns open when the trigger itself is clicked.
+ if (clickedDropdown && !clickedDropdownContent) {
+ return
+ }
+
+ if (e.target.closest("[phx-click]")) {
+ setTimeout(closeAllDropdowns, 50)
+ }
+}
+
+/**
+ * Initialize dropdown management
+ */
+export function initDropdownManagement() {
+ // Close dropdowns on phx-click
+ document.addEventListener("click", handleDropdownClick)
+
+ // Close dropdowns when window loses/regains focus
+ window.addEventListener("blur", closeAllDropdowns)
+ window.addEventListener("focus", closeAllDropdowns)
+}
diff --git a/apps/elektrine/assets/js/utils/email_selection.js b/apps/elektrine/assets/js/utils/email_selection.js
new file mode 100644
index 0000000..49a1cc5
--- /dev/null
+++ b/apps/elektrine/assets/js/utils/email_selection.js
@@ -0,0 +1,79 @@
+/**
+ * Email Selection Utilities
+ * Handles checkbox updates, keyboard shortcuts, and shift+click for message selection
+ */
+
+/**
+ * Update message checkboxes based on selection state
+ */
+function updateCheckboxes({ selected_ids, select_all }) {
+ const checkboxes = document.querySelectorAll('[id^="message-checkbox-"]')
+ checkboxes.forEach(checkbox => {
+ const messageId = parseInt(checkbox.id.replace('message-checkbox-', ''))
+ const isSelected = select_all || selected_ids.includes(messageId)
+
+ checkbox.checked = isSelected
+
+ const messageCard = document.getElementById(`message-${messageId}`)
+ if (messageCard) {
+ messageCard.classList.toggle('message-selected', isSelected)
+ }
+ })
+}
+
+/**
+ * Handle keyboard shortcuts for message selection
+ */
+function handleSelectionKeyboard(e) {
+ // Only handle shortcuts when not typing in an input
+ if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return
+
+ // Ctrl+A or Cmd+A - Select all messages on page
+ if ((e.ctrlKey || e.metaKey) && e.key === 'a') {
+ e.preventDefault()
+ const selectAllBtn = document.querySelector('[phx-click="select_all_messages"]')
+ if (selectAllBtn) selectAllBtn.click()
+ }
+
+ // Escape - Clear selection
+ if (e.key === 'Escape') {
+ const clearBtn = document.querySelector('[phx-click="deselect_all_messages"]')
+ if (clearBtn) clearBtn.click()
+ }
+}
+
+/**
+ * Handle shift+click and checkbox clicks for message selection
+ */
+function handleSelectionClick(e) {
+ const messageCard = e.target.closest('.message-card[data-message-id]')
+ if (messageCard && e.shiftKey) {
+ e.preventDefault()
+ const messageId = messageCard.getAttribute('data-message-id')
+ const checkbox = document.getElementById(`message-checkbox-${messageId}`)
+ if (checkbox) {
+ checkbox.click()
+ }
+ return
+ }
+
+ // Handle individual checkbox clicks for immediate visual feedback
+ if (e.target.type === 'checkbox' && e.target.id.startsWith('message-checkbox-')) {
+ const messageId = e.target.id.replace('message-checkbox-', '')
+ const messageCard = document.getElementById(`message-${messageId}`)
+ if (messageCard) {
+ setTimeout(() => {
+ messageCard.classList.toggle('message-selected', e.target.checked)
+ }, 50)
+ }
+ }
+}
+
+/**
+ * Initialize email selection handlers
+ */
+export function initEmailSelection() {
+ window.addEventListener("phx:update_checkboxes", (e) => updateCheckboxes(e.detail))
+ document.addEventListener('keydown', handleSelectionKeyboard)
+ document.addEventListener('click', handleSelectionClick)
+}
diff --git a/apps/elektrine/assets/js/utils/form_submission.js b/apps/elektrine/assets/js/utils/form_submission.js
new file mode 100644
index 0000000..6436244
--- /dev/null
+++ b/apps/elektrine/assets/js/utils/form_submission.js
@@ -0,0 +1,19 @@
+export function submitFormPreservingEvents(form, submitter = null) {
+ if (!form) return
+
+ if (typeof form.requestSubmit === 'function') {
+ if (submitter instanceof HTMLElement) {
+ form.requestSubmit(submitter)
+ } else {
+ form.requestSubmit()
+ }
+ return
+ }
+
+ const transientSubmitter = document.createElement('button')
+ transientSubmitter.type = 'submit'
+ transientSubmitter.hidden = true
+ form.appendChild(transientSubmitter)
+ transientSubmitter.click()
+ transientSubmitter.remove()
+}
diff --git a/apps/elektrine/assets/js/utils/index.js b/apps/elektrine/assets/js/utils/index.js
new file mode 100644
index 0000000..2c94954
--- /dev/null
+++ b/apps/elektrine/assets/js/utils/index.js
@@ -0,0 +1,9 @@
+/**
+ * Utility modules index
+ * Central export for all utility functions
+ */
+
+export { initEmailSelection } from './email_selection'
+export { initDropdownManagement, closeAllDropdowns } from './dropdown'
+export { initClipboardHandlers, copyToClipboard, openUrl } from './clipboard'
+export { spinnerSvg } from './spinner'
diff --git a/apps/elektrine/assets/js/utils/overlay_portal.js b/apps/elektrine/assets/js/utils/overlay_portal.js
new file mode 100644
index 0000000..462fd3a
--- /dev/null
+++ b/apps/elektrine/assets/js/utils/overlay_portal.js
@@ -0,0 +1,85 @@
+export class OverlayPortal {
+ constructor(element, options = {}) {
+ this.element = element;
+ this.originParent = element?.parentNode || null;
+ this.originNextSibling = element?.nextSibling || null;
+ this.portalRoot = options.portalRoot || this.defaultPortalRoot();
+ }
+
+ mount() {
+ if (!this.element || !this.portalRoot || this.element.parentNode === this.portalRoot) return;
+
+ this.portalRoot.appendChild(this.element);
+ }
+
+ defaultPortalRoot() {
+ return this.element?.closest?.("[data-phx-main]") || document.body;
+ }
+
+ restore() {
+ if (!this.element || !this.originParent || this.element.parentNode === this.originParent) {
+ return;
+ }
+
+ this.clearPosition();
+
+ if (this.originNextSibling && this.originNextSibling.parentNode === this.originParent) {
+ this.originParent.insertBefore(this.element, this.originNextSibling);
+ } else {
+ this.originParent.appendChild(this.element);
+ }
+ }
+
+ positionNear(trigger, options = {}) {
+ if (!this.element || !trigger) return;
+
+ const margin = options.margin ?? 8;
+ const zIndex = options.zIndex ?? 10000;
+ const triggerRect = trigger.getBoundingClientRect();
+ const elementRect = this.element.getBoundingClientRect();
+ const maxLeft = Math.max(margin, window.innerWidth - elementRect.width - margin);
+ const align = options.align || "start";
+ const leftAnchor =
+ align === "end"
+ ? triggerRect.right - elementRect.width
+ : align === "center"
+ ? triggerRect.left + triggerRect.width / 2 - elementRect.width / 2
+ : triggerRect.left;
+ const left = Math.min(Math.max(leftAnchor, margin), maxLeft);
+ const placement = options.placement || "auto";
+ const belowTop = triggerRect.bottom + margin;
+ const aboveTop = triggerRect.top - elementRect.height - margin;
+ const top =
+ placement === "top"
+ ? aboveTop >= margin
+ ? aboveTop
+ : belowTop
+ : placement === "bottom"
+ ? belowTop + elementRect.height <= window.innerHeight - margin
+ ? belowTop
+ : Math.max(margin, aboveTop)
+ : belowTop + elementRect.height <= window.innerHeight - margin
+ ? belowTop
+ : Math.max(margin, aboveTop);
+
+ Object.assign(this.element.style, {
+ position: "fixed",
+ left: `${left}px`,
+ top: `${top}px`,
+ right: "auto",
+ bottom: "auto",
+ zIndex: String(zIndex),
+ });
+ }
+
+ clearPosition() {
+ if (!this.element) return;
+
+ this.element.style.removeProperty("position");
+ this.element.style.removeProperty("left");
+ this.element.style.removeProperty("top");
+ this.element.style.removeProperty("right");
+ this.element.style.removeProperty("bottom");
+ this.element.style.removeProperty("z-index");
+ }
+}
diff --git a/apps/elektrine/assets/js/utils/spinner.js b/apps/elektrine/assets/js/utils/spinner.js
new file mode 100644
index 0000000..220a34b
--- /dev/null
+++ b/apps/elektrine/assets/js/utils/spinner.js
@@ -0,0 +1,20 @@
+const SPINNER_PATH =
+ 'M20.27,4.74a4.93,4.93,0,0,1,1.52,4.61,5.32,5.32,0,0,1-4.1,4.51,5.12,5.12,0,0,1-5.2-1.5,5.53,5.53,0,0,0,6.13-1.48A5.66,5.66,0,0,0,20.27,4.74ZM12.32,11.53a5.49,5.49,0,0,0-1.47-6.2A5.57,5.57,0,0,0,4.71,3.72,5.17,5.17,0,0,1,9.53,2.2,5.52,5.52,0,0,1,13.9,6.45,5.28,5.28,0,0,1,12.32,11.53ZM19.2,20.29a4.92,4.92,0,0,1-4.72,1.49,5.32,5.32,0,0,1-4.34-4.05A5.2,5.2,0,0,1,11.6,12.5a5.6,5.6,0,0,0,1.51,6.13A5.63,5.63,0,0,0,19.2,20.29ZM3.79,19.38A5.18,5.18,0,0,1,2.32,14a5.3,5.3,0,0,1,4.59-4,5,5,0,0,1,4.58,1.61,5.55,5.55,0,0,0-6.32,1.69A5.46,5.46,0,0,0,3.79,19.38ZM12.23,12a5.11,5.11,0,0,0,3.66-5,5.75,5.75,0,0,0-3.18-6,5,5,0,0,1,4.42,2.3,5.21,5.21,0,0,1,.24,5.92A5.4,5.4,0,0,1,12.23,12ZM11.76,12a5.18,5.18,0,0,0-3.68,5.09,5.58,5.58,0,0,0,3.19,5.79c-1,.35-2.9-.46-4-1.68A5.51,5.51,0,0,1,11.76,12ZM23,12.63a5.07,5.07,0,0,1-2.35,4.52,5.23,5.23,0,0,1-5.91.2,5.24,5.24,0,0,1-2.67-4.77,5.51,5.51,0,0,0,5.45,3.33A5.52,5.52,0,0,0,23,12.63ZM1,11.23a5,5,0,0,1,2.49-4.5,5.23,5.23,0,0,1,5.81-.06,5.3,5.3,0,0,1,2.61,4.74A5.56,5.56,0,0,0,6.56,8.06,5.71,5.71,0,0,0,1,11.23Z'
+
+const SPINNER_SIZE_CLASSES = {
+ xs: 'w-3 h-3',
+ sm: 'w-4 h-4',
+ md: 'w-5 h-5',
+ lg: 'w-8 h-8'
+}
+
+export function spinnerSvg({ size = 'md', className = '' } = {}) {
+ const sizeClasses = SPINNER_SIZE_CLASSES[size] || SPINNER_SIZE_CLASSES.md
+ const classes = ['animate-spin', sizeClasses, className].filter(Boolean).join(' ')
+
+ return `
+
+
+
+ `
+}
diff --git a/apps/elektrine/assets/js/utils/user_hover_card_scroll.js b/apps/elektrine/assets/js/utils/user_hover_card_scroll.js
new file mode 100644
index 0000000..fe00a91
--- /dev/null
+++ b/apps/elektrine/assets/js/utils/user_hover_card_scroll.js
@@ -0,0 +1,26 @@
+const hooks = new Set();
+let listenersInstalled = false;
+
+function dismissForScroll() {
+ hooks.forEach((hook) => hook.dismissForScroll?.());
+}
+
+function ensureScrollListeners() {
+ if (listenersInstalled || typeof window === "undefined") return;
+
+ listenersInstalled = true;
+ // js-check: allow-global-listener-singleton
+ window.addEventListener("scroll", dismissForScroll, {
+ capture: true,
+ passive: true,
+ });
+}
+
+export function registerUserHoverCardHook(hook) {
+ hooks.add(hook);
+ ensureScrollListeners();
+}
+
+export function unregisterUserHoverCardHook(hook) {
+ hooks.delete(hook);
+}
diff --git a/apps/elektrine/assets/js/webrtc_client.js b/apps/elektrine/assets/js/webrtc_client.js
new file mode 100644
index 0000000..771da77
--- /dev/null
+++ b/apps/elektrine/assets/js/webrtc_client.js
@@ -0,0 +1,638 @@
+/**
+ * WebRTC Client for peer-to-peer audio/video calling
+ * Handles signaling, media, recovery, and cleanup.
+ */
+
+const CHANNEL_JOIN_DELAY_MS = 100
+const ICE_RESTART_DELAY_MS = 1200
+const MAX_ICE_RESTART_ATTEMPTS = 2
+
+function newClientSessionId() {
+ if (typeof crypto !== "undefined" && crypto.randomUUID) {
+ return crypto.randomUUID()
+ }
+
+ return `${Date.now()}-${Math.random().toString(36).slice(2)}`
+}
+
+export class WebRTCClient {
+ constructor(socket, callId, userId, iceServers, transport = null) {
+ this.socket = socket
+ this.callId = callId
+ this.userId = userId
+ this.iceServers = iceServers || []
+ this.transport = transport || {}
+ this.clientSessionId = newClientSessionId()
+ this.channel = null
+ this.peerConnection = null
+ this.localStream = null
+ this.remoteStream = null
+ this.onRemoteStreamCallback = null
+ this.onCallEndedCallback = null
+ this.onConnectionEstablishedCallback = null
+ this.iceCandidateQueue = []
+ this.remoteDescriptionSet = false
+ this.pendingOffer = null
+ this.disposed = false
+ this.cleanupStarted = false
+ this.callEndedNotified = false
+ this.iceRestartAttempts = 0
+ this.connectionEstablishedNotified = false
+ this.iceRestartTimer = null
+ this.onlineHandler = null
+ this.offlineHandler = null
+ this.peerReady = false
+ this.shouldInitiateOnPeerReady = false
+ this.answerInProgress = false
+ this.handledSignals = new Set()
+ }
+
+ /**
+ * Initialize the call channel and set up event handlers.
+ */
+ async initialize() {
+ if (this.disposed) {
+ throw new Error("Client already cleaned up")
+ }
+
+ this.channel = this.socket.channel(`call:${this.callId}`, {
+ client_session_id: this.clientSessionId
+ })
+ this.setupChannelHandlers()
+ this.setupNetworkHandlers()
+
+ return new Promise((resolve, reject) => {
+ this.channel
+ .join()
+ .receive("ok", () => {
+ setTimeout(() => resolve(), CHANNEL_JOIN_DELAY_MS)
+ })
+ .receive("error", (resp) => {
+ reject(new Error(resp?.reason || "Failed to join call channel"))
+ })
+ .receive("timeout", () => {
+ reject(new Error("Call channel join timed out"))
+ })
+ })
+ }
+
+ /**
+ * Set up Phoenix Channel event handlers for signaling.
+ */
+ setupChannelHandlers() {
+ this.channel.on("peer_ready", async (payload = {}) => {
+ if (this.ignoreSignal("peer_ready", payload)) return
+
+ this.peerReady = true
+
+ if (this.shouldInitiateOnPeerReady && !this.pendingOffer) {
+ try {
+ await this.initiateOffer()
+ } catch (error) {
+ this.finishCall(`failed: ${error?.message || 'offer_failed'}`)
+ return
+ }
+ }
+
+ await this.flushPendingOffer()
+ })
+
+ this.channel.on("offer", async (payload = {}) => {
+ if (this.ignoreSignal("offer", payload)) return
+
+ try {
+ await this.handleOffer(payload.sdp)
+ } catch (error) {
+ this.finishCall(`failed: ${error?.message || 'offer_handling_failed'}`)
+ }
+ })
+
+ this.channel.on("answer", async (payload = {}) => {
+ if (this.ignoreSignal("answer", payload)) return
+
+ try {
+ await this.handleAnswer(payload.sdp)
+ } catch (error) {
+ this.finishCall(`failed: ${error?.message || 'answer_handling_failed'}`)
+ }
+ })
+
+ this.channel.on("ice_candidate", async (payload = {}) => {
+ if (this.ignoreSignal("ice_candidate", payload)) return
+
+ try {
+ await this.handleIceCandidate(payload.candidate)
+ } catch (_error) {
+ // Ignore malformed candidates from remote peer.
+ }
+ })
+
+ this.channel.on("call_rejected", (payload = {}) => {
+ if (!this.ignoreSignal("call_rejected", payload)) this.finishCall("rejected")
+ })
+ this.channel.on("call_ended", (payload = {}) => {
+ if (!this.ignoreSignal("call_ended", payload)) this.finishCall(payload.reason || "ended")
+ })
+ this.channel.on("call_missed", (payload = {}) => {
+ if (!this.ignoreSignal("call_missed", payload)) this.finishCall("missed")
+ })
+ }
+
+ ignoreSignal(event, payload = {}) {
+ if (payload.from_client_session_id && payload.from_client_session_id === this.clientSessionId) {
+ return true
+ }
+
+ const key = `${event}:${payload.from_user_id || ''}:${payload.from_client_session_id || ''}:${payload.signal_id || ''}:${payload.sdp?.type || ''}:${payload.candidate?.candidate || ''}`
+
+ if (this.handledSignals.has(key)) {
+ return true
+ }
+
+ this.handledSignals.add(key)
+ return false
+ }
+
+ pushSignal(event, payload = {}) {
+ if (this.channel && this.channel.state === "joined") {
+ return this.channel.push(event, {
+ ...payload,
+ client_session_id: this.clientSessionId
+ })
+ }
+
+ return null
+ }
+
+ setupNetworkHandlers() {
+ if (this.onlineHandler || this.offlineHandler || typeof window === "undefined") {
+ return
+ }
+
+ this.onlineHandler = () => {
+ if (!this.disposed && this.peerConnection && this.remoteDescriptionSet) {
+ this.scheduleIceRestart("network_recovered")
+ }
+ }
+
+ this.offlineHandler = () => {
+ if (!this.disposed) {
+ this.finishCall("failed: network_offline")
+ }
+ }
+
+ window.addEventListener("online", this.onlineHandler)
+ window.addEventListener("offline", this.offlineHandler)
+ }
+
+ teardownNetworkHandlers() {
+ if (typeof window === "undefined") {
+ return
+ }
+
+ if (this.onlineHandler) {
+ window.removeEventListener("online", this.onlineHandler)
+ this.onlineHandler = null
+ }
+
+ if (this.offlineHandler) {
+ window.removeEventListener("offline", this.offlineHandler)
+ this.offlineHandler = null
+ }
+ }
+
+ /**
+ * Create a new RTCPeerConnection.
+ */
+ createPeerConnection() {
+ if (this.peerConnection) {
+ return this.peerConnection
+ }
+
+ this.peerConnection = new RTCPeerConnection({ iceServers: this.iceServers })
+ this.connectionEstablishedNotified = false
+
+ this.peerConnection.onicecandidate = (event) => {
+ if (event.candidate && this.channel && this.channel.state === "joined") {
+ this.pushSignal("ice_candidate", { candidate: event.candidate })
+ }
+ }
+
+ this.peerConnection.ontrack = (event) => {
+ if (!this.remoteStream) {
+ this.remoteStream = new MediaStream()
+ }
+ this.remoteStream.addTrack(event.track)
+
+ if (this.onRemoteStreamCallback) {
+ this.onRemoteStreamCallback(this.remoteStream)
+ }
+ }
+
+ this.peerConnection.onconnectionstatechange = () => {
+ if (!this.peerConnection) {
+ return
+ }
+
+ const state = this.peerConnection.connectionState
+
+ if (state === "connected") {
+ this.iceRestartAttempts = 0
+ if (!this.connectionEstablishedNotified && this.onConnectionEstablishedCallback) {
+ this.connectionEstablishedNotified = true
+ this.onConnectionEstablishedCallback()
+ }
+ }
+
+ if (state === "disconnected") {
+ this.scheduleIceRestart("disconnected")
+ }
+
+ if (state === "failed") {
+ this.scheduleIceRestart("failed")
+ }
+ }
+
+ this.peerConnection.oniceconnectionstatechange = () => {
+ if (!this.peerConnection) {
+ return
+ }
+
+ const state = this.peerConnection.iceConnectionState
+ if (state === "disconnected" || state === "failed") {
+ this.scheduleIceRestart(state)
+ }
+ }
+
+ return this.peerConnection
+ }
+
+ /**
+ * Start a call as caller (create offer).
+ */
+ async startCall(callType = "video") {
+ if (this.disposed) {
+ throw new Error("Client already cleaned up")
+ }
+
+ await this.prepareLocalPeer(callType)
+ this.shouldInitiateOnPeerReady = true
+ await this.initiateOffer()
+ }
+
+ async resumeCall(callType = "video", initiator = false) {
+ if (this.disposed) {
+ throw new Error("Client already cleaned up")
+ }
+
+ await this.prepareLocalPeer(callType)
+ this.shouldInitiateOnPeerReady = initiator
+
+ if (this.channel && this.channel.state === "joined") {
+ this.pushSignal("ready_to_receive")
+ }
+ }
+
+ /**
+ * Handle incoming offer (callee).
+ */
+ async handleOffer(offer) {
+ if (!offer || typeof offer.sdp !== "string") {
+ return
+ }
+
+ const callType = offer.sdp.includes("m=video") ? "video" : "audio"
+ await this.getUserMedia(callType)
+
+ const peer = this.createPeerConnection()
+ this.attachLocalTracks(peer)
+
+ await peer.setRemoteDescription(new RTCSessionDescription(offer))
+ this.remoteDescriptionSet = true
+ await this.processIceCandidateQueue()
+
+ const answer = await peer.createAnswer()
+ await peer.setLocalDescription(answer)
+
+ this.pushSignal("answer", { sdp: answer })
+ }
+
+ /**
+ * Handle incoming answer (caller).
+ */
+ async handleAnswer(answer) {
+ if (!this.peerConnection || !answer) {
+ return
+ }
+
+ if (this.answerInProgress || this.remoteDescriptionSet || this.peerConnection.signalingState !== "have-local-offer") {
+ return
+ }
+
+ this.answerInProgress = true
+
+ try {
+ await this.peerConnection.setRemoteDescription(new RTCSessionDescription(answer))
+ this.remoteDescriptionSet = true
+ await this.processIceCandidateQueue()
+ } finally {
+ this.answerInProgress = false
+ }
+ }
+
+ /**
+ * Handle incoming ICE candidate.
+ */
+ async handleIceCandidate(candidate) {
+ if (!candidate) {
+ return
+ }
+
+ if (this.remoteDescriptionSet && this.peerConnection) {
+ await this.peerConnection.addIceCandidate(new RTCIceCandidate(candidate))
+ return
+ }
+
+ this.iceCandidateQueue.push(candidate)
+ }
+
+ /**
+ * Process queued ICE candidates.
+ */
+ async processIceCandidateQueue() {
+ if (!this.peerConnection) {
+ this.iceCandidateQueue = []
+ return
+ }
+
+ for (const candidate of this.iceCandidateQueue) {
+ try {
+ await this.peerConnection.addIceCandidate(new RTCIceCandidate(candidate))
+ } catch (_error) {
+ // Ignore stale/invalid candidate entries from race conditions.
+ }
+ }
+
+ this.iceCandidateQueue = []
+ }
+
+ /**
+ * Get user media (camera/microphone).
+ */
+ async getUserMedia(callType) {
+ if (this.localStream) {
+ return this.localStream
+ }
+
+ const constraints = {
+ audio: {
+ echoCancellation: true,
+ noiseSuppression: true,
+ autoGainControl: true,
+ sampleRate: 48000,
+ channelCount: 1
+ },
+ video:
+ callType === "video"
+ ? {
+ width: { ideal: 1280 },
+ height: { ideal: 720 },
+ facingMode: "user"
+ }
+ : false
+ }
+
+ this.localStream = await navigator.mediaDevices.getUserMedia(constraints)
+ return this.localStream
+ }
+
+ attachLocalTracks(peerConnection) {
+ if (!this.localStream || !peerConnection) {
+ return
+ }
+
+ const existingTrackIds = new Set(
+ peerConnection
+ .getSenders()
+ .map((sender) => sender.track?.id)
+ .filter(Boolean)
+ )
+
+ this.localStream.getTracks().forEach((track) => {
+ if (!existingTrackIds.has(track.id)) {
+ peerConnection.addTrack(track, this.localStream)
+ }
+ })
+ }
+
+ async flushPendingOffer() {
+ if (!this.pendingOffer || !this.peerReady || !this.channel || this.channel.state !== "joined") {
+ return
+ }
+
+ this.pushSignal("offer", { sdp: this.pendingOffer })
+ this.pendingOffer = null
+ }
+
+ scheduleIceRestart(_reason) {
+ if (this.disposed || this.cleanupStarted || !this.peerConnection || !this.remoteDescriptionSet) {
+ return
+ }
+
+ if (this.iceRestartAttempts >= MAX_ICE_RESTART_ATTEMPTS) {
+ this.finishCall("failed: ice_restart_exhausted")
+ return
+ }
+
+ if (this.iceRestartTimer) {
+ return
+ }
+
+ this.iceRestartTimer = window.setTimeout(async () => {
+ this.iceRestartTimer = null
+ await this.restartIce()
+ }, ICE_RESTART_DELAY_MS)
+ }
+
+ async restartIce() {
+ if (
+ this.disposed ||
+ this.cleanupStarted ||
+ !this.peerConnection ||
+ !this.channel ||
+ this.channel.state !== "joined"
+ ) {
+ return
+ }
+
+ try {
+ this.iceRestartAttempts += 1
+ const offer = await this.peerConnection.createOffer({ iceRestart: true })
+ await this.peerConnection.setLocalDescription(offer)
+ this.pushSignal("offer", { sdp: offer })
+ } catch (_error) {
+ if (this.iceRestartAttempts >= MAX_ICE_RESTART_ATTEMPTS) {
+ this.finishCall(`failed: ${_error?.message || 'ice_restart_failed'}`)
+ }
+ }
+ }
+
+ /**
+ * Toggle video track.
+ */
+ toggleVideo() {
+ if (this.localStream) {
+ const videoTrack = this.localStream.getVideoTracks()[0]
+ if (videoTrack) {
+ videoTrack.enabled = !videoTrack.enabled
+ return videoTrack.enabled
+ }
+ }
+ return false
+ }
+
+ /**
+ * Toggle audio track.
+ */
+ toggleAudio() {
+ if (this.localStream) {
+ const audioTrack = this.localStream.getAudioTracks()[0]
+ if (audioTrack) {
+ audioTrack.enabled = !audioTrack.enabled
+ return audioTrack.enabled
+ }
+ }
+ return false
+ }
+
+ /**
+ * End the call.
+ */
+ endCall() {
+ if (this.channel && this.channel.state === "joined") {
+ this.pushSignal("end_call")
+ }
+ this.cleanup()
+ }
+
+ /**
+ * Reject the call.
+ */
+ rejectCall() {
+ if (this.channel && this.channel.state === "joined") {
+ this.pushSignal("reject_call")
+ }
+ this.cleanup()
+ }
+
+ finishCall(reason) {
+ this.cleanup()
+
+ if (!this.callEndedNotified && this.onCallEndedCallback) {
+ this.callEndedNotified = true
+ this.onCallEndedCallback(reason || "ended")
+ }
+ }
+
+ /**
+ * Clean up resources.
+ */
+ cleanup() {
+ if (this.cleanupStarted) {
+ return
+ }
+
+ this.cleanupStarted = true
+ this.disposed = true
+
+ if (this.iceRestartTimer) {
+ clearTimeout(this.iceRestartTimer)
+ this.iceRestartTimer = null
+ }
+
+ this.teardownNetworkHandlers()
+
+ if (this.localStream) {
+ this.localStream.getTracks().forEach((track) => track.stop())
+ this.localStream = null
+ }
+
+ if (this.peerConnection) {
+ this.peerConnection.onicecandidate = null
+ this.peerConnection.ontrack = null
+ this.peerConnection.onconnectionstatechange = null
+ this.peerConnection.oniceconnectionstatechange = null
+ this.peerConnection.close()
+ this.peerConnection = null
+ }
+
+ if (this.channel) {
+ this.channel.leave()
+ this.channel = null
+ }
+
+ if (this.socket) {
+ this.socket.disconnect()
+ }
+
+ this.remoteStream = null
+ this.pendingOffer = null
+ this.iceCandidateQueue = []
+ this.remoteDescriptionSet = false
+ this.peerReady = false
+ this.shouldInitiateOnPeerReady = false
+ this.answerInProgress = false
+ this.handledSignals.clear()
+ }
+
+ /**
+ * Set callback for when remote stream is received.
+ */
+ onRemoteStream(callback) {
+ this.onRemoteStreamCallback = callback
+ }
+
+ /**
+ * Set callback for when call ends.
+ */
+ onCallEnded(callback) {
+ this.onCallEndedCallback = callback
+ }
+
+ /**
+ * Set callback for when connection is established.
+ */
+ onConnectionEstablished(callback) {
+ this.onConnectionEstablishedCallback = callback
+ }
+
+ /**
+ * Get local stream.
+ */
+ getLocalStream() {
+ return this.localStream
+ }
+
+ /**
+ * Get remote stream.
+ */
+ getRemoteStream() {
+ return this.remoteStream
+ }
+
+ async prepareLocalPeer(callType) {
+ await this.getUserMedia(callType)
+
+ const peer = this.createPeerConnection()
+ this.attachLocalTracks(peer)
+ return peer
+ }
+
+ async initiateOffer() {
+ const peer = this.createPeerConnection()
+ const offer = await peer.createOffer()
+ await peer.setLocalDescription(offer)
+ this.pendingOffer = offer
+ await this.flushPendingOffer()
+ }
+}
diff --git a/apps/elektrine/assets/package-lock.json b/apps/elektrine/assets/package-lock.json
new file mode 100644
index 0000000..e6ba61f
--- /dev/null
+++ b/apps/elektrine/assets/package-lock.json
@@ -0,0 +1,1204 @@
+{
+ "name": "elektrine",
+ "version": "1.0.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "elektrine",
+ "version": "1.0.0",
+ "dependencies": {
+ "@noble/hashes": "^2.2.0",
+ "daisyui": "^5.6.18",
+ "uplot": "^1.6.32"
+ },
+ "devDependencies": {
+ "@tailwindcss/cli": "^4.3.3",
+ "tailwindcss": "^4.3.3"
+ }
+ },
+ "node_modules/@jridgewell/gen-mapping": {
+ "version": "0.3.13",
+ "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
+ "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.5.0",
+ "@jridgewell/trace-mapping": "^0.3.24"
+ }
+ },
+ "node_modules/@jridgewell/remapping": {
+ "version": "2.3.5",
+ "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
+ "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/gen-mapping": "^0.3.5",
+ "@jridgewell/trace-mapping": "^0.3.24"
+ }
+ },
+ "node_modules/@jridgewell/resolve-uri": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
+ "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@jridgewell/sourcemap-codec": {
+ "version": "1.5.5",
+ "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
+ "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@jridgewell/trace-mapping": {
+ "version": "0.3.31",
+ "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
+ "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/resolve-uri": "^3.1.0",
+ "@jridgewell/sourcemap-codec": "^1.4.14"
+ }
+ },
+ "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/@parcel/watcher": {
+ "version": "2.5.1",
+ "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.1.tgz",
+ "integrity": "sha512-dfUnCxiN9H4ap84DvD2ubjw+3vUNpstxa0TneY/Paat8a3R4uQZDLSvWjmznAY/DoahqTHl9V46HF/Zs3F29pg==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "dependencies": {
+ "detect-libc": "^1.0.3",
+ "is-glob": "^4.0.3",
+ "micromatch": "^4.0.5",
+ "node-addon-api": "^7.0.0"
+ },
+ "engines": {
+ "node": ">= 10.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ },
+ "optionalDependencies": {
+ "@parcel/watcher-android-arm64": "2.5.1",
+ "@parcel/watcher-darwin-arm64": "2.5.1",
+ "@parcel/watcher-darwin-x64": "2.5.1",
+ "@parcel/watcher-freebsd-x64": "2.5.1",
+ "@parcel/watcher-linux-arm-glibc": "2.5.1",
+ "@parcel/watcher-linux-arm-musl": "2.5.1",
+ "@parcel/watcher-linux-arm64-glibc": "2.5.1",
+ "@parcel/watcher-linux-arm64-musl": "2.5.1",
+ "@parcel/watcher-linux-x64-glibc": "2.5.1",
+ "@parcel/watcher-linux-x64-musl": "2.5.1",
+ "@parcel/watcher-win32-arm64": "2.5.1",
+ "@parcel/watcher-win32-ia32": "2.5.1",
+ "@parcel/watcher-win32-x64": "2.5.1"
+ }
+ },
+ "node_modules/@parcel/watcher-android-arm64": {
+ "version": "2.5.1",
+ "resolved": "https://registry.npmjs.org/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.5.1.tgz",
+ "integrity": "sha512-KF8+j9nNbUN8vzOFDpRMsaKBHZ/mcjEjMToVMJOhTozkDonQFFrRcfdLWn6yWKCmJKmdVxSgHiYvTCef4/qcBA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">= 10.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/@parcel/watcher-darwin-arm64": {
+ "version": "2.5.1",
+ "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.5.1.tgz",
+ "integrity": "sha512-eAzPv5osDmZyBhou8PoF4i6RQXAfeKL9tjb3QzYuccXFMQU0ruIc/POh30ePnaOyD1UXdlKguHBmsTs53tVoPw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 10.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/@parcel/watcher-darwin-x64": {
+ "version": "2.5.1",
+ "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.5.1.tgz",
+ "integrity": "sha512-1ZXDthrnNmwv10A0/3AJNZ9JGlzrF82i3gNQcWOzd7nJ8aj+ILyW1MTxVk35Db0u91oD5Nlk9MBiujMlwmeXZg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 10.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/@parcel/watcher-freebsd-x64": {
+ "version": "2.5.1",
+ "resolved": "https://registry.npmjs.org/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.5.1.tgz",
+ "integrity": "sha512-SI4eljM7Flp9yPuKi8W0ird8TI/JK6CSxju3NojVI6BjHsTyK7zxA9urjVjEKJ5MBYC+bLmMcbAWlZ+rFkLpJQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">= 10.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/@parcel/watcher-linux-arm-glibc": {
+ "version": "2.5.1",
+ "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.5.1.tgz",
+ "integrity": "sha512-RCdZlEyTs8geyBkkcnPWvtXLY44BCeZKmGYRtSgtwwnHR4dxfHRG3gR99XdMEdQ7KeiDdasJwwvNSF5jKtDwdA==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/@parcel/watcher-linux-arm-musl": {
+ "version": "2.5.1",
+ "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.5.1.tgz",
+ "integrity": "sha512-6E+m/Mm1t1yhB8X412stiKFG3XykmgdIOqhjWj+VL8oHkKABfu/gjFj8DvLrYVHSBNC+/u5PeNrujiSQ1zwd1Q==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/@parcel/watcher-linux-arm64-glibc": {
+ "version": "2.5.1",
+ "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.5.1.tgz",
+ "integrity": "sha512-LrGp+f02yU3BN9A+DGuY3v3bmnFUggAITBGriZHUREfNEzZh/GO06FF5u2kx8x+GBEUYfyTGamol4j3m9ANe8w==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/@parcel/watcher-linux-arm64-musl": {
+ "version": "2.5.1",
+ "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.5.1.tgz",
+ "integrity": "sha512-cFOjABi92pMYRXS7AcQv9/M1YuKRw8SZniCDw0ssQb/noPkRzA+HBDkwmyOJYp5wXcsTrhxO0zq1U11cK9jsFg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/@parcel/watcher-linux-x64-glibc": {
+ "version": "2.5.1",
+ "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.5.1.tgz",
+ "integrity": "sha512-GcESn8NZySmfwlTsIur+49yDqSny2IhPeZfXunQi48DMugKeZ7uy1FX83pO0X22sHntJ4Ub+9k34XQCX+oHt2A==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/@parcel/watcher-linux-x64-musl": {
+ "version": "2.5.1",
+ "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.5.1.tgz",
+ "integrity": "sha512-n0E2EQbatQ3bXhcH2D1XIAANAcTZkQICBPVaxMeaCVBtOpBZpWJuf7LwyWPSBDITb7In8mqQgJ7gH8CILCURXg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/@parcel/watcher-win32-arm64": {
+ "version": "2.5.1",
+ "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.5.1.tgz",
+ "integrity": "sha512-RFzklRvmc3PkjKjry3hLF9wD7ppR4AKcWNzH7kXR7GUe0Igb3Nz8fyPwtZCSquGrhU5HhUNDr/mKBqj7tqA2Vw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 10.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/@parcel/watcher-win32-ia32": {
+ "version": "2.5.1",
+ "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-ia32/-/watcher-win32-ia32-2.5.1.tgz",
+ "integrity": "sha512-c2KkcVN+NJmuA7CGlaGD1qJh1cLfDnQsHjE89E60vUEMlqduHGCdCLJCID5geFVM0dOtA3ZiIO8BoEQmzQVfpQ==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 10.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/@parcel/watcher-win32-x64": {
+ "version": "2.5.1",
+ "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.5.1.tgz",
+ "integrity": "sha512-9lHBdJITeNR++EvSQVUcaZoWupyHfXe1jZvGZ06O/5MflPcuPLtEphScIBL+AiCWBO46tDSHzWyD0uDmmZqsgA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 10.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/@tailwindcss/cli": {
+ "version": "4.3.3",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/cli/-/cli-4.3.3.tgz",
+ "integrity": "sha512-ZvS/n1ZHOBKcVlhkt8l5NNr1EDXk1NboYO5CYDOs6NUmvT9z6bzkwsosaJftY57T/3gWNzWMJzIXLodZC8ssdw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@parcel/watcher": "2.5.1",
+ "@tailwindcss/node": "4.3.3",
+ "@tailwindcss/oxide": "4.3.3",
+ "enhanced-resolve": "^5.24.1",
+ "mri": "^1.2.0",
+ "picocolors": "^1.1.1",
+ "tailwindcss": "4.3.3"
+ },
+ "bin": {
+ "tailwindcss": "dist/index.mjs"
+ }
+ },
+ "node_modules/@tailwindcss/node": {
+ "version": "4.3.3",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.3.tgz",
+ "integrity": "sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/remapping": "^2.3.5",
+ "enhanced-resolve": "^5.24.1",
+ "jiti": "^2.7.0",
+ "lightningcss": "1.32.0",
+ "magic-string": "^0.30.21",
+ "source-map-js": "^1.2.1",
+ "tailwindcss": "4.3.3"
+ }
+ },
+ "node_modules/@tailwindcss/oxide": {
+ "version": "4.3.3",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.3.tgz",
+ "integrity": "sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 20"
+ },
+ "optionalDependencies": {
+ "@tailwindcss/oxide-android-arm64": "4.3.3",
+ "@tailwindcss/oxide-darwin-arm64": "4.3.3",
+ "@tailwindcss/oxide-darwin-x64": "4.3.3",
+ "@tailwindcss/oxide-freebsd-x64": "4.3.3",
+ "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.3",
+ "@tailwindcss/oxide-linux-arm64-gnu": "4.3.3",
+ "@tailwindcss/oxide-linux-arm64-musl": "4.3.3",
+ "@tailwindcss/oxide-linux-x64-gnu": "4.3.3",
+ "@tailwindcss/oxide-linux-x64-musl": "4.3.3",
+ "@tailwindcss/oxide-wasm32-wasi": "4.3.3",
+ "@tailwindcss/oxide-win32-arm64-msvc": "4.3.3",
+ "@tailwindcss/oxide-win32-x64-msvc": "4.3.3"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-android-arm64": {
+ "version": "4.3.3",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.3.tgz",
+ "integrity": "sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-darwin-arm64": {
+ "version": "4.3.3",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.3.tgz",
+ "integrity": "sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-darwin-x64": {
+ "version": "4.3.3",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.3.tgz",
+ "integrity": "sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-freebsd-x64": {
+ "version": "4.3.3",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.3.tgz",
+ "integrity": "sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": {
+ "version": "4.3.3",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.3.tgz",
+ "integrity": "sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-linux-arm64-gnu": {
+ "version": "4.3.3",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.3.tgz",
+ "integrity": "sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-linux-arm64-musl": {
+ "version": "4.3.3",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.3.tgz",
+ "integrity": "sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-linux-x64-gnu": {
+ "version": "4.3.3",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.3.tgz",
+ "integrity": "sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-linux-x64-musl": {
+ "version": "4.3.3",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.3.tgz",
+ "integrity": "sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-wasm32-wasi": {
+ "version": "4.3.3",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.3.tgz",
+ "integrity": "sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ==",
+ "bundleDependencies": [
+ "@napi-rs/wasm-runtime",
+ "@emnapi/core",
+ "@emnapi/runtime",
+ "@tybys/wasm-util",
+ "@emnapi/wasi-threads",
+ "tslib"
+ ],
+ "cpu": [
+ "wasm32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@emnapi/core": "^1.11.1",
+ "@emnapi/runtime": "^1.11.1",
+ "@emnapi/wasi-threads": "^1.2.2",
+ "@napi-rs/wasm-runtime": "^1.1.4",
+ "@tybys/wasm-util": "^0.10.2",
+ "tslib": "^2.8.1"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
+ "version": "4.3.3",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.3.tgz",
+ "integrity": "sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-win32-x64-msvc": {
+ "version": "4.3.3",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.3.tgz",
+ "integrity": "sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/braces": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
+ "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fill-range": "^7.1.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/daisyui": {
+ "version": "5.6.18",
+ "resolved": "https://registry.npmjs.org/daisyui/-/daisyui-5.6.18.tgz",
+ "integrity": "sha512-6y9rboRQl8fosnusy/5pg11yp+H0LtK3YdtfS4Vf2QVnaYOKasNdYm6hDxeiT6bwZPdl4WRPzVIlIApdo2ssBg==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/saadeghi/daisyui?sponsor=1"
+ }
+ },
+ "node_modules/detect-libc": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-1.0.3.tgz",
+ "integrity": "sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "detect-libc": "bin/detect-libc.js"
+ },
+ "engines": {
+ "node": ">=0.10"
+ }
+ },
+ "node_modules/enhanced-resolve": {
+ "version": "5.24.2",
+ "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.24.2.tgz",
+ "integrity": "sha512-rpsZEGT1jFuve6QlpyRp9ckQ+kN61hvF9BzCPyMdaKTm8UJce96KBn3sorXOFXlzjPrs3Vc4T1NsSroZ3PxlFw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "graceful-fs": "^4.2.4",
+ "tapable": "^2.3.3"
+ },
+ "engines": {
+ "node": ">=10.13.0"
+ }
+ },
+ "node_modules/fill-range": {
+ "version": "7.1.1",
+ "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
+ "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "to-regex-range": "^5.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/graceful-fs": {
+ "version": "4.2.11",
+ "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
+ "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/is-extglob": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
+ "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/is-glob": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
+ "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "is-extglob": "^2.1.1"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/is-number": {
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
+ "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.12.0"
+ }
+ },
+ "node_modules/jiti": {
+ "version": "2.7.0",
+ "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz",
+ "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "jiti": "lib/jiti-cli.mjs"
+ }
+ },
+ "node_modules/lightningcss": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
+ "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==",
+ "dev": true,
+ "license": "MPL-2.0",
+ "dependencies": {
+ "detect-libc": "^2.0.3"
+ },
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ },
+ "optionalDependencies": {
+ "lightningcss-android-arm64": "1.32.0",
+ "lightningcss-darwin-arm64": "1.32.0",
+ "lightningcss-darwin-x64": "1.32.0",
+ "lightningcss-freebsd-x64": "1.32.0",
+ "lightningcss-linux-arm-gnueabihf": "1.32.0",
+ "lightningcss-linux-arm64-gnu": "1.32.0",
+ "lightningcss-linux-arm64-musl": "1.32.0",
+ "lightningcss-linux-x64-gnu": "1.32.0",
+ "lightningcss-linux-x64-musl": "1.32.0",
+ "lightningcss-win32-arm64-msvc": "1.32.0",
+ "lightningcss-win32-x64-msvc": "1.32.0"
+ }
+ },
+ "node_modules/lightningcss-android-arm64": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz",
+ "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-darwin-arm64": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz",
+ "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-darwin-x64": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz",
+ "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-freebsd-x64": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz",
+ "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm-gnueabihf": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz",
+ "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm64-gnu": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz",
+ "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm64-musl": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz",
+ "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-x64-gnu": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz",
+ "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-x64-musl": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz",
+ "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-win32-arm64-msvc": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz",
+ "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-win32-x64-msvc": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz",
+ "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss/node_modules/detect-libc": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
+ "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/magic-string": {
+ "version": "0.30.21",
+ "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
+ "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.5.5"
+ }
+ },
+ "node_modules/micromatch": {
+ "version": "4.0.8",
+ "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
+ "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "braces": "^3.0.3",
+ "picomatch": "^2.3.1"
+ },
+ "engines": {
+ "node": ">=8.6"
+ }
+ },
+ "node_modules/mri": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz",
+ "integrity": "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/node-addon-api": {
+ "version": "7.1.1",
+ "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz",
+ "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/picocolors": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
+ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/picomatch": {
+ "version": "2.3.2",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
+ "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8.6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/source-map-js": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
+ "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/tailwindcss": {
+ "version": "4.3.3",
+ "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.3.tgz",
+ "integrity": "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/tapable": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz",
+ "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/webpack"
+ }
+ },
+ "node_modules/to-regex-range": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
+ "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "is-number": "^7.0.0"
+ },
+ "engines": {
+ "node": ">=8.0"
+ }
+ },
+ "node_modules/uplot": {
+ "version": "1.6.32",
+ "resolved": "https://registry.npmjs.org/uplot/-/uplot-1.6.32.tgz",
+ "integrity": "sha512-KIMVnG68zvu5XXUbC4LQEPnhwOxBuLyW1AHtpm6IKTXImkbLgkMy+jabjLgSLMasNuGGzQm/ep3tOkyTxpiQIw==",
+ "license": "MIT"
+ }
+ }
+}
diff --git a/apps/elektrine/assets/package.json b/apps/elektrine/assets/package.json
new file mode 100644
index 0000000..7cfcdc4
--- /dev/null
+++ b/apps/elektrine/assets/package.json
@@ -0,0 +1,19 @@
+{
+ "name": "elektrine",
+ "version": "1.0.0",
+ "description": "Elektrine Phoenix application",
+ "main": "js/app.js",
+ "scripts": {
+ "deploy": "cd .. && mix assets.deploy && rm -f _build/esbuild*",
+ "check": "node scripts/check_js.mjs"
+ },
+ "dependencies": {
+ "@noble/hashes": "^2.2.0",
+ "daisyui": "^5.6.18",
+ "uplot": "^1.6.32"
+ },
+ "devDependencies": {
+ "@tailwindcss/cli": "^4.3.3",
+ "tailwindcss": "^4.3.3"
+ }
+}
diff --git a/apps/elektrine/assets/scripts/check_js.mjs b/apps/elektrine/assets/scripts/check_js.mjs
new file mode 100644
index 0000000..f87017d
--- /dev/null
+++ b/apps/elektrine/assets/scripts/check_js.mjs
@@ -0,0 +1,154 @@
+import { spawnSync } from "node:child_process";
+import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
+import { dirname, join, relative } from "node:path";
+import { fileURLToPath } from "node:url";
+
+const rootDir = dirname(dirname(fileURLToPath(import.meta.url)));
+const jsDir = join(rootDir, "js");
+
+const bannedPatterns = [
+ {
+ pattern: /\bdebugger\b/,
+ message: "debugger statements should not be committed",
+ },
+ {
+ pattern: /\bconsole\.log\s*\(/,
+ message: "use intentional console.warn/error or remove debug logging",
+ },
+ {
+ pattern: /(oklch|hsl)\(var\(--/,
+ message: "use public --color-* theme tokens instead of DaisyUI internals",
+ },
+ {
+ pattern: /fallback-b[1-3c]\b/,
+ message: "use public --color-* theme tokens instead of fallback base tokens",
+ },
+];
+
+const relativeImportPattern =
+ /(?:import|export)\s+(?:[\s\S]*?\s+from\s+)?["'](\.{1,2}\/[^"']+)["']/g;
+
+const lifecycleRules = [
+ {
+ resource: "setInterval",
+ acquire: /\bsetInterval\s*\(/g,
+ release: /\bclearInterval\s*\(/g,
+ message: "hooks that create intervals must clear them",
+ },
+ {
+ resource: "MutationObserver",
+ acquire: /\bnew\s+MutationObserver\s*\(/g,
+ release: /\.disconnect\s*\(/g,
+ message: "hooks that create MutationObserver instances must disconnect them",
+ },
+ {
+ resource: "ResizeObserver",
+ acquire: /\bnew\s+ResizeObserver\s*\(/g,
+ release: /\.disconnect\s*\(/g,
+ message: "hooks that create ResizeObserver instances must disconnect them",
+ },
+];
+
+function jsFiles(dir) {
+ return readdirSync(dir)
+ .flatMap((entry) => {
+ const path = join(dir, entry);
+ const stat = statSync(path);
+
+ if (stat.isDirectory()) return jsFiles(path);
+ if (entry.endsWith(".js")) return [path];
+ return [];
+ })
+ .sort();
+}
+
+function resolveRelativeImport(file, specifier) {
+ const basePath = join(dirname(file), specifier);
+ const candidates = [basePath, `${basePath}.js`, join(basePath, "index.js")];
+
+ return candidates.find((candidate) => existsSync(candidate));
+}
+
+function countMatches(source, pattern) {
+ return Array.from(source.matchAll(pattern)).length;
+}
+
+let failures = 0;
+
+for (const file of jsFiles(jsDir)) {
+ const displayPath = relative(rootDir, file);
+ const syntax = spawnSync(process.execPath, ["--check", file], {
+ encoding: "utf8",
+ });
+
+ if (syntax.status !== 0) {
+ failures += 1;
+ process.stderr.write(`error: ${displayPath} failed node --check\n`);
+ process.stderr.write(syntax.stderr || syntax.stdout);
+ continue;
+ }
+
+ const source = readFileSync(file, "utf8");
+ for (const { pattern, message } of bannedPatterns) {
+ if (!pattern.test(source)) continue;
+
+ failures += 1;
+ process.stderr.write(`error: ${displayPath}: ${message}\n`);
+ }
+
+ for (const match of source.matchAll(relativeImportPattern)) {
+ const specifier = match[1];
+
+ if (!resolveRelativeImport(file, specifier)) {
+ failures += 1;
+ process.stderr.write(`error: ${displayPath}: unresolved relative import ${specifier}\n`);
+ }
+ }
+
+ if (
+ displayPath.startsWith("js/hooks/") &&
+ source.includes("mounted()") &&
+ source.includes("addEventListener(") &&
+ !source.includes("destroyed()")
+ ) {
+ failures += 1;
+ process.stderr.write(
+ `error: ${displayPath}: hooks that attach event listeners in mounted() must define destroyed()\n`,
+ );
+ }
+
+ if (displayPath.startsWith("js/hooks/")) {
+ const globalListenerAdds = countMatches(
+ source,
+ /\b(?:window|document)\.addEventListener\s*\(/g,
+ );
+ const globalListenerRemoves = countMatches(
+ source,
+ /\b(?:window|document)\.removeEventListener\s*\(/g,
+ );
+ const allowsAppLifetimeListeners = source.includes(
+ "js-check: allow-global-listener-singleton",
+ );
+
+ if (globalListenerAdds > globalListenerRemoves && !allowsAppLifetimeListeners) {
+ failures += 1;
+ process.stderr.write(
+ `error: ${displayPath}: global window/document listeners must be removed or documented as an app-lifetime singleton\n`,
+ );
+ }
+
+ for (const { acquire, release, message } of lifecycleRules) {
+ if (countMatches(source, acquire) > 0 && countMatches(source, release) === 0) {
+ failures += 1;
+ process.stderr.write(`error: ${displayPath}: ${message}\n`);
+ }
+ }
+ }
+}
+
+if (failures > 0) {
+ process.stderr.write(`JavaScript check failed with ${failures} issue(s).\n`);
+ process.exit(1);
+}
+
+process.stdout.write("JavaScript check passed.\n");
diff --git a/apps/elektrine/assets/tailwind.config.mjs b/apps/elektrine/assets/tailwind.config.mjs
new file mode 100644
index 0000000..fb46b1d
--- /dev/null
+++ b/apps/elektrine/assets/tailwind.config.mjs
@@ -0,0 +1,90 @@
+// Tailwind CSS v4 configuration
+// https://tailwindcss.com/docs/configuration
+
+import plugin from "tailwindcss/plugin"
+import fs from "fs"
+import path from "path"
+import { fileURLToPath } from 'url'
+
+const __filename = fileURLToPath(import.meta.url)
+const __dirname = path.dirname(__filename)
+
+function resolveHeroiconsDir() {
+ const candidates = [
+ path.join(__dirname, "vendor/heroicons/optimized")
+ ]
+
+ const existing = candidates.find((candidate) => fs.existsSync(candidate))
+ if (!existing) {
+ throw new Error(`Heroicons directory not found. Checked: ${candidates.join(", ")}`)
+ }
+
+ return existing
+}
+
+export default {
+ content: [
+ "./js/**/*.js",
+ // Umbrella apps split UI templates and some icon definitions across app folders.
+ "../../*/lib/**/*.ex",
+ "../../*/lib/**/*.heex"
+ ],
+ theme: {
+ extend: {
+ colors: {
+ brand: "#FD4F00",
+ },
+ fontFamily: {
+ sans: ['Geist', 'ui-sans-serif', 'system-ui', '-apple-system', 'sans-serif'],
+ mono: ['Geist Mono', 'ui-monospace', 'SFMono-Regular', 'Menlo', 'monospace'],
+ pixel: ['Geist Pixel', 'Geist Mono', 'ui-monospace', 'monospace'],
+ }
+ },
+ },
+ plugins: [
+ // LiveView loading variants
+ plugin(({addVariant}) => addVariant("phx-click-loading", [".phx-click-loading&", ".phx-click-loading &"])),
+ plugin(({addVariant}) => addVariant("phx-submit-loading", [".phx-submit-loading&", ".phx-submit-loading &"])),
+ plugin(({addVariant}) => addVariant("phx-change-loading", [".phx-change-loading&", ".phx-change-loading &"])),
+
+ // Heroicons plugin
+ plugin(function({matchComponents, theme}) {
+ let iconsDir = resolveHeroiconsDir()
+ let values = {}
+ let icons = [
+ ["", "/24/outline"],
+ ["-solid", "/24/solid"],
+ ["-mini", "/20/solid"],
+ ["-micro", "/16/solid"]
+ ]
+ icons.forEach(([suffix, dir]) => {
+ fs.readdirSync(path.join(iconsDir, dir)).forEach(file => {
+ let name = path.basename(file, ".svg") + suffix
+ values[name] = {name, fullPath: path.join(iconsDir, dir, file)}
+ })
+ })
+ matchComponents({
+ "hero": ({name, fullPath}) => {
+ let content = fs.readFileSync(fullPath).toString().replace(/\r?\n|\r/g, "")
+ let size = theme("spacing.6")
+ if (name.endsWith("-mini")) {
+ size = theme("spacing.5")
+ } else if (name.endsWith("-micro")) {
+ size = theme("spacing.4")
+ }
+ return {
+ [`--hero-${name}`]: `url('data:image/svg+xml;utf8,${content}')`,
+ "-webkit-mask": `var(--hero-${name})`,
+ "mask": `var(--hero-${name})`,
+ "mask-repeat": "no-repeat",
+ "background-color": "currentColor",
+ "vertical-align": "middle",
+ "display": "inline-block",
+ "width": size,
+ "height": size
+ }
+ }
+ }, {values})
+ })
+ ]
+}
diff --git a/apps/elektrine/assets/vendor/heroicons/LICENSE b/apps/elektrine/assets/vendor/heroicons/LICENSE
new file mode 100644
index 0000000..d6a8229
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) Tailwind Labs, Inc.
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/apps/elektrine/assets/vendor/heroicons/SOURCE.md b/apps/elektrine/assets/vendor/heroicons/SOURCE.md
new file mode 100644
index 0000000..21472f9
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/SOURCE.md
@@ -0,0 +1,9 @@
+Vendored from Tailwind Labs Heroicons.
+
+- Source: https://github.com/tailwindlabs/heroicons
+- Version: v2.2.0
+- Commit: 0435d4ca364a608cc75e2f8683d374e55abbae26
+- License: MIT
+
+Only the `optimized` SVG directory is vendored here. The Tailwind config reads
+these static SVG files directly when generating `hero-*` icon classes.
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/academic-cap.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/academic-cap.svg
new file mode 100644
index 0000000..c66143a
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/academic-cap.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/adjustments-horizontal.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/adjustments-horizontal.svg
new file mode 100644
index 0000000..19f1fcc
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/adjustments-horizontal.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/adjustments-vertical.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/adjustments-vertical.svg
new file mode 100644
index 0000000..70f1f83
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/adjustments-vertical.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/archive-box-arrow-down.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/archive-box-arrow-down.svg
new file mode 100644
index 0000000..bf0ff06
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/archive-box-arrow-down.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/archive-box-x-mark.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/archive-box-x-mark.svg
new file mode 100644
index 0000000..869641d
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/archive-box-x-mark.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/archive-box.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/archive-box.svg
new file mode 100644
index 0000000..b6f42cb
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/archive-box.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-down-circle.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-down-circle.svg
new file mode 100644
index 0000000..8836f9b
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-down-circle.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-down-left.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-down-left.svg
new file mode 100644
index 0000000..f6ab16f
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-down-left.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-down-on-square-stack.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-down-on-square-stack.svg
new file mode 100644
index 0000000..2284806
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-down-on-square-stack.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-down-on-square.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-down-on-square.svg
new file mode 100644
index 0000000..801c244
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-down-on-square.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-down-right.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-down-right.svg
new file mode 100644
index 0000000..8a1d80a
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-down-right.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-down-tray.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-down-tray.svg
new file mode 100644
index 0000000..1ff676b
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-down-tray.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-down.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-down.svg
new file mode 100644
index 0000000..b90f11a
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-down.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-left-circle.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-left-circle.svg
new file mode 100644
index 0000000..5dd2ded
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-left-circle.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-left-end-on-rectangle.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-left-end-on-rectangle.svg
new file mode 100644
index 0000000..2fd5cce
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-left-end-on-rectangle.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-left-start-on-rectangle.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-left-start-on-rectangle.svg
new file mode 100644
index 0000000..89e7d5a
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-left-start-on-rectangle.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-left.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-left.svg
new file mode 100644
index 0000000..748b385
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-left.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-long-down.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-long-down.svg
new file mode 100644
index 0000000..364503c
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-long-down.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-long-left.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-long-left.svg
new file mode 100644
index 0000000..185924f
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-long-left.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-long-right.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-long-right.svg
new file mode 100644
index 0000000..40b5533
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-long-right.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-long-up.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-long-up.svg
new file mode 100644
index 0000000..535b7b7
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-long-up.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-path-rounded-square.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-path-rounded-square.svg
new file mode 100644
index 0000000..b698c36
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-path-rounded-square.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-path.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-path.svg
new file mode 100644
index 0000000..2d5cbe2
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-path.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-right-circle.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-right-circle.svg
new file mode 100644
index 0000000..e9d64e2
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-right-circle.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-right-end-on-rectangle.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-right-end-on-rectangle.svg
new file mode 100644
index 0000000..233ca17
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-right-end-on-rectangle.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-right-start-on-rectangle.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-right-start-on-rectangle.svg
new file mode 100644
index 0000000..14a3273
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-right-start-on-rectangle.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-right.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-right.svg
new file mode 100644
index 0000000..bc4ee02
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-right.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-top-right-on-square.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-top-right-on-square.svg
new file mode 100644
index 0000000..ef67401
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-top-right-on-square.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-trending-down.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-trending-down.svg
new file mode 100644
index 0000000..98dc7f9
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-trending-down.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-trending-up.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-trending-up.svg
new file mode 100644
index 0000000..77395a2
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-trending-up.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-turn-down-left.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-turn-down-left.svg
new file mode 100644
index 0000000..ca5f6f0
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-turn-down-left.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-turn-down-right.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-turn-down-right.svg
new file mode 100644
index 0000000..3051a7f
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-turn-down-right.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-turn-left-down.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-turn-left-down.svg
new file mode 100644
index 0000000..6e8cc84
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-turn-left-down.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-turn-left-up.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-turn-left-up.svg
new file mode 100644
index 0000000..e0d9b70
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-turn-left-up.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-turn-right-down.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-turn-right-down.svg
new file mode 100644
index 0000000..ba73dc7
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-turn-right-down.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-turn-right-up.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-turn-right-up.svg
new file mode 100644
index 0000000..517ef31
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-turn-right-up.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-turn-up-left.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-turn-up-left.svg
new file mode 100644
index 0000000..227b8e7
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-turn-up-left.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-turn-up-right.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-turn-up-right.svg
new file mode 100644
index 0000000..a9d9cf1
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-turn-up-right.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-up-circle.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-up-circle.svg
new file mode 100644
index 0000000..0385993
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-up-circle.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-up-left.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-up-left.svg
new file mode 100644
index 0000000..88cf7cf
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-up-left.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-up-on-square-stack.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-up-on-square-stack.svg
new file mode 100644
index 0000000..9e43afc
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-up-on-square-stack.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-up-on-square.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-up-on-square.svg
new file mode 100644
index 0000000..13bf43d
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-up-on-square.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-up-right.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-up-right.svg
new file mode 100644
index 0000000..dd7a8ef
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-up-right.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-up-tray.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-up-tray.svg
new file mode 100644
index 0000000..aecaa26
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-up-tray.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-up.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-up.svg
new file mode 100644
index 0000000..58f91a3
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-up.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-uturn-down.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-uturn-down.svg
new file mode 100644
index 0000000..2afee9f
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-uturn-down.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-uturn-left.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-uturn-left.svg
new file mode 100644
index 0000000..55f63dd
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-uturn-left.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-uturn-right.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-uturn-right.svg
new file mode 100644
index 0000000..8028a57
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-uturn-right.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-uturn-up.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-uturn-up.svg
new file mode 100644
index 0000000..7df0285
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrow-uturn-up.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrows-pointing-in.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrows-pointing-in.svg
new file mode 100644
index 0000000..29ea56b
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrows-pointing-in.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrows-pointing-out.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrows-pointing-out.svg
new file mode 100644
index 0000000..a9d9385
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrows-pointing-out.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrows-right-left.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrows-right-left.svg
new file mode 100644
index 0000000..9ba45af
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrows-right-left.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrows-up-down.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrows-up-down.svg
new file mode 100644
index 0000000..2cf5523
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/arrows-up-down.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/at-symbol.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/at-symbol.svg
new file mode 100644
index 0000000..11e99d8
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/at-symbol.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/backspace.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/backspace.svg
new file mode 100644
index 0000000..1c5f355
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/backspace.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/backward.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/backward.svg
new file mode 100644
index 0000000..0e7a49c
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/backward.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/banknotes.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/banknotes.svg
new file mode 100644
index 0000000..27ceef7
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/banknotes.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/bars-2.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/bars-2.svg
new file mode 100644
index 0000000..7a209e2
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/bars-2.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/bars-3-bottom-left.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/bars-3-bottom-left.svg
new file mode 100644
index 0000000..08ca32a
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/bars-3-bottom-left.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/bars-3-bottom-right.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/bars-3-bottom-right.svg
new file mode 100644
index 0000000..edee4c9
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/bars-3-bottom-right.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/bars-3-center-left.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/bars-3-center-left.svg
new file mode 100644
index 0000000..d2228f2
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/bars-3-center-left.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/bars-3.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/bars-3.svg
new file mode 100644
index 0000000..9a1136a
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/bars-3.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/bars-4.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/bars-4.svg
new file mode 100644
index 0000000..e1c529e
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/bars-4.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/bars-arrow-down.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/bars-arrow-down.svg
new file mode 100644
index 0000000..4d48bba
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/bars-arrow-down.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/bars-arrow-up.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/bars-arrow-up.svg
new file mode 100644
index 0000000..6d44246
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/bars-arrow-up.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/battery-0.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/battery-0.svg
new file mode 100644
index 0000000..8df1066
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/battery-0.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/battery-100.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/battery-100.svg
new file mode 100644
index 0000000..561cc78
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/battery-100.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/battery-50.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/battery-50.svg
new file mode 100644
index 0000000..ad4ef42
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/battery-50.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/beaker.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/beaker.svg
new file mode 100644
index 0000000..f8c9c3a
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/beaker.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/bell-alert.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/bell-alert.svg
new file mode 100644
index 0000000..189b8bc
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/bell-alert.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/bell-slash.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/bell-slash.svg
new file mode 100644
index 0000000..7fcf3c1
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/bell-slash.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/bell-snooze.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/bell-snooze.svg
new file mode 100644
index 0000000..0af5048
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/bell-snooze.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/bell.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/bell.svg
new file mode 100644
index 0000000..1b4f00f
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/bell.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/bold.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/bold.svg
new file mode 100644
index 0000000..25c3dbc
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/bold.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/bolt-slash.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/bolt-slash.svg
new file mode 100644
index 0000000..3f96c8e
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/bolt-slash.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/bolt.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/bolt.svg
new file mode 100644
index 0000000..e406e2f
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/bolt.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/book-open.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/book-open.svg
new file mode 100644
index 0000000..442db26
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/book-open.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/bookmark-slash.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/bookmark-slash.svg
new file mode 100644
index 0000000..b0b62d1
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/bookmark-slash.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/bookmark-square.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/bookmark-square.svg
new file mode 100644
index 0000000..8c4a3c8
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/bookmark-square.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/bookmark.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/bookmark.svg
new file mode 100644
index 0000000..9a8282a
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/bookmark.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/briefcase.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/briefcase.svg
new file mode 100644
index 0000000..96e74f1
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/briefcase.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/bug-ant.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/bug-ant.svg
new file mode 100644
index 0000000..bc7e73f
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/bug-ant.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/building-library.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/building-library.svg
new file mode 100644
index 0000000..496facf
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/building-library.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/building-office-2.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/building-office-2.svg
new file mode 100644
index 0000000..acd4621
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/building-office-2.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/building-office.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/building-office.svg
new file mode 100644
index 0000000..691dce8
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/building-office.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/building-storefront.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/building-storefront.svg
new file mode 100644
index 0000000..fc9c14b
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/building-storefront.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/cake.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/cake.svg
new file mode 100644
index 0000000..012026a
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/cake.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/calculator.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/calculator.svg
new file mode 100644
index 0000000..a3c3420
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/calculator.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/calendar-date-range.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/calendar-date-range.svg
new file mode 100644
index 0000000..3a086f8
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/calendar-date-range.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/calendar-days.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/calendar-days.svg
new file mode 100644
index 0000000..16c9890
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/calendar-days.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/calendar.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/calendar.svg
new file mode 100644
index 0000000..d8468cb
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/calendar.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/camera.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/camera.svg
new file mode 100644
index 0000000..0429f04
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/camera.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/chart-bar-square.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/chart-bar-square.svg
new file mode 100644
index 0000000..7e56212
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/chart-bar-square.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/chart-bar.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/chart-bar.svg
new file mode 100644
index 0000000..da7e5d0
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/chart-bar.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/chart-pie.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/chart-pie.svg
new file mode 100644
index 0000000..8d17490
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/chart-pie.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/chat-bubble-bottom-center-text.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/chat-bubble-bottom-center-text.svg
new file mode 100644
index 0000000..549eb6e
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/chat-bubble-bottom-center-text.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/chat-bubble-bottom-center.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/chat-bubble-bottom-center.svg
new file mode 100644
index 0000000..0f5f609
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/chat-bubble-bottom-center.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/chat-bubble-left-ellipsis.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/chat-bubble-left-ellipsis.svg
new file mode 100644
index 0000000..b7f2b84
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/chat-bubble-left-ellipsis.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/chat-bubble-left-right.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/chat-bubble-left-right.svg
new file mode 100644
index 0000000..d99e13b
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/chat-bubble-left-right.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/chat-bubble-left.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/chat-bubble-left.svg
new file mode 100644
index 0000000..5c9b47f
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/chat-bubble-left.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/chat-bubble-oval-left-ellipsis.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/chat-bubble-oval-left-ellipsis.svg
new file mode 100644
index 0000000..a61f6bd
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/chat-bubble-oval-left-ellipsis.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/chat-bubble-oval-left.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/chat-bubble-oval-left.svg
new file mode 100644
index 0000000..2cc7ce5
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/chat-bubble-oval-left.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/check-badge.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/check-badge.svg
new file mode 100644
index 0000000..cb96b01
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/check-badge.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/check-circle.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/check-circle.svg
new file mode 100644
index 0000000..5beeb13
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/check-circle.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/check.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/check.svg
new file mode 100644
index 0000000..0b7e9b3
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/check.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/chevron-double-down.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/chevron-double-down.svg
new file mode 100644
index 0000000..9d659c3
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/chevron-double-down.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/chevron-double-left.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/chevron-double-left.svg
new file mode 100644
index 0000000..3194c3f
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/chevron-double-left.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/chevron-double-right.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/chevron-double-right.svg
new file mode 100644
index 0000000..86a963a
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/chevron-double-right.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/chevron-double-up.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/chevron-double-up.svg
new file mode 100644
index 0000000..bcf7619
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/chevron-double-up.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/chevron-down.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/chevron-down.svg
new file mode 100644
index 0000000..f734a0c
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/chevron-down.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/chevron-left.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/chevron-left.svg
new file mode 100644
index 0000000..f293041
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/chevron-left.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/chevron-right.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/chevron-right.svg
new file mode 100644
index 0000000..3a08c80
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/chevron-right.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/chevron-up-down.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/chevron-up-down.svg
new file mode 100644
index 0000000..db44522
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/chevron-up-down.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/chevron-up.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/chevron-up.svg
new file mode 100644
index 0000000..58573a7
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/chevron-up.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/circle-stack.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/circle-stack.svg
new file mode 100644
index 0000000..3da8275
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/circle-stack.svg
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/clipboard-document-check.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/clipboard-document-check.svg
new file mode 100644
index 0000000..b8909e7
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/clipboard-document-check.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/clipboard-document-list.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/clipboard-document-list.svg
new file mode 100644
index 0000000..355dec2
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/clipboard-document-list.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/clipboard-document.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/clipboard-document.svg
new file mode 100644
index 0000000..6e09983
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/clipboard-document.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/clipboard.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/clipboard.svg
new file mode 100644
index 0000000..2447452
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/clipboard.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/clock.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/clock.svg
new file mode 100644
index 0000000..b808f55
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/clock.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/cloud-arrow-down.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/cloud-arrow-down.svg
new file mode 100644
index 0000000..478173d
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/cloud-arrow-down.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/cloud-arrow-up.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/cloud-arrow-up.svg
new file mode 100644
index 0000000..fcd0c96
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/cloud-arrow-up.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/cloud.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/cloud.svg
new file mode 100644
index 0000000..98a40cf
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/cloud.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/code-bracket-square.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/code-bracket-square.svg
new file mode 100644
index 0000000..9d59c9a
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/code-bracket-square.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/code-bracket.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/code-bracket.svg
new file mode 100644
index 0000000..48b64b1
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/code-bracket.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/cog-6-tooth.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/cog-6-tooth.svg
new file mode 100644
index 0000000..67d34e6
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/cog-6-tooth.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/cog-8-tooth.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/cog-8-tooth.svg
new file mode 100644
index 0000000..6d92cde
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/cog-8-tooth.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/cog.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/cog.svg
new file mode 100644
index 0000000..4a7ef24
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/cog.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/command-line.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/command-line.svg
new file mode 100644
index 0000000..aa4c164
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/command-line.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/computer-desktop.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/computer-desktop.svg
new file mode 100644
index 0000000..56f18f7
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/computer-desktop.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/cpu-chip.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/cpu-chip.svg
new file mode 100644
index 0000000..ee84795
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/cpu-chip.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/credit-card.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/credit-card.svg
new file mode 100644
index 0000000..d55fcd3
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/credit-card.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/cube-transparent.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/cube-transparent.svg
new file mode 100644
index 0000000..b3af3d6
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/cube-transparent.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/cube.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/cube.svg
new file mode 100644
index 0000000..a3c841d
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/cube.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/currency-bangladeshi.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/currency-bangladeshi.svg
new file mode 100644
index 0000000..c9b4056
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/currency-bangladeshi.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/currency-dollar.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/currency-dollar.svg
new file mode 100644
index 0000000..1d4c4ac
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/currency-dollar.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/currency-euro.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/currency-euro.svg
new file mode 100644
index 0000000..88b62da
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/currency-euro.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/currency-pound.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/currency-pound.svg
new file mode 100644
index 0000000..232af6e
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/currency-pound.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/currency-rupee.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/currency-rupee.svg
new file mode 100644
index 0000000..f79b4e7
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/currency-rupee.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/currency-yen.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/currency-yen.svg
new file mode 100644
index 0000000..e234e5f
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/currency-yen.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/cursor-arrow-rays.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/cursor-arrow-rays.svg
new file mode 100644
index 0000000..ff9f3d5
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/cursor-arrow-rays.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/cursor-arrow-ripple.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/cursor-arrow-ripple.svg
new file mode 100644
index 0000000..8ce389b
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/cursor-arrow-ripple.svg
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/device-phone-mobile.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/device-phone-mobile.svg
new file mode 100644
index 0000000..99440e4
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/device-phone-mobile.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/device-tablet.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/device-tablet.svg
new file mode 100644
index 0000000..79cf2bf
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/device-tablet.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/divide.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/divide.svg
new file mode 100644
index 0000000..09ae988
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/divide.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/document-arrow-down.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/document-arrow-down.svg
new file mode 100644
index 0000000..3f24c1f
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/document-arrow-down.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/document-arrow-up.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/document-arrow-up.svg
new file mode 100644
index 0000000..b92118e
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/document-arrow-up.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/document-chart-bar.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/document-chart-bar.svg
new file mode 100644
index 0000000..0a0dc7c
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/document-chart-bar.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/document-check.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/document-check.svg
new file mode 100644
index 0000000..9867eab
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/document-check.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/document-currency-bangladeshi.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/document-currency-bangladeshi.svg
new file mode 100644
index 0000000..d14b959
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/document-currency-bangladeshi.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/document-currency-dollar.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/document-currency-dollar.svg
new file mode 100644
index 0000000..27936ba
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/document-currency-dollar.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/document-currency-euro.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/document-currency-euro.svg
new file mode 100644
index 0000000..622ab9b
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/document-currency-euro.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/document-currency-pound.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/document-currency-pound.svg
new file mode 100644
index 0000000..7b4b079
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/document-currency-pound.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/document-currency-rupee.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/document-currency-rupee.svg
new file mode 100644
index 0000000..b4c50e7
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/document-currency-rupee.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/document-currency-yen.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/document-currency-yen.svg
new file mode 100644
index 0000000..55e94ff
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/document-currency-yen.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/document-duplicate.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/document-duplicate.svg
new file mode 100644
index 0000000..faa81c5
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/document-duplicate.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/document-magnifying-glass.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/document-magnifying-glass.svg
new file mode 100644
index 0000000..e2c3f4b
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/document-magnifying-glass.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/document-minus.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/document-minus.svg
new file mode 100644
index 0000000..0c0c5e0
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/document-minus.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/document-plus.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/document-plus.svg
new file mode 100644
index 0000000..2b9da44
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/document-plus.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/document-text.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/document-text.svg
new file mode 100644
index 0000000..14210f6
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/document-text.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/document.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/document.svg
new file mode 100644
index 0000000..9f81766
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/document.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/ellipsis-horizontal-circle.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/ellipsis-horizontal-circle.svg
new file mode 100644
index 0000000..3002b9c
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/ellipsis-horizontal-circle.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/ellipsis-horizontal.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/ellipsis-horizontal.svg
new file mode 100644
index 0000000..6359416
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/ellipsis-horizontal.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/ellipsis-vertical.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/ellipsis-vertical.svg
new file mode 100644
index 0000000..937c4c6
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/ellipsis-vertical.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/envelope-open.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/envelope-open.svg
new file mode 100644
index 0000000..648144c
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/envelope-open.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/envelope.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/envelope.svg
new file mode 100644
index 0000000..5f3d4fe
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/envelope.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/equals.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/equals.svg
new file mode 100644
index 0000000..7dd31a9
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/equals.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/exclamation-circle.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/exclamation-circle.svg
new file mode 100644
index 0000000..094c29f
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/exclamation-circle.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/exclamation-triangle.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/exclamation-triangle.svg
new file mode 100644
index 0000000..b841436
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/exclamation-triangle.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/eye-dropper.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/eye-dropper.svg
new file mode 100644
index 0000000..2807f23
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/eye-dropper.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/eye-slash.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/eye-slash.svg
new file mode 100644
index 0000000..61957b3
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/eye-slash.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/eye.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/eye.svg
new file mode 100644
index 0000000..0e0f914
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/eye.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/face-frown.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/face-frown.svg
new file mode 100644
index 0000000..e3ee54e
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/face-frown.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/face-smile.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/face-smile.svg
new file mode 100644
index 0000000..e36ddb7
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/face-smile.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/film.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/film.svg
new file mode 100644
index 0000000..d1bc36d
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/film.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/finger-print.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/finger-print.svg
new file mode 100644
index 0000000..f4f3340
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/finger-print.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/fire.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/fire.svg
new file mode 100644
index 0000000..7556d5d
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/fire.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/flag.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/flag.svg
new file mode 100644
index 0000000..ef6658e
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/flag.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/folder-arrow-down.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/folder-arrow-down.svg
new file mode 100644
index 0000000..6c25eea
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/folder-arrow-down.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/folder-minus.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/folder-minus.svg
new file mode 100644
index 0000000..e9c9a04
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/folder-minus.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/folder-open.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/folder-open.svg
new file mode 100644
index 0000000..3a36b57
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/folder-open.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/folder-plus.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/folder-plus.svg
new file mode 100644
index 0000000..e76be0e
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/folder-plus.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/folder.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/folder.svg
new file mode 100644
index 0000000..5b595b6
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/folder.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/forward.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/forward.svg
new file mode 100644
index 0000000..1cf6daf
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/forward.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/funnel.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/funnel.svg
new file mode 100644
index 0000000..be9bae4
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/funnel.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/gif.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/gif.svg
new file mode 100644
index 0000000..96f6176
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/gif.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/gift-top.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/gift-top.svg
new file mode 100644
index 0000000..4ac9ece
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/gift-top.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/gift.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/gift.svg
new file mode 100644
index 0000000..cffe93d
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/gift.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/globe-alt.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/globe-alt.svg
new file mode 100644
index 0000000..43d1dcc
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/globe-alt.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/globe-americas.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/globe-americas.svg
new file mode 100644
index 0000000..a630940
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/globe-americas.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/globe-asia-australia.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/globe-asia-australia.svg
new file mode 100644
index 0000000..4c073cf
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/globe-asia-australia.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/globe-europe-africa.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/globe-europe-africa.svg
new file mode 100644
index 0000000..fc4644a
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/globe-europe-africa.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/h1.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/h1.svg
new file mode 100644
index 0000000..76f06cd
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/h1.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/h2.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/h2.svg
new file mode 100644
index 0000000..0d8ba3e
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/h2.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/h3.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/h3.svg
new file mode 100644
index 0000000..2ba4d4a
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/h3.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/hand-raised.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/hand-raised.svg
new file mode 100644
index 0000000..de3a1a1
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/hand-raised.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/hand-thumb-down.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/hand-thumb-down.svg
new file mode 100644
index 0000000..2cca438
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/hand-thumb-down.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/hand-thumb-up.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/hand-thumb-up.svg
new file mode 100644
index 0000000..32c6f96
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/hand-thumb-up.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/hashtag.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/hashtag.svg
new file mode 100644
index 0000000..dc28035
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/hashtag.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/heart.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/heart.svg
new file mode 100644
index 0000000..b23191f
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/heart.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/home-modern.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/home-modern.svg
new file mode 100644
index 0000000..66948b1
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/home-modern.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/home.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/home.svg
new file mode 100644
index 0000000..60b4496
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/home.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/identification.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/identification.svg
new file mode 100644
index 0000000..8e831db
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/identification.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/inbox-arrow-down.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/inbox-arrow-down.svg
new file mode 100644
index 0000000..c6bca81
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/inbox-arrow-down.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/inbox-stack.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/inbox-stack.svg
new file mode 100644
index 0000000..8bc16a5
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/inbox-stack.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/inbox.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/inbox.svg
new file mode 100644
index 0000000..4ae995f
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/inbox.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/information-circle.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/information-circle.svg
new file mode 100644
index 0000000..a18abab
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/information-circle.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/italic.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/italic.svg
new file mode 100644
index 0000000..23698b5
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/italic.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/key.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/key.svg
new file mode 100644
index 0000000..dd7e56f
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/key.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/language.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/language.svg
new file mode 100644
index 0000000..e7e0cfc
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/language.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/lifebuoy.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/lifebuoy.svg
new file mode 100644
index 0000000..e0c7a1f
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/lifebuoy.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/light-bulb.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/light-bulb.svg
new file mode 100644
index 0000000..1006288
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/light-bulb.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/link-slash.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/link-slash.svg
new file mode 100644
index 0000000..5da53cf
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/link-slash.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/link.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/link.svg
new file mode 100644
index 0000000..35884ce
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/link.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/list-bullet.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/list-bullet.svg
new file mode 100644
index 0000000..da9f504
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/list-bullet.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/lock-closed.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/lock-closed.svg
new file mode 100644
index 0000000..31bd407
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/lock-closed.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/lock-open.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/lock-open.svg
new file mode 100644
index 0000000..99b63e7
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/lock-open.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/magnifying-glass-circle.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/magnifying-glass-circle.svg
new file mode 100644
index 0000000..54c1fd3
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/magnifying-glass-circle.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/magnifying-glass-minus.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/magnifying-glass-minus.svg
new file mode 100644
index 0000000..1cf27b5
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/magnifying-glass-minus.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/magnifying-glass-plus.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/magnifying-glass-plus.svg
new file mode 100644
index 0000000..ab075d6
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/magnifying-glass-plus.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/magnifying-glass.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/magnifying-glass.svg
new file mode 100644
index 0000000..2e4c54d
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/magnifying-glass.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/map-pin.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/map-pin.svg
new file mode 100644
index 0000000..c4f17e0
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/map-pin.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/map.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/map.svg
new file mode 100644
index 0000000..ce2931c
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/map.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/megaphone.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/megaphone.svg
new file mode 100644
index 0000000..109b396
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/megaphone.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/microphone.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/microphone.svg
new file mode 100644
index 0000000..8d6e8f4
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/microphone.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/minus-circle.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/minus-circle.svg
new file mode 100644
index 0000000..f3572df
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/minus-circle.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/minus.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/minus.svg
new file mode 100644
index 0000000..f91443c
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/minus.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/moon.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/moon.svg
new file mode 100644
index 0000000..799b15b
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/moon.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/musical-note.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/musical-note.svg
new file mode 100644
index 0000000..0a08598
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/musical-note.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/newspaper.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/newspaper.svg
new file mode 100644
index 0000000..4d66d46
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/newspaper.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/no-symbol.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/no-symbol.svg
new file mode 100644
index 0000000..dd92576
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/no-symbol.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/numbered-list.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/numbered-list.svg
new file mode 100644
index 0000000..f00bbd6
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/numbered-list.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/paint-brush.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/paint-brush.svg
new file mode 100644
index 0000000..0aebe49
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/paint-brush.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/paper-airplane.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/paper-airplane.svg
new file mode 100644
index 0000000..5cb9fe3
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/paper-airplane.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/paper-clip.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/paper-clip.svg
new file mode 100644
index 0000000..1a019c3
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/paper-clip.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/pause-circle.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/pause-circle.svg
new file mode 100644
index 0000000..ec93c1d
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/pause-circle.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/pause.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/pause.svg
new file mode 100644
index 0000000..55ea47d
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/pause.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/pencil-square.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/pencil-square.svg
new file mode 100644
index 0000000..25c6e94
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/pencil-square.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/pencil.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/pencil.svg
new file mode 100644
index 0000000..2bc22ee
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/pencil.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/percent-badge.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/percent-badge.svg
new file mode 100644
index 0000000..1cd08c8
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/percent-badge.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/phone-arrow-down-left.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/phone-arrow-down-left.svg
new file mode 100644
index 0000000..592ed3d
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/phone-arrow-down-left.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/phone-arrow-up-right.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/phone-arrow-up-right.svg
new file mode 100644
index 0000000..9f6896e
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/phone-arrow-up-right.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/phone-x-mark.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/phone-x-mark.svg
new file mode 100644
index 0000000..e8f1197
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/phone-x-mark.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/phone.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/phone.svg
new file mode 100644
index 0000000..b6808fb
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/phone.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/photo.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/photo.svg
new file mode 100644
index 0000000..fb02a1c
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/photo.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/play-circle.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/play-circle.svg
new file mode 100644
index 0000000..0944fc6
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/play-circle.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/play-pause.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/play-pause.svg
new file mode 100644
index 0000000..5cc07e0
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/play-pause.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/play.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/play.svg
new file mode 100644
index 0000000..a348d36
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/play.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/plus-circle.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/plus-circle.svg
new file mode 100644
index 0000000..a877442
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/plus-circle.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/plus.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/plus.svg
new file mode 100644
index 0000000..c650b5c
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/plus.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/power.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/power.svg
new file mode 100644
index 0000000..77dfe41
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/power.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/presentation-chart-bar.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/presentation-chart-bar.svg
new file mode 100644
index 0000000..120aaaf
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/presentation-chart-bar.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/presentation-chart-line.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/presentation-chart-line.svg
new file mode 100644
index 0000000..72f5720
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/presentation-chart-line.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/printer.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/printer.svg
new file mode 100644
index 0000000..bf28802
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/printer.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/puzzle-piece.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/puzzle-piece.svg
new file mode 100644
index 0000000..0f01af8
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/puzzle-piece.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/qr-code.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/qr-code.svg
new file mode 100644
index 0000000..4825d13
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/qr-code.svg
@@ -0,0 +1,9 @@
+
+
+
+
+
+
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/question-mark-circle.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/question-mark-circle.svg
new file mode 100644
index 0000000..34d8027
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/question-mark-circle.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/queue-list.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/queue-list.svg
new file mode 100644
index 0000000..c30fec3
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/queue-list.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/radio.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/radio.svg
new file mode 100644
index 0000000..273ae11
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/radio.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/receipt-percent.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/receipt-percent.svg
new file mode 100644
index 0000000..3212a6f
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/receipt-percent.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/receipt-refund.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/receipt-refund.svg
new file mode 100644
index 0000000..94d0f22
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/receipt-refund.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/rectangle-group.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/rectangle-group.svg
new file mode 100644
index 0000000..4898a0c
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/rectangle-group.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/rectangle-stack.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/rectangle-stack.svg
new file mode 100644
index 0000000..991c633
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/rectangle-stack.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/rocket-launch.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/rocket-launch.svg
new file mode 100644
index 0000000..f950c24
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/rocket-launch.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/rss.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/rss.svg
new file mode 100644
index 0000000..a148475
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/rss.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/scale.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/scale.svg
new file mode 100644
index 0000000..928d3b8
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/scale.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/scissors.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/scissors.svg
new file mode 100644
index 0000000..fcf6e9b
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/scissors.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/server-stack.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/server-stack.svg
new file mode 100644
index 0000000..420066c
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/server-stack.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/server.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/server.svg
new file mode 100644
index 0000000..13f2ae4
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/server.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/share.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/share.svg
new file mode 100644
index 0000000..59671fb
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/share.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/shield-check.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/shield-check.svg
new file mode 100644
index 0000000..6e93ee4
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/shield-check.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/shield-exclamation.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/shield-exclamation.svg
new file mode 100644
index 0000000..c1e135f
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/shield-exclamation.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/shopping-bag.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/shopping-bag.svg
new file mode 100644
index 0000000..5cf1a26
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/shopping-bag.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/shopping-cart.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/shopping-cart.svg
new file mode 100644
index 0000000..7afdf47
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/shopping-cart.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/signal-slash.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/signal-slash.svg
new file mode 100644
index 0000000..b34500c
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/signal-slash.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/signal.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/signal.svg
new file mode 100644
index 0000000..9fa80dc
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/signal.svg
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/slash.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/slash.svg
new file mode 100644
index 0000000..a2e3c14
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/slash.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/sparkles.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/sparkles.svg
new file mode 100644
index 0000000..fa542ae
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/sparkles.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/speaker-wave.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/speaker-wave.svg
new file mode 100644
index 0000000..cf8780e
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/speaker-wave.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/speaker-x-mark.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/speaker-x-mark.svg
new file mode 100644
index 0000000..be22558
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/speaker-x-mark.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/square-2-stack.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/square-2-stack.svg
new file mode 100644
index 0000000..328a997
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/square-2-stack.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/square-3-stack-3d.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/square-3-stack-3d.svg
new file mode 100644
index 0000000..6520941
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/square-3-stack-3d.svg
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/squares-2x2.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/squares-2x2.svg
new file mode 100644
index 0000000..375f706
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/squares-2x2.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/squares-plus.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/squares-plus.svg
new file mode 100644
index 0000000..8b6e1c2
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/squares-plus.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/star.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/star.svg
new file mode 100644
index 0000000..c585d24
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/star.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/stop-circle.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/stop-circle.svg
new file mode 100644
index 0000000..767eb26
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/stop-circle.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/stop.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/stop.svg
new file mode 100644
index 0000000..e90d578
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/stop.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/strikethrough.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/strikethrough.svg
new file mode 100644
index 0000000..69c3e5e
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/strikethrough.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/sun.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/sun.svg
new file mode 100644
index 0000000..f08a75c
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/sun.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/swatch.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/swatch.svg
new file mode 100644
index 0000000..91d53c8
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/swatch.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/table-cells.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/table-cells.svg
new file mode 100644
index 0000000..fa33c82
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/table-cells.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/tag.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/tag.svg
new file mode 100644
index 0000000..e8fa33f
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/tag.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/ticket.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/ticket.svg
new file mode 100644
index 0000000..1636997
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/ticket.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/trash.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/trash.svg
new file mode 100644
index 0000000..242d02e
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/trash.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/trophy.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/trophy.svg
new file mode 100644
index 0000000..4ce0044
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/trophy.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/truck.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/truck.svg
new file mode 100644
index 0000000..cf8b2ae
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/truck.svg
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/tv.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/tv.svg
new file mode 100644
index 0000000..71a03f1
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/tv.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/underline.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/underline.svg
new file mode 100644
index 0000000..8ce4f8a
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/underline.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/user-circle.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/user-circle.svg
new file mode 100644
index 0000000..a4828ea
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/user-circle.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/user-group.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/user-group.svg
new file mode 100644
index 0000000..b3eac77
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/user-group.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/user-minus.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/user-minus.svg
new file mode 100644
index 0000000..a9222c1
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/user-minus.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/user-plus.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/user-plus.svg
new file mode 100644
index 0000000..dbfb380
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/user-plus.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/user.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/user.svg
new file mode 100644
index 0000000..3b924bd
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/user.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/users.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/users.svg
new file mode 100644
index 0000000..51b4585
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/users.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/variable.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/variable.svg
new file mode 100644
index 0000000..025ef98
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/variable.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/video-camera-slash.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/video-camera-slash.svg
new file mode 100644
index 0000000..7206f3c
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/video-camera-slash.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/video-camera.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/video-camera.svg
new file mode 100644
index 0000000..91ffb60
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/video-camera.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/view-columns.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/view-columns.svg
new file mode 100644
index 0000000..a953a60
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/view-columns.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/viewfinder-circle.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/viewfinder-circle.svg
new file mode 100644
index 0000000..da6fbe4
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/viewfinder-circle.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/wallet.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/wallet.svg
new file mode 100644
index 0000000..5198eee
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/wallet.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/wifi.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/wifi.svg
new file mode 100644
index 0000000..bcd9226
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/wifi.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/window.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/window.svg
new file mode 100644
index 0000000..ecb5a8e
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/window.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/wrench-screwdriver.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/wrench-screwdriver.svg
new file mode 100644
index 0000000..89a99de
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/wrench-screwdriver.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/wrench.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/wrench.svg
new file mode 100644
index 0000000..552ef3d
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/wrench.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/x-circle.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/x-circle.svg
new file mode 100644
index 0000000..8050905
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/x-circle.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/x-mark.svg b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/x-mark.svg
new file mode 100644
index 0000000..dd7ae72
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/16/solid/x-mark.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/academic-cap.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/academic-cap.svg
new file mode 100644
index 0000000..28d31d1
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/academic-cap.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/adjustments-horizontal.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/adjustments-horizontal.svg
new file mode 100644
index 0000000..3e5e2c9
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/adjustments-horizontal.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/adjustments-vertical.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/adjustments-vertical.svg
new file mode 100644
index 0000000..afefea9
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/adjustments-vertical.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/archive-box-arrow-down.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/archive-box-arrow-down.svg
new file mode 100644
index 0000000..86f45bd
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/archive-box-arrow-down.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/archive-box-x-mark.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/archive-box-x-mark.svg
new file mode 100644
index 0000000..3a48526
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/archive-box-x-mark.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/archive-box.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/archive-box.svg
new file mode 100644
index 0000000..7b80ec2
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/archive-box.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-down-circle.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-down-circle.svg
new file mode 100644
index 0000000..2676c66
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-down-circle.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-down-left.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-down-left.svg
new file mode 100644
index 0000000..710c80a
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-down-left.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-down-on-square-stack.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-down-on-square-stack.svg
new file mode 100644
index 0000000..3f4bf61
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-down-on-square-stack.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-down-on-square.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-down-on-square.svg
new file mode 100644
index 0000000..9c0621f
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-down-on-square.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-down-right.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-down-right.svg
new file mode 100644
index 0000000..3ed2487
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-down-right.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-down-tray.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-down-tray.svg
new file mode 100644
index 0000000..312dc38
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-down-tray.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-down.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-down.svg
new file mode 100644
index 0000000..8442536
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-down.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-left-circle.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-left-circle.svg
new file mode 100644
index 0000000..52b3de6
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-left-circle.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-left-end-on-rectangle.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-left-end-on-rectangle.svg
new file mode 100644
index 0000000..81912ac
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-left-end-on-rectangle.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-left-on-rectangle.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-left-on-rectangle.svg
new file mode 100644
index 0000000..81912ac
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-left-on-rectangle.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-left-start-on-rectangle.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-left-start-on-rectangle.svg
new file mode 100644
index 0000000..0bdc78f
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-left-start-on-rectangle.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-left.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-left.svg
new file mode 100644
index 0000000..f087dba
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-left.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-long-down.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-long-down.svg
new file mode 100644
index 0000000..387286b
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-long-down.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-long-left.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-long-left.svg
new file mode 100644
index 0000000..6377071
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-long-left.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-long-right.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-long-right.svg
new file mode 100644
index 0000000..82fe6cf
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-long-right.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-long-up.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-long-up.svg
new file mode 100644
index 0000000..61f2ba1
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-long-up.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-path-rounded-square.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-path-rounded-square.svg
new file mode 100644
index 0000000..47cf252
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-path-rounded-square.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-path.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-path.svg
new file mode 100644
index 0000000..8090942
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-path.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-right-circle.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-right-circle.svg
new file mode 100644
index 0000000..fa2082b
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-right-circle.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-right-end-on-rectangle.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-right-end-on-rectangle.svg
new file mode 100644
index 0000000..99c4be5
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-right-end-on-rectangle.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-right-on-rectangle.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-right-on-rectangle.svg
new file mode 100644
index 0000000..7e69255
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-right-on-rectangle.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-right-start-on-rectangle.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-right-start-on-rectangle.svg
new file mode 100644
index 0000000..7e69255
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-right-start-on-rectangle.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-right.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-right.svg
new file mode 100644
index 0000000..3415f36
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-right.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-small-down.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-small-down.svg
new file mode 100644
index 0000000..f8a8957
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-small-down.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-small-left.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-small-left.svg
new file mode 100644
index 0000000..55aae95
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-small-left.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-small-right.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-small-right.svg
new file mode 100644
index 0000000..32ed766
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-small-right.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-small-up.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-small-up.svg
new file mode 100644
index 0000000..f60e86b
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-small-up.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-top-right-on-square.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-top-right-on-square.svg
new file mode 100644
index 0000000..4216598
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-top-right-on-square.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-trending-down.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-trending-down.svg
new file mode 100644
index 0000000..ae6d9cd
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-trending-down.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-trending-up.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-trending-up.svg
new file mode 100644
index 0000000..f5aa7b9
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-trending-up.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-turn-down-left.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-turn-down-left.svg
new file mode 100644
index 0000000..c4518df
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-turn-down-left.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-turn-down-right.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-turn-down-right.svg
new file mode 100644
index 0000000..eab8272
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-turn-down-right.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-turn-left-down.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-turn-left-down.svg
new file mode 100644
index 0000000..b97738d
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-turn-left-down.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-turn-left-up.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-turn-left-up.svg
new file mode 100644
index 0000000..6b69325
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-turn-left-up.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-turn-right-down.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-turn-right-down.svg
new file mode 100644
index 0000000..1f455a7
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-turn-right-down.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-turn-right-up.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-turn-right-up.svg
new file mode 100644
index 0000000..d8658ef
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-turn-right-up.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-turn-up-left.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-turn-up-left.svg
new file mode 100644
index 0000000..7c7775e
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-turn-up-left.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-turn-up-right.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-turn-up-right.svg
new file mode 100644
index 0000000..6719f9f
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-turn-up-right.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-up-circle.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-up-circle.svg
new file mode 100644
index 0000000..a07eca3
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-up-circle.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-up-left.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-up-left.svg
new file mode 100644
index 0000000..84d4ab9
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-up-left.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-up-on-square-stack.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-up-on-square-stack.svg
new file mode 100644
index 0000000..c85ce24
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-up-on-square-stack.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-up-on-square.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-up-on-square.svg
new file mode 100644
index 0000000..3a54f70
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-up-on-square.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-up-right.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-up-right.svg
new file mode 100644
index 0000000..5f58b69
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-up-right.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-up-tray.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-up-tray.svg
new file mode 100644
index 0000000..a57986a
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-up-tray.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-up.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-up.svg
new file mode 100644
index 0000000..1a1ca8f
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-up.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-uturn-down.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-uturn-down.svg
new file mode 100644
index 0000000..6c88620
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-uturn-down.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-uturn-left.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-uturn-left.svg
new file mode 100644
index 0000000..c3f666d
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-uturn-left.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-uturn-right.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-uturn-right.svg
new file mode 100644
index 0000000..0a8b31a
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-uturn-right.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-uturn-up.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-uturn-up.svg
new file mode 100644
index 0000000..f79c4bc
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrow-uturn-up.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrows-pointing-in.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrows-pointing-in.svg
new file mode 100644
index 0000000..e300a28
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrows-pointing-in.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrows-pointing-out.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrows-pointing-out.svg
new file mode 100644
index 0000000..7f58167
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrows-pointing-out.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrows-right-left.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrows-right-left.svg
new file mode 100644
index 0000000..cef196e
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrows-right-left.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrows-up-down.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrows-up-down.svg
new file mode 100644
index 0000000..43927b7
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/arrows-up-down.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/at-symbol.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/at-symbol.svg
new file mode 100644
index 0000000..5a7511a
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/at-symbol.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/backspace.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/backspace.svg
new file mode 100644
index 0000000..b95a6b7
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/backspace.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/backward.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/backward.svg
new file mode 100644
index 0000000..a8879ec
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/backward.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/banknotes.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/banknotes.svg
new file mode 100644
index 0000000..e0b86f9
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/banknotes.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/bars-2.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/bars-2.svg
new file mode 100644
index 0000000..bdf38a3
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/bars-2.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/bars-3-bottom-left.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/bars-3-bottom-left.svg
new file mode 100644
index 0000000..943bdac
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/bars-3-bottom-left.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/bars-3-bottom-right.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/bars-3-bottom-right.svg
new file mode 100644
index 0000000..4385318
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/bars-3-bottom-right.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/bars-3-center-left.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/bars-3-center-left.svg
new file mode 100644
index 0000000..f33da66
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/bars-3-center-left.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/bars-3.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/bars-3.svg
new file mode 100644
index 0000000..7ebe49c
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/bars-3.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/bars-4.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/bars-4.svg
new file mode 100644
index 0000000..fa63380
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/bars-4.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/bars-arrow-down.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/bars-arrow-down.svg
new file mode 100644
index 0000000..7d1bcd7
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/bars-arrow-down.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/bars-arrow-up.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/bars-arrow-up.svg
new file mode 100644
index 0000000..dbba841
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/bars-arrow-up.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/battery-0.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/battery-0.svg
new file mode 100644
index 0000000..86323f9
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/battery-0.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/battery-100.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/battery-100.svg
new file mode 100644
index 0000000..7d15706
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/battery-100.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/battery-50.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/battery-50.svg
new file mode 100644
index 0000000..b88ade1
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/battery-50.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/beaker.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/beaker.svg
new file mode 100644
index 0000000..6347163
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/beaker.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/bell-alert.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/bell-alert.svg
new file mode 100644
index 0000000..ea50a7b
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/bell-alert.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/bell-slash.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/bell-slash.svg
new file mode 100644
index 0000000..f7107f3
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/bell-slash.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/bell-snooze.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/bell-snooze.svg
new file mode 100644
index 0000000..06daf77
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/bell-snooze.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/bell.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/bell.svg
new file mode 100644
index 0000000..4b55d26
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/bell.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/bold.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/bold.svg
new file mode 100644
index 0000000..b3f6425
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/bold.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/bolt-slash.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/bolt-slash.svg
new file mode 100644
index 0000000..ba8d113
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/bolt-slash.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/bolt.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/bolt.svg
new file mode 100644
index 0000000..c2af3c1
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/bolt.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/book-open.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/book-open.svg
new file mode 100644
index 0000000..5dc4839
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/book-open.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/bookmark-slash.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/bookmark-slash.svg
new file mode 100644
index 0000000..2518392
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/bookmark-slash.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/bookmark-square.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/bookmark-square.svg
new file mode 100644
index 0000000..8aa84fb
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/bookmark-square.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/bookmark.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/bookmark.svg
new file mode 100644
index 0000000..237b0dc
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/bookmark.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/briefcase.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/briefcase.svg
new file mode 100644
index 0000000..8487636
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/briefcase.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/bug-ant.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/bug-ant.svg
new file mode 100644
index 0000000..81190d2
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/bug-ant.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/building-library.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/building-library.svg
new file mode 100644
index 0000000..a0439c6
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/building-library.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/building-office-2.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/building-office-2.svg
new file mode 100644
index 0000000..c4b4a9b
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/building-office-2.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/building-office.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/building-office.svg
new file mode 100644
index 0000000..2907c7b
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/building-office.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/building-storefront.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/building-storefront.svg
new file mode 100644
index 0000000..6a5be94
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/building-storefront.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/cake.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/cake.svg
new file mode 100644
index 0000000..bd0ac56
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/cake.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/calculator.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/calculator.svg
new file mode 100644
index 0000000..374cfdf
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/calculator.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/calendar-date-range.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/calendar-date-range.svg
new file mode 100644
index 0000000..8ec6d93
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/calendar-date-range.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/calendar-days.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/calendar-days.svg
new file mode 100644
index 0000000..a0ed5eb
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/calendar-days.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/calendar.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/calendar.svg
new file mode 100644
index 0000000..52e5752
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/calendar.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/camera.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/camera.svg
new file mode 100644
index 0000000..418456d
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/camera.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/chart-bar-square.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/chart-bar-square.svg
new file mode 100644
index 0000000..d34b223
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/chart-bar-square.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/chart-bar.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/chart-bar.svg
new file mode 100644
index 0000000..45a9866
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/chart-bar.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/chart-pie.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/chart-pie.svg
new file mode 100644
index 0000000..f00fdeb
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/chart-pie.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/chat-bubble-bottom-center-text.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/chat-bubble-bottom-center-text.svg
new file mode 100644
index 0000000..98e8c48
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/chat-bubble-bottom-center-text.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/chat-bubble-bottom-center.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/chat-bubble-bottom-center.svg
new file mode 100644
index 0000000..8b1f45d
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/chat-bubble-bottom-center.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/chat-bubble-left-ellipsis.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/chat-bubble-left-ellipsis.svg
new file mode 100644
index 0000000..f447448
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/chat-bubble-left-ellipsis.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/chat-bubble-left-right.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/chat-bubble-left-right.svg
new file mode 100644
index 0000000..089463b
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/chat-bubble-left-right.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/chat-bubble-left.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/chat-bubble-left.svg
new file mode 100644
index 0000000..fc1c62e
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/chat-bubble-left.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/chat-bubble-oval-left-ellipsis.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/chat-bubble-oval-left-ellipsis.svg
new file mode 100644
index 0000000..59f7d8e
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/chat-bubble-oval-left-ellipsis.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/chat-bubble-oval-left.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/chat-bubble-oval-left.svg
new file mode 100644
index 0000000..97beedf
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/chat-bubble-oval-left.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/check-badge.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/check-badge.svg
new file mode 100644
index 0000000..6a79b88
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/check-badge.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/check-circle.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/check-circle.svg
new file mode 100644
index 0000000..763716e
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/check-circle.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/check.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/check.svg
new file mode 100644
index 0000000..c29bd3f
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/check.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/chevron-double-down.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/chevron-double-down.svg
new file mode 100644
index 0000000..bd4f869
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/chevron-double-down.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/chevron-double-left.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/chevron-double-left.svg
new file mode 100644
index 0000000..60babe1
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/chevron-double-left.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/chevron-double-right.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/chevron-double-right.svg
new file mode 100644
index 0000000..7fe528c
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/chevron-double-right.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/chevron-double-up.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/chevron-double-up.svg
new file mode 100644
index 0000000..8ce111d
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/chevron-double-up.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/chevron-down.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/chevron-down.svg
new file mode 100644
index 0000000..18cbc1d
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/chevron-down.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/chevron-left.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/chevron-left.svg
new file mode 100644
index 0000000..b6e87d1
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/chevron-left.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/chevron-right.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/chevron-right.svg
new file mode 100644
index 0000000..e9eb2c4
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/chevron-right.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/chevron-up-down.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/chevron-up-down.svg
new file mode 100644
index 0000000..64964bf
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/chevron-up-down.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/chevron-up.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/chevron-up.svg
new file mode 100644
index 0000000..b6cd4db
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/chevron-up.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/circle-stack.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/circle-stack.svg
new file mode 100644
index 0000000..2133753
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/circle-stack.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/clipboard-document-check.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/clipboard-document-check.svg
new file mode 100644
index 0000000..38fb967
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/clipboard-document-check.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/clipboard-document-list.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/clipboard-document-list.svg
new file mode 100644
index 0000000..184daa0
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/clipboard-document-list.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/clipboard-document.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/clipboard-document.svg
new file mode 100644
index 0000000..de69a12
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/clipboard-document.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/clipboard.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/clipboard.svg
new file mode 100644
index 0000000..e08430a
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/clipboard.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/clock.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/clock.svg
new file mode 100644
index 0000000..e8349dc
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/clock.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/cloud-arrow-down.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/cloud-arrow-down.svg
new file mode 100644
index 0000000..e5088ca
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/cloud-arrow-down.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/cloud-arrow-up.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/cloud-arrow-up.svg
new file mode 100644
index 0000000..e63c3c2
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/cloud-arrow-up.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/cloud.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/cloud.svg
new file mode 100644
index 0000000..c47c4f4
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/cloud.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/code-bracket-square.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/code-bracket-square.svg
new file mode 100644
index 0000000..5e6a0c4
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/code-bracket-square.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/code-bracket.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/code-bracket.svg
new file mode 100644
index 0000000..5b42af4
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/code-bracket.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/cog-6-tooth.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/cog-6-tooth.svg
new file mode 100644
index 0000000..8eae336
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/cog-6-tooth.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/cog-8-tooth.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/cog-8-tooth.svg
new file mode 100644
index 0000000..81c94a5
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/cog-8-tooth.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/cog.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/cog.svg
new file mode 100644
index 0000000..55b19d4
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/cog.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/command-line.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/command-line.svg
new file mode 100644
index 0000000..93e35fc
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/command-line.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/computer-desktop.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/computer-desktop.svg
new file mode 100644
index 0000000..3c0fe1c
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/computer-desktop.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/cpu-chip.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/cpu-chip.svg
new file mode 100644
index 0000000..95adf0e
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/cpu-chip.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/credit-card.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/credit-card.svg
new file mode 100644
index 0000000..dbfafef
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/credit-card.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/cube-transparent.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/cube-transparent.svg
new file mode 100644
index 0000000..600d2e3
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/cube-transparent.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/cube.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/cube.svg
new file mode 100644
index 0000000..75f3644
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/cube.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/currency-bangladeshi.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/currency-bangladeshi.svg
new file mode 100644
index 0000000..187d75c
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/currency-bangladeshi.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/currency-dollar.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/currency-dollar.svg
new file mode 100644
index 0000000..e329589
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/currency-dollar.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/currency-euro.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/currency-euro.svg
new file mode 100644
index 0000000..64e2a32
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/currency-euro.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/currency-pound.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/currency-pound.svg
new file mode 100644
index 0000000..480e7b6
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/currency-pound.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/currency-rupee.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/currency-rupee.svg
new file mode 100644
index 0000000..43a9b8c
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/currency-rupee.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/currency-yen.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/currency-yen.svg
new file mode 100644
index 0000000..311006d
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/currency-yen.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/cursor-arrow-rays.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/cursor-arrow-rays.svg
new file mode 100644
index 0000000..18f5dc8
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/cursor-arrow-rays.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/cursor-arrow-ripple.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/cursor-arrow-ripple.svg
new file mode 100644
index 0000000..afbd741
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/cursor-arrow-ripple.svg
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/device-phone-mobile.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/device-phone-mobile.svg
new file mode 100644
index 0000000..7261026
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/device-phone-mobile.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/device-tablet.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/device-tablet.svg
new file mode 100644
index 0000000..c2b14bd
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/device-tablet.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/divide.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/divide.svg
new file mode 100644
index 0000000..1567b7c
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/divide.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/document-arrow-down.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/document-arrow-down.svg
new file mode 100644
index 0000000..04820b7
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/document-arrow-down.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/document-arrow-up.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/document-arrow-up.svg
new file mode 100644
index 0000000..b70468d
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/document-arrow-up.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/document-chart-bar.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/document-chart-bar.svg
new file mode 100644
index 0000000..48c91e9
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/document-chart-bar.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/document-check.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/document-check.svg
new file mode 100644
index 0000000..922f600
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/document-check.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/document-currency-bangladeshi.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/document-currency-bangladeshi.svg
new file mode 100644
index 0000000..5dfa06e
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/document-currency-bangladeshi.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/document-currency-dollar.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/document-currency-dollar.svg
new file mode 100644
index 0000000..3cc4509
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/document-currency-dollar.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/document-currency-euro.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/document-currency-euro.svg
new file mode 100644
index 0000000..455d22c
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/document-currency-euro.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/document-currency-pound.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/document-currency-pound.svg
new file mode 100644
index 0000000..4925373
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/document-currency-pound.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/document-currency-rupee.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/document-currency-rupee.svg
new file mode 100644
index 0000000..533bccc
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/document-currency-rupee.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/document-currency-yen.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/document-currency-yen.svg
new file mode 100644
index 0000000..a0c9ee4
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/document-currency-yen.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/document-duplicate.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/document-duplicate.svg
new file mode 100644
index 0000000..5b52546
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/document-duplicate.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/document-magnifying-glass.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/document-magnifying-glass.svg
new file mode 100644
index 0000000..0cfcadb
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/document-magnifying-glass.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/document-minus.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/document-minus.svg
new file mode 100644
index 0000000..207fe75
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/document-minus.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/document-plus.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/document-plus.svg
new file mode 100644
index 0000000..a59bca9
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/document-plus.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/document-text.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/document-text.svg
new file mode 100644
index 0000000..11c383c
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/document-text.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/document.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/document.svg
new file mode 100644
index 0000000..7631542
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/document.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/ellipsis-horizontal-circle.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/ellipsis-horizontal-circle.svg
new file mode 100644
index 0000000..4ed81ac
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/ellipsis-horizontal-circle.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/ellipsis-horizontal.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/ellipsis-horizontal.svg
new file mode 100644
index 0000000..e0b281b
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/ellipsis-horizontal.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/ellipsis-vertical.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/ellipsis-vertical.svg
new file mode 100644
index 0000000..1f7b5a9
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/ellipsis-vertical.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/envelope-open.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/envelope-open.svg
new file mode 100644
index 0000000..871fc80
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/envelope-open.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/envelope.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/envelope.svg
new file mode 100644
index 0000000..e0ef01b
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/envelope.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/equals.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/equals.svg
new file mode 100644
index 0000000..8db5d85
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/equals.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/exclamation-circle.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/exclamation-circle.svg
new file mode 100644
index 0000000..3f12a44
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/exclamation-circle.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/exclamation-triangle.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/exclamation-triangle.svg
new file mode 100644
index 0000000..c331b5b
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/exclamation-triangle.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/eye-dropper.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/eye-dropper.svg
new file mode 100644
index 0000000..bf1b0f4
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/eye-dropper.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/eye-slash.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/eye-slash.svg
new file mode 100644
index 0000000..8ee3cb9
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/eye-slash.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/eye.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/eye.svg
new file mode 100644
index 0000000..5ce8bcc
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/eye.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/face-frown.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/face-frown.svg
new file mode 100644
index 0000000..dd9cd52
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/face-frown.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/face-smile.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/face-smile.svg
new file mode 100644
index 0000000..0e8030c
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/face-smile.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/film.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/film.svg
new file mode 100644
index 0000000..2131ddd
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/film.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/finger-print.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/finger-print.svg
new file mode 100644
index 0000000..c6cdebc
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/finger-print.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/fire.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/fire.svg
new file mode 100644
index 0000000..e22ab93
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/fire.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/flag.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/flag.svg
new file mode 100644
index 0000000..b2a332a
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/flag.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/folder-arrow-down.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/folder-arrow-down.svg
new file mode 100644
index 0000000..67f62c9
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/folder-arrow-down.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/folder-minus.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/folder-minus.svg
new file mode 100644
index 0000000..abab6e9
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/folder-minus.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/folder-open.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/folder-open.svg
new file mode 100644
index 0000000..5f236ce
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/folder-open.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/folder-plus.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/folder-plus.svg
new file mode 100644
index 0000000..db22ab9
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/folder-plus.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/folder.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/folder.svg
new file mode 100644
index 0000000..a0fd97b
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/folder.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/forward.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/forward.svg
new file mode 100644
index 0000000..e7755db
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/forward.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/funnel.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/funnel.svg
new file mode 100644
index 0000000..86aaba0
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/funnel.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/gif.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/gif.svg
new file mode 100644
index 0000000..7df5ca5
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/gif.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/gift-top.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/gift-top.svg
new file mode 100644
index 0000000..e0564c3
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/gift-top.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/gift.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/gift.svg
new file mode 100644
index 0000000..f3e40f4
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/gift.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/globe-alt.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/globe-alt.svg
new file mode 100644
index 0000000..bb9f5cc
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/globe-alt.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/globe-americas.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/globe-americas.svg
new file mode 100644
index 0000000..2ae1103
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/globe-americas.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/globe-asia-australia.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/globe-asia-australia.svg
new file mode 100644
index 0000000..bb70e98
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/globe-asia-australia.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/globe-europe-africa.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/globe-europe-africa.svg
new file mode 100644
index 0000000..4584e07
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/globe-europe-africa.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/h1.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/h1.svg
new file mode 100644
index 0000000..c57bb96
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/h1.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/h2.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/h2.svg
new file mode 100644
index 0000000..3b1bf3a
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/h2.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/h3.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/h3.svg
new file mode 100644
index 0000000..93945c7
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/h3.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/hand-raised.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/hand-raised.svg
new file mode 100644
index 0000000..9657afe
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/hand-raised.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/hand-thumb-down.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/hand-thumb-down.svg
new file mode 100644
index 0000000..3476072
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/hand-thumb-down.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/hand-thumb-up.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/hand-thumb-up.svg
new file mode 100644
index 0000000..6ddf587
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/hand-thumb-up.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/hashtag.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/hashtag.svg
new file mode 100644
index 0000000..befe101
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/hashtag.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/heart.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/heart.svg
new file mode 100644
index 0000000..4277e6f
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/heart.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/home-modern.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/home-modern.svg
new file mode 100644
index 0000000..ede9c1e
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/home-modern.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/home.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/home.svg
new file mode 100644
index 0000000..9f7580a
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/home.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/identification.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/identification.svg
new file mode 100644
index 0000000..9117f87
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/identification.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/inbox-arrow-down.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/inbox-arrow-down.svg
new file mode 100644
index 0000000..e44a2a5
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/inbox-arrow-down.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/inbox-stack.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/inbox-stack.svg
new file mode 100644
index 0000000..067b085
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/inbox-stack.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/inbox.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/inbox.svg
new file mode 100644
index 0000000..9340579
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/inbox.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/information-circle.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/information-circle.svg
new file mode 100644
index 0000000..d834a25
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/information-circle.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/italic.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/italic.svg
new file mode 100644
index 0000000..f68b929
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/italic.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/key.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/key.svg
new file mode 100644
index 0000000..ed927ea
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/key.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/language.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/language.svg
new file mode 100644
index 0000000..53ad839
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/language.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/lifebuoy.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/lifebuoy.svg
new file mode 100644
index 0000000..e3799ad
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/lifebuoy.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/light-bulb.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/light-bulb.svg
new file mode 100644
index 0000000..728c000
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/light-bulb.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/link-slash.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/link-slash.svg
new file mode 100644
index 0000000..e974918
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/link-slash.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/link.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/link.svg
new file mode 100644
index 0000000..a40b5a5
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/link.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/list-bullet.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/list-bullet.svg
new file mode 100644
index 0000000..0700722
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/list-bullet.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/lock-closed.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/lock-closed.svg
new file mode 100644
index 0000000..28f1387
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/lock-closed.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/lock-open.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/lock-open.svg
new file mode 100644
index 0000000..3370496
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/lock-open.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/magnifying-glass-circle.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/magnifying-glass-circle.svg
new file mode 100644
index 0000000..7c427ea
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/magnifying-glass-circle.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/magnifying-glass-minus.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/magnifying-glass-minus.svg
new file mode 100644
index 0000000..353b848
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/magnifying-glass-minus.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/magnifying-glass-plus.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/magnifying-glass-plus.svg
new file mode 100644
index 0000000..1dc4f5d
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/magnifying-glass-plus.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/magnifying-glass.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/magnifying-glass.svg
new file mode 100644
index 0000000..8fede6f
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/magnifying-glass.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/map-pin.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/map-pin.svg
new file mode 100644
index 0000000..43719f1
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/map-pin.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/map.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/map.svg
new file mode 100644
index 0000000..ac7d6d3
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/map.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/megaphone.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/megaphone.svg
new file mode 100644
index 0000000..0155e4b
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/megaphone.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/microphone.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/microphone.svg
new file mode 100644
index 0000000..7f17f47
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/microphone.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/minus-circle.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/minus-circle.svg
new file mode 100644
index 0000000..ad202c9
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/minus-circle.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/minus-small.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/minus-small.svg
new file mode 100644
index 0000000..a27d7cd
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/minus-small.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/minus.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/minus.svg
new file mode 100644
index 0000000..8dee1f9
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/minus.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/moon.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/moon.svg
new file mode 100644
index 0000000..e5e23ac
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/moon.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/musical-note.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/musical-note.svg
new file mode 100644
index 0000000..cfc5cd0
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/musical-note.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/newspaper.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/newspaper.svg
new file mode 100644
index 0000000..a45e27c
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/newspaper.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/no-symbol.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/no-symbol.svg
new file mode 100644
index 0000000..238e1c4
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/no-symbol.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/numbered-list.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/numbered-list.svg
new file mode 100644
index 0000000..935dade
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/numbered-list.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/paint-brush.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/paint-brush.svg
new file mode 100644
index 0000000..bcb054b
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/paint-brush.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/paper-airplane.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/paper-airplane.svg
new file mode 100644
index 0000000..4a019d5
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/paper-airplane.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/paper-clip.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/paper-clip.svg
new file mode 100644
index 0000000..3b48652
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/paper-clip.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/pause-circle.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/pause-circle.svg
new file mode 100644
index 0000000..c2fc5be
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/pause-circle.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/pause.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/pause.svg
new file mode 100644
index 0000000..ffa1bf7
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/pause.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/pencil-square.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/pencil-square.svg
new file mode 100644
index 0000000..358fc12
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/pencil-square.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/pencil.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/pencil.svg
new file mode 100644
index 0000000..a6ac73f
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/pencil.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/percent-badge.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/percent-badge.svg
new file mode 100644
index 0000000..32b3275
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/percent-badge.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/phone-arrow-down-left.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/phone-arrow-down-left.svg
new file mode 100644
index 0000000..d79024f
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/phone-arrow-down-left.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/phone-arrow-up-right.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/phone-arrow-up-right.svg
new file mode 100644
index 0000000..015c1fb
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/phone-arrow-up-right.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/phone-x-mark.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/phone-x-mark.svg
new file mode 100644
index 0000000..3326c34
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/phone-x-mark.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/phone.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/phone.svg
new file mode 100644
index 0000000..e58c217
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/phone.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/photo.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/photo.svg
new file mode 100644
index 0000000..a6d92e3
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/photo.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/play-circle.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/play-circle.svg
new file mode 100644
index 0000000..0b3dac9
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/play-circle.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/play-pause.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/play-pause.svg
new file mode 100644
index 0000000..0727d8c
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/play-pause.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/play.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/play.svg
new file mode 100644
index 0000000..9d635f6
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/play.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/plus-circle.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/plus-circle.svg
new file mode 100644
index 0000000..9468c6e
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/plus-circle.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/plus-small.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/plus-small.svg
new file mode 100644
index 0000000..9718082
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/plus-small.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/plus.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/plus.svg
new file mode 100644
index 0000000..6faf9aa
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/plus.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/power.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/power.svg
new file mode 100644
index 0000000..14ba4ac
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/power.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/presentation-chart-bar.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/presentation-chart-bar.svg
new file mode 100644
index 0000000..7362861
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/presentation-chart-bar.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/presentation-chart-line.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/presentation-chart-line.svg
new file mode 100644
index 0000000..282087f
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/presentation-chart-line.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/printer.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/printer.svg
new file mode 100644
index 0000000..a511616
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/printer.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/puzzle-piece.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/puzzle-piece.svg
new file mode 100644
index 0000000..fcb7d8a
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/puzzle-piece.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/qr-code.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/qr-code.svg
new file mode 100644
index 0000000..008823c
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/qr-code.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/question-mark-circle.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/question-mark-circle.svg
new file mode 100644
index 0000000..e4b1de1
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/question-mark-circle.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/queue-list.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/queue-list.svg
new file mode 100644
index 0000000..3afe65b
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/queue-list.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/radio.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/radio.svg
new file mode 100644
index 0000000..6261d4e
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/radio.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/receipt-percent.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/receipt-percent.svg
new file mode 100644
index 0000000..100e23d
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/receipt-percent.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/receipt-refund.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/receipt-refund.svg
new file mode 100644
index 0000000..cc0ce43
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/receipt-refund.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/rectangle-group.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/rectangle-group.svg
new file mode 100644
index 0000000..c4ce4f6
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/rectangle-group.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/rectangle-stack.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/rectangle-stack.svg
new file mode 100644
index 0000000..f49e339
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/rectangle-stack.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/rocket-launch.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/rocket-launch.svg
new file mode 100644
index 0000000..e30ac8b
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/rocket-launch.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/rss.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/rss.svg
new file mode 100644
index 0000000..a99b8bc
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/rss.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/scale.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/scale.svg
new file mode 100644
index 0000000..ba1a849
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/scale.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/scissors.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/scissors.svg
new file mode 100644
index 0000000..eec0302
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/scissors.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/server-stack.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/server-stack.svg
new file mode 100644
index 0000000..d4aea4a
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/server-stack.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/server.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/server.svg
new file mode 100644
index 0000000..7bc532a
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/server.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/share.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/share.svg
new file mode 100644
index 0000000..291a7b0
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/share.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/shield-check.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/shield-check.svg
new file mode 100644
index 0000000..d55fc7e
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/shield-check.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/shield-exclamation.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/shield-exclamation.svg
new file mode 100644
index 0000000..dfcc0f3
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/shield-exclamation.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/shopping-bag.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/shopping-bag.svg
new file mode 100644
index 0000000..3c706ab
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/shopping-bag.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/shopping-cart.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/shopping-cart.svg
new file mode 100644
index 0000000..7fc14d4
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/shopping-cart.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/signal-slash.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/signal-slash.svg
new file mode 100644
index 0000000..0096e64
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/signal-slash.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/signal.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/signal.svg
new file mode 100644
index 0000000..63aec83
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/signal.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/slash.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/slash.svg
new file mode 100644
index 0000000..08416ce
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/slash.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/sparkles.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/sparkles.svg
new file mode 100644
index 0000000..1f280a0
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/sparkles.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/speaker-wave.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/speaker-wave.svg
new file mode 100644
index 0000000..53bce85
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/speaker-wave.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/speaker-x-mark.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/speaker-x-mark.svg
new file mode 100644
index 0000000..f6948af
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/speaker-x-mark.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/square-2-stack.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/square-2-stack.svg
new file mode 100644
index 0000000..35a3aa8
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/square-2-stack.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/square-3-stack-3d.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/square-3-stack-3d.svg
new file mode 100644
index 0000000..8302bd9
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/square-3-stack-3d.svg
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/squares-2x2.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/squares-2x2.svg
new file mode 100644
index 0000000..d778a4d
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/squares-2x2.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/squares-plus.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/squares-plus.svg
new file mode 100644
index 0000000..725df1d
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/squares-plus.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/star.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/star.svg
new file mode 100644
index 0000000..c6882d0
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/star.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/stop-circle.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/stop-circle.svg
new file mode 100644
index 0000000..aac6789
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/stop-circle.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/stop.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/stop.svg
new file mode 100644
index 0000000..c4559c4
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/stop.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/strikethrough.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/strikethrough.svg
new file mode 100644
index 0000000..13b3a49
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/strikethrough.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/sun.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/sun.svg
new file mode 100644
index 0000000..8d0e58f
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/sun.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/swatch.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/swatch.svg
new file mode 100644
index 0000000..7ac8dfd
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/swatch.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/table-cells.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/table-cells.svg
new file mode 100644
index 0000000..c041d12
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/table-cells.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/tag.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/tag.svg
new file mode 100644
index 0000000..02f502e
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/tag.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/ticket.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/ticket.svg
new file mode 100644
index 0000000..cea3ce7
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/ticket.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/trash.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/trash.svg
new file mode 100644
index 0000000..529224d
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/trash.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/trophy.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/trophy.svg
new file mode 100644
index 0000000..c02f0d5
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/trophy.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/truck.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/truck.svg
new file mode 100644
index 0000000..bf12ef7
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/truck.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/tv.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/tv.svg
new file mode 100644
index 0000000..b945a0c
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/tv.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/underline.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/underline.svg
new file mode 100644
index 0000000..416a009
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/underline.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/user-circle.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/user-circle.svg
new file mode 100644
index 0000000..59785eb
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/user-circle.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/user-group.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/user-group.svg
new file mode 100644
index 0000000..17e755e
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/user-group.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/user-minus.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/user-minus.svg
new file mode 100644
index 0000000..240bff5
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/user-minus.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/user-plus.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/user-plus.svg
new file mode 100644
index 0000000..3392941
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/user-plus.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/user.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/user.svg
new file mode 100644
index 0000000..25b7abc
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/user.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/users.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/users.svg
new file mode 100644
index 0000000..5a48a77
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/users.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/variable.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/variable.svg
new file mode 100644
index 0000000..c3e1ccf
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/variable.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/video-camera-slash.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/video-camera-slash.svg
new file mode 100644
index 0000000..a5ff5f7
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/video-camera-slash.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/video-camera.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/video-camera.svg
new file mode 100644
index 0000000..2485f0c
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/video-camera.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/view-columns.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/view-columns.svg
new file mode 100644
index 0000000..4289dcc
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/view-columns.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/viewfinder-circle.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/viewfinder-circle.svg
new file mode 100644
index 0000000..e5f4c37
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/viewfinder-circle.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/wallet.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/wallet.svg
new file mode 100644
index 0000000..9d7835d
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/wallet.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/wifi.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/wifi.svg
new file mode 100644
index 0000000..95f17df
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/wifi.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/window.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/window.svg
new file mode 100644
index 0000000..40ae1cc
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/window.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/wrench-screwdriver.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/wrench-screwdriver.svg
new file mode 100644
index 0000000..32c38ed
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/wrench-screwdriver.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/wrench.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/wrench.svg
new file mode 100644
index 0000000..46f68f4
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/wrench.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/x-circle.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/x-circle.svg
new file mode 100644
index 0000000..c8b56c7
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/x-circle.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/x-mark.svg b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/x-mark.svg
new file mode 100644
index 0000000..b20f867
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/20/solid/x-mark.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/academic-cap.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/academic-cap.svg
new file mode 100644
index 0000000..cd05c0e
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/academic-cap.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/adjustments-horizontal.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/adjustments-horizontal.svg
new file mode 100644
index 0000000..24a8bc7
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/adjustments-horizontal.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/adjustments-vertical.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/adjustments-vertical.svg
new file mode 100644
index 0000000..8ec937c
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/adjustments-vertical.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/archive-box-arrow-down.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/archive-box-arrow-down.svg
new file mode 100644
index 0000000..a325df4
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/archive-box-arrow-down.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/archive-box-x-mark.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/archive-box-x-mark.svg
new file mode 100644
index 0000000..cca86e1
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/archive-box-x-mark.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/archive-box.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/archive-box.svg
new file mode 100644
index 0000000..921a582
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/archive-box.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-down-circle.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-down-circle.svg
new file mode 100644
index 0000000..954042d
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-down-circle.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-down-left.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-down-left.svg
new file mode 100644
index 0000000..be398da
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-down-left.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-down-on-square-stack.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-down-on-square-stack.svg
new file mode 100644
index 0000000..6032f2c
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-down-on-square-stack.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-down-on-square.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-down-on-square.svg
new file mode 100644
index 0000000..d725386
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-down-on-square.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-down-right.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-down-right.svg
new file mode 100644
index 0000000..d31282e
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-down-right.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-down-tray.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-down-tray.svg
new file mode 100644
index 0000000..2b8890c
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-down-tray.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-down.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-down.svg
new file mode 100644
index 0000000..5e1a5de
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-down.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-left-circle.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-left-circle.svg
new file mode 100644
index 0000000..8240282
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-left-circle.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-left-end-on-rectangle.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-left-end-on-rectangle.svg
new file mode 100644
index 0000000..4bca1c9
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-left-end-on-rectangle.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-left-on-rectangle.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-left-on-rectangle.svg
new file mode 100644
index 0000000..4bca1c9
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-left-on-rectangle.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-left-start-on-rectangle.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-left-start-on-rectangle.svg
new file mode 100644
index 0000000..29997d2
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-left-start-on-rectangle.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-left.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-left.svg
new file mode 100644
index 0000000..1071988
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-left.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-long-down.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-long-down.svg
new file mode 100644
index 0000000..768a299
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-long-down.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-long-left.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-long-left.svg
new file mode 100644
index 0000000..0c01923
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-long-left.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-long-right.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-long-right.svg
new file mode 100644
index 0000000..746e1e4
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-long-right.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-long-up.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-long-up.svg
new file mode 100644
index 0000000..bebf680
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-long-up.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-path-rounded-square.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-path-rounded-square.svg
new file mode 100644
index 0000000..4392295
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-path-rounded-square.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-path.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-path.svg
new file mode 100644
index 0000000..fba7241
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-path.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-right-circle.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-right-circle.svg
new file mode 100644
index 0000000..6f065a0
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-right-circle.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-right-end-on-rectangle.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-right-end-on-rectangle.svg
new file mode 100644
index 0000000..48ffe90
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-right-end-on-rectangle.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-right-on-rectangle.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-right-on-rectangle.svg
new file mode 100644
index 0000000..abea6bf
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-right-on-rectangle.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-right-start-on-rectangle.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-right-start-on-rectangle.svg
new file mode 100644
index 0000000..abea6bf
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-right-start-on-rectangle.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-right.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-right.svg
new file mode 100644
index 0000000..f700b1f
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-right.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-small-down.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-small-down.svg
new file mode 100644
index 0000000..9977f0e
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-small-down.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-small-left.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-small-left.svg
new file mode 100644
index 0000000..8d54067
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-small-left.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-small-right.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-small-right.svg
new file mode 100644
index 0000000..af2ef62
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-small-right.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-small-up.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-small-up.svg
new file mode 100644
index 0000000..78e282d
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-small-up.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-top-right-on-square.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-top-right-on-square.svg
new file mode 100644
index 0000000..f3fb15f
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-top-right-on-square.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-trending-down.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-trending-down.svg
new file mode 100644
index 0000000..75f083a
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-trending-down.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-trending-up.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-trending-up.svg
new file mode 100644
index 0000000..b79bfbe
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-trending-up.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-turn-down-left.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-turn-down-left.svg
new file mode 100644
index 0000000..4425ea1
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-turn-down-left.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-turn-down-right.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-turn-down-right.svg
new file mode 100644
index 0000000..1efaec1
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-turn-down-right.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-turn-left-down.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-turn-left-down.svg
new file mode 100644
index 0000000..8ca63d9
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-turn-left-down.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-turn-left-up.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-turn-left-up.svg
new file mode 100644
index 0000000..37cc0b9
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-turn-left-up.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-turn-right-down.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-turn-right-down.svg
new file mode 100644
index 0000000..e10ebd3
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-turn-right-down.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-turn-right-up.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-turn-right-up.svg
new file mode 100644
index 0000000..ddf4cd8
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-turn-right-up.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-turn-up-left.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-turn-up-left.svg
new file mode 100644
index 0000000..3b2a330
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-turn-up-left.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-turn-up-right.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-turn-up-right.svg
new file mode 100644
index 0000000..7a9e437
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-turn-up-right.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-up-circle.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-up-circle.svg
new file mode 100644
index 0000000..c2e0359
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-up-circle.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-up-left.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-up-left.svg
new file mode 100644
index 0000000..6553168
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-up-left.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-up-on-square-stack.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-up-on-square-stack.svg
new file mode 100644
index 0000000..f1b3d5e
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-up-on-square-stack.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-up-on-square.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-up-on-square.svg
new file mode 100644
index 0000000..3625717
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-up-on-square.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-up-right.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-up-right.svg
new file mode 100644
index 0000000..9ec7dee
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-up-right.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-up-tray.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-up-tray.svg
new file mode 100644
index 0000000..3779677
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-up-tray.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-up.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-up.svg
new file mode 100644
index 0000000..cb05fb2
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-up.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-uturn-down.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-uturn-down.svg
new file mode 100644
index 0000000..f362008
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-uturn-down.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-uturn-left.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-uturn-left.svg
new file mode 100644
index 0000000..5a9819c
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-uturn-left.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-uturn-right.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-uturn-right.svg
new file mode 100644
index 0000000..244e674
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-uturn-right.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-uturn-up.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-uturn-up.svg
new file mode 100644
index 0000000..a19ff96
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrow-uturn-up.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrows-pointing-in.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrows-pointing-in.svg
new file mode 100644
index 0000000..2621d38
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrows-pointing-in.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrows-pointing-out.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrows-pointing-out.svg
new file mode 100644
index 0000000..73de08c
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrows-pointing-out.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrows-right-left.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrows-right-left.svg
new file mode 100644
index 0000000..22dca0a
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrows-right-left.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrows-up-down.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrows-up-down.svg
new file mode 100644
index 0000000..f8cc08f
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/arrows-up-down.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/at-symbol.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/at-symbol.svg
new file mode 100644
index 0000000..2b9e446
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/at-symbol.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/backspace.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/backspace.svg
new file mode 100644
index 0000000..1563d7d
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/backspace.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/backward.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/backward.svg
new file mode 100644
index 0000000..ff788f8
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/backward.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/banknotes.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/banknotes.svg
new file mode 100644
index 0000000..945f60e
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/banknotes.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/bars-2.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/bars-2.svg
new file mode 100644
index 0000000..d64a9bb
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/bars-2.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/bars-3-bottom-left.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/bars-3-bottom-left.svg
new file mode 100644
index 0000000..9960567
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/bars-3-bottom-left.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/bars-3-bottom-right.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/bars-3-bottom-right.svg
new file mode 100644
index 0000000..4e90c18
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/bars-3-bottom-right.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/bars-3-center-left.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/bars-3-center-left.svg
new file mode 100644
index 0000000..cca274c
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/bars-3-center-left.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/bars-3.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/bars-3.svg
new file mode 100644
index 0000000..6d2221f
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/bars-3.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/bars-4.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/bars-4.svg
new file mode 100644
index 0000000..b9e9c46
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/bars-4.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/bars-arrow-down.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/bars-arrow-down.svg
new file mode 100644
index 0000000..91af7e4
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/bars-arrow-down.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/bars-arrow-up.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/bars-arrow-up.svg
new file mode 100644
index 0000000..5c515e5
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/bars-arrow-up.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/battery-0.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/battery-0.svg
new file mode 100644
index 0000000..b9811f7
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/battery-0.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/battery-100.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/battery-100.svg
new file mode 100644
index 0000000..f880879
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/battery-100.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/battery-50.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/battery-50.svg
new file mode 100644
index 0000000..0018f8d
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/battery-50.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/beaker.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/beaker.svg
new file mode 100644
index 0000000..9457e9a
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/beaker.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/bell-alert.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/bell-alert.svg
new file mode 100644
index 0000000..68d2f64
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/bell-alert.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/bell-slash.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/bell-slash.svg
new file mode 100644
index 0000000..fc31652
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/bell-slash.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/bell-snooze.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/bell-snooze.svg
new file mode 100644
index 0000000..10fa5bd
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/bell-snooze.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/bell.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/bell.svg
new file mode 100644
index 0000000..8145b9d
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/bell.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/bold.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/bold.svg
new file mode 100644
index 0000000..d815857
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/bold.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/bolt-slash.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/bolt-slash.svg
new file mode 100644
index 0000000..91f81b4
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/bolt-slash.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/bolt.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/bolt.svg
new file mode 100644
index 0000000..506283d
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/bolt.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/book-open.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/book-open.svg
new file mode 100644
index 0000000..de02b14
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/book-open.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/bookmark-slash.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/bookmark-slash.svg
new file mode 100644
index 0000000..d55d5ae
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/bookmark-slash.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/bookmark-square.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/bookmark-square.svg
new file mode 100644
index 0000000..538d9e6
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/bookmark-square.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/bookmark.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/bookmark.svg
new file mode 100644
index 0000000..4e88a5c
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/bookmark.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/briefcase.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/briefcase.svg
new file mode 100644
index 0000000..d0c1fc3
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/briefcase.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/bug-ant.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/bug-ant.svg
new file mode 100644
index 0000000..056d5a5
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/bug-ant.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/building-library.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/building-library.svg
new file mode 100644
index 0000000..2ea181c
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/building-library.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/building-office-2.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/building-office-2.svg
new file mode 100644
index 0000000..d1f922a
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/building-office-2.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/building-office.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/building-office.svg
new file mode 100644
index 0000000..695729f
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/building-office.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/building-storefront.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/building-storefront.svg
new file mode 100644
index 0000000..8e287f8
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/building-storefront.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/cake.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/cake.svg
new file mode 100644
index 0000000..aa770ce
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/cake.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/calculator.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/calculator.svg
new file mode 100644
index 0000000..ef441d6
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/calculator.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/calendar-date-range.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/calendar-date-range.svg
new file mode 100644
index 0000000..e1e9119
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/calendar-date-range.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/calendar-days.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/calendar-days.svg
new file mode 100644
index 0000000..94731d7
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/calendar-days.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/calendar.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/calendar.svg
new file mode 100644
index 0000000..81b3dfc
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/calendar.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/camera.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/camera.svg
new file mode 100644
index 0000000..aabc3c6
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/camera.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/chart-bar-square.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/chart-bar-square.svg
new file mode 100644
index 0000000..e03b7df
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/chart-bar-square.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/chart-bar.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/chart-bar.svg
new file mode 100644
index 0000000..31ff30c
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/chart-bar.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/chart-pie.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/chart-pie.svg
new file mode 100644
index 0000000..eebd21f
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/chart-pie.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/chat-bubble-bottom-center-text.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/chat-bubble-bottom-center-text.svg
new file mode 100644
index 0000000..549b4b2
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/chat-bubble-bottom-center-text.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/chat-bubble-bottom-center.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/chat-bubble-bottom-center.svg
new file mode 100644
index 0000000..3bd9cf4
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/chat-bubble-bottom-center.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/chat-bubble-left-ellipsis.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/chat-bubble-left-ellipsis.svg
new file mode 100644
index 0000000..5865363
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/chat-bubble-left-ellipsis.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/chat-bubble-left-right.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/chat-bubble-left-right.svg
new file mode 100644
index 0000000..fb00eb1
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/chat-bubble-left-right.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/chat-bubble-left.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/chat-bubble-left.svg
new file mode 100644
index 0000000..1c0d986
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/chat-bubble-left.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/chat-bubble-oval-left-ellipsis.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/chat-bubble-oval-left-ellipsis.svg
new file mode 100644
index 0000000..3cc9ce5
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/chat-bubble-oval-left-ellipsis.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/chat-bubble-oval-left.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/chat-bubble-oval-left.svg
new file mode 100644
index 0000000..c4bab3a
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/chat-bubble-oval-left.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/check-badge.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/check-badge.svg
new file mode 100644
index 0000000..d39704d
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/check-badge.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/check-circle.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/check-circle.svg
new file mode 100644
index 0000000..912ffc6
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/check-circle.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/check.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/check.svg
new file mode 100644
index 0000000..37ad683
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/check.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/chevron-double-down.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/chevron-double-down.svg
new file mode 100644
index 0000000..308710e
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/chevron-double-down.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/chevron-double-left.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/chevron-double-left.svg
new file mode 100644
index 0000000..7af0232
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/chevron-double-left.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/chevron-double-right.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/chevron-double-right.svg
new file mode 100644
index 0000000..e29b66f
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/chevron-double-right.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/chevron-double-up.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/chevron-double-up.svg
new file mode 100644
index 0000000..ab19f2e
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/chevron-double-up.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/chevron-down.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/chevron-down.svg
new file mode 100644
index 0000000..b6ac1cf
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/chevron-down.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/chevron-left.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/chevron-left.svg
new file mode 100644
index 0000000..5e9f57b
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/chevron-left.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/chevron-right.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/chevron-right.svg
new file mode 100644
index 0000000..cd8a457
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/chevron-right.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/chevron-up-down.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/chevron-up-down.svg
new file mode 100644
index 0000000..ba6820a
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/chevron-up-down.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/chevron-up.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/chevron-up.svg
new file mode 100644
index 0000000..eafbff4
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/chevron-up.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/circle-stack.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/circle-stack.svg
new file mode 100644
index 0000000..2f75456
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/circle-stack.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/clipboard-document-check.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/clipboard-document-check.svg
new file mode 100644
index 0000000..b4be34b
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/clipboard-document-check.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/clipboard-document-list.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/clipboard-document-list.svg
new file mode 100644
index 0000000..11e818a
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/clipboard-document-list.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/clipboard-document.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/clipboard-document.svg
new file mode 100644
index 0000000..31eb487
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/clipboard-document.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/clipboard.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/clipboard.svg
new file mode 100644
index 0000000..2e7501e
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/clipboard.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/clock.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/clock.svg
new file mode 100644
index 0000000..a7fce7b
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/clock.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/cloud-arrow-down.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/cloud-arrow-down.svg
new file mode 100644
index 0000000..9bbee4f
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/cloud-arrow-down.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/cloud-arrow-up.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/cloud-arrow-up.svg
new file mode 100644
index 0000000..0994437
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/cloud-arrow-up.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/cloud.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/cloud.svg
new file mode 100644
index 0000000..9662d6c
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/cloud.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/code-bracket-square.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/code-bracket-square.svg
new file mode 100644
index 0000000..de71371
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/code-bracket-square.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/code-bracket.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/code-bracket.svg
new file mode 100644
index 0000000..8754e63
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/code-bracket.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/cog-6-tooth.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/cog-6-tooth.svg
new file mode 100644
index 0000000..91ba7db
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/cog-6-tooth.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/cog-8-tooth.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/cog-8-tooth.svg
new file mode 100644
index 0000000..515cc16
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/cog-8-tooth.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/cog.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/cog.svg
new file mode 100644
index 0000000..5aadb3e
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/cog.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/command-line.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/command-line.svg
new file mode 100644
index 0000000..6f9f8f9
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/command-line.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/computer-desktop.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/computer-desktop.svg
new file mode 100644
index 0000000..1744ea2
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/computer-desktop.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/cpu-chip.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/cpu-chip.svg
new file mode 100644
index 0000000..7ab9e09
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/cpu-chip.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/credit-card.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/credit-card.svg
new file mode 100644
index 0000000..fbe46d8
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/credit-card.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/cube-transparent.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/cube-transparent.svg
new file mode 100644
index 0000000..10f4c2c
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/cube-transparent.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/cube.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/cube.svg
new file mode 100644
index 0000000..87d16c0
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/cube.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/currency-bangladeshi.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/currency-bangladeshi.svg
new file mode 100644
index 0000000..4aaae03
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/currency-bangladeshi.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/currency-dollar.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/currency-dollar.svg
new file mode 100644
index 0000000..a69380e
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/currency-dollar.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/currency-euro.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/currency-euro.svg
new file mode 100644
index 0000000..b6504f3
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/currency-euro.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/currency-pound.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/currency-pound.svg
new file mode 100644
index 0000000..fb32377
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/currency-pound.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/currency-rupee.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/currency-rupee.svg
new file mode 100644
index 0000000..2040a22
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/currency-rupee.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/currency-yen.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/currency-yen.svg
new file mode 100644
index 0000000..80d3c99
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/currency-yen.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/cursor-arrow-rays.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/cursor-arrow-rays.svg
new file mode 100644
index 0000000..813793c
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/cursor-arrow-rays.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/cursor-arrow-ripple.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/cursor-arrow-ripple.svg
new file mode 100644
index 0000000..24dbb3e
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/cursor-arrow-ripple.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/device-phone-mobile.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/device-phone-mobile.svg
new file mode 100644
index 0000000..7ff5ae2
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/device-phone-mobile.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/device-tablet.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/device-tablet.svg
new file mode 100644
index 0000000..58b94a4
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/device-tablet.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/divide.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/divide.svg
new file mode 100644
index 0000000..d657f36
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/divide.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/document-arrow-down.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/document-arrow-down.svg
new file mode 100644
index 0000000..39cb4d3
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/document-arrow-down.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/document-arrow-up.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/document-arrow-up.svg
new file mode 100644
index 0000000..f4ad844
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/document-arrow-up.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/document-chart-bar.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/document-chart-bar.svg
new file mode 100644
index 0000000..0bf7dc1
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/document-chart-bar.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/document-check.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/document-check.svg
new file mode 100644
index 0000000..6ef1c98
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/document-check.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/document-currency-bangladeshi.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/document-currency-bangladeshi.svg
new file mode 100644
index 0000000..16bc3bf
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/document-currency-bangladeshi.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/document-currency-dollar.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/document-currency-dollar.svg
new file mode 100644
index 0000000..e07ce04
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/document-currency-dollar.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/document-currency-euro.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/document-currency-euro.svg
new file mode 100644
index 0000000..0a9846a
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/document-currency-euro.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/document-currency-pound.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/document-currency-pound.svg
new file mode 100644
index 0000000..9944c17
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/document-currency-pound.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/document-currency-rupee.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/document-currency-rupee.svg
new file mode 100644
index 0000000..5767bec
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/document-currency-rupee.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/document-currency-yen.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/document-currency-yen.svg
new file mode 100644
index 0000000..8e2b983
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/document-currency-yen.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/document-duplicate.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/document-duplicate.svg
new file mode 100644
index 0000000..9c6bc80
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/document-duplicate.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/document-magnifying-glass.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/document-magnifying-glass.svg
new file mode 100644
index 0000000..ebf1b13
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/document-magnifying-glass.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/document-minus.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/document-minus.svg
new file mode 100644
index 0000000..a13b6fe
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/document-minus.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/document-plus.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/document-plus.svg
new file mode 100644
index 0000000..e06def8
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/document-plus.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/document-text.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/document-text.svg
new file mode 100644
index 0000000..8c03e9e
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/document-text.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/document.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/document.svg
new file mode 100644
index 0000000..5a4881a
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/document.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/ellipsis-horizontal-circle.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/ellipsis-horizontal-circle.svg
new file mode 100644
index 0000000..d6f11dd
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/ellipsis-horizontal-circle.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/ellipsis-horizontal.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/ellipsis-horizontal.svg
new file mode 100644
index 0000000..4efa28a
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/ellipsis-horizontal.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/ellipsis-vertical.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/ellipsis-vertical.svg
new file mode 100644
index 0000000..2caf3e0
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/ellipsis-vertical.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/envelope-open.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/envelope-open.svg
new file mode 100644
index 0000000..01f34be
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/envelope-open.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/envelope.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/envelope.svg
new file mode 100644
index 0000000..b8029e8
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/envelope.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/equals.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/equals.svg
new file mode 100644
index 0000000..3c9702b
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/equals.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/exclamation-circle.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/exclamation-circle.svg
new file mode 100644
index 0000000..0833d8e
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/exclamation-circle.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/exclamation-triangle.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/exclamation-triangle.svg
new file mode 100644
index 0000000..dc2e6cf
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/exclamation-triangle.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/eye-dropper.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/eye-dropper.svg
new file mode 100644
index 0000000..c1b7b8a
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/eye-dropper.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/eye-slash.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/eye-slash.svg
new file mode 100644
index 0000000..fb79424
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/eye-slash.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/eye.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/eye.svg
new file mode 100644
index 0000000..f4ae935
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/eye.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/face-frown.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/face-frown.svg
new file mode 100644
index 0000000..86af7b0
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/face-frown.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/face-smile.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/face-smile.svg
new file mode 100644
index 0000000..e987282
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/face-smile.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/film.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/film.svg
new file mode 100644
index 0000000..f02856d
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/film.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/finger-print.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/finger-print.svg
new file mode 100644
index 0000000..2de9f82
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/finger-print.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/fire.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/fire.svg
new file mode 100644
index 0000000..0e944f5
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/fire.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/flag.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/flag.svg
new file mode 100644
index 0000000..f5d17df
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/flag.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/folder-arrow-down.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/folder-arrow-down.svg
new file mode 100644
index 0000000..74f0b6c
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/folder-arrow-down.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/folder-minus.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/folder-minus.svg
new file mode 100644
index 0000000..fc4212b
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/folder-minus.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/folder-open.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/folder-open.svg
new file mode 100644
index 0000000..df90e71
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/folder-open.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/folder-plus.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/folder-plus.svg
new file mode 100644
index 0000000..8acf5f7
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/folder-plus.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/folder.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/folder.svg
new file mode 100644
index 0000000..4c301ad
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/folder.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/forward.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/forward.svg
new file mode 100644
index 0000000..317f5ce
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/forward.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/funnel.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/funnel.svg
new file mode 100644
index 0000000..5937bdc
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/funnel.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/gif.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/gif.svg
new file mode 100644
index 0000000..6d22aca
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/gif.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/gift-top.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/gift-top.svg
new file mode 100644
index 0000000..3eb1793
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/gift-top.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/gift.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/gift.svg
new file mode 100644
index 0000000..a856b6f
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/gift.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/globe-alt.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/globe-alt.svg
new file mode 100644
index 0000000..b63d268
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/globe-alt.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/globe-americas.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/globe-americas.svg
new file mode 100644
index 0000000..f0a6beb
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/globe-americas.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/globe-asia-australia.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/globe-asia-australia.svg
new file mode 100644
index 0000000..396cd3c
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/globe-asia-australia.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/globe-europe-africa.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/globe-europe-africa.svg
new file mode 100644
index 0000000..427637d
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/globe-europe-africa.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/h1.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/h1.svg
new file mode 100644
index 0000000..e70d229
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/h1.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/h2.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/h2.svg
new file mode 100644
index 0000000..5fd0ee0
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/h2.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/h3.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/h3.svg
new file mode 100644
index 0000000..386c70f
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/h3.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/hand-raised.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/hand-raised.svg
new file mode 100644
index 0000000..c9008af
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/hand-raised.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/hand-thumb-down.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/hand-thumb-down.svg
new file mode 100644
index 0000000..3d2cc16
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/hand-thumb-down.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/hand-thumb-up.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/hand-thumb-up.svg
new file mode 100644
index 0000000..01b9877
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/hand-thumb-up.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/hashtag.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/hashtag.svg
new file mode 100644
index 0000000..9956ce0
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/hashtag.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/heart.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/heart.svg
new file mode 100644
index 0000000..2c371f3
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/heart.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/home-modern.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/home-modern.svg
new file mode 100644
index 0000000..b081fe8
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/home-modern.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/home.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/home.svg
new file mode 100644
index 0000000..e1900b8
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/home.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/identification.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/identification.svg
new file mode 100644
index 0000000..9a99a61
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/identification.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/inbox-arrow-down.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/inbox-arrow-down.svg
new file mode 100644
index 0000000..686060b
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/inbox-arrow-down.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/inbox-stack.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/inbox-stack.svg
new file mode 100644
index 0000000..c8a6ebb
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/inbox-stack.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/inbox.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/inbox.svg
new file mode 100644
index 0000000..2a26cee
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/inbox.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/information-circle.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/information-circle.svg
new file mode 100644
index 0000000..d4ac360
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/information-circle.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/italic.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/italic.svg
new file mode 100644
index 0000000..b424324
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/italic.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/key.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/key.svg
new file mode 100644
index 0000000..30c5bde
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/key.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/language.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/language.svg
new file mode 100644
index 0000000..1094eb1
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/language.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/lifebuoy.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/lifebuoy.svg
new file mode 100644
index 0000000..446562b
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/lifebuoy.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/light-bulb.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/light-bulb.svg
new file mode 100644
index 0000000..37c0317
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/light-bulb.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/link-slash.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/link-slash.svg
new file mode 100644
index 0000000..9d6f0e8
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/link-slash.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/link.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/link.svg
new file mode 100644
index 0000000..38febfc
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/link.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/list-bullet.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/list-bullet.svg
new file mode 100644
index 0000000..0dcb940
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/list-bullet.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/lock-closed.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/lock-closed.svg
new file mode 100644
index 0000000..4917dbb
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/lock-closed.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/lock-open.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/lock-open.svg
new file mode 100644
index 0000000..7f9a5a3
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/lock-open.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/magnifying-glass-circle.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/magnifying-glass-circle.svg
new file mode 100644
index 0000000..4c0da1f
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/magnifying-glass-circle.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/magnifying-glass-minus.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/magnifying-glass-minus.svg
new file mode 100644
index 0000000..d4dfada
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/magnifying-glass-minus.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/magnifying-glass-plus.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/magnifying-glass-plus.svg
new file mode 100644
index 0000000..a1d4363
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/magnifying-glass-plus.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/magnifying-glass.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/magnifying-glass.svg
new file mode 100644
index 0000000..947336c
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/magnifying-glass.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/map-pin.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/map-pin.svg
new file mode 100644
index 0000000..74cc786
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/map-pin.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/map.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/map.svg
new file mode 100644
index 0000000..2d9e239
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/map.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/megaphone.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/megaphone.svg
new file mode 100644
index 0000000..2b3c60b
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/megaphone.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/microphone.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/microphone.svg
new file mode 100644
index 0000000..e349845
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/microphone.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/minus-circle.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/minus-circle.svg
new file mode 100644
index 0000000..7cc1de3
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/minus-circle.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/minus-small.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/minus-small.svg
new file mode 100644
index 0000000..25144c8
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/minus-small.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/minus.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/minus.svg
new file mode 100644
index 0000000..fa1fb8b
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/minus.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/moon.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/moon.svg
new file mode 100644
index 0000000..6a13b2f
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/moon.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/musical-note.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/musical-note.svg
new file mode 100644
index 0000000..d903ff2
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/musical-note.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/newspaper.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/newspaper.svg
new file mode 100644
index 0000000..666274f
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/newspaper.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/no-symbol.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/no-symbol.svg
new file mode 100644
index 0000000..a7dd365
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/no-symbol.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/numbered-list.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/numbered-list.svg
new file mode 100644
index 0000000..10ba072
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/numbered-list.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/paint-brush.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/paint-brush.svg
new file mode 100644
index 0000000..21a208e
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/paint-brush.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/paper-airplane.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/paper-airplane.svg
new file mode 100644
index 0000000..80db4d0
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/paper-airplane.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/paper-clip.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/paper-clip.svg
new file mode 100644
index 0000000..c27a60a
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/paper-clip.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/pause-circle.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/pause-circle.svg
new file mode 100644
index 0000000..723e5f2
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/pause-circle.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/pause.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/pause.svg
new file mode 100644
index 0000000..7a61104
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/pause.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/pencil-square.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/pencil-square.svg
new file mode 100644
index 0000000..01de21e
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/pencil-square.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/pencil.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/pencil.svg
new file mode 100644
index 0000000..7af74ab
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/pencil.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/percent-badge.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/percent-badge.svg
new file mode 100644
index 0000000..2f84596
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/percent-badge.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/phone-arrow-down-left.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/phone-arrow-down-left.svg
new file mode 100644
index 0000000..31185c8
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/phone-arrow-down-left.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/phone-arrow-up-right.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/phone-arrow-up-right.svg
new file mode 100644
index 0000000..ab875b0
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/phone-arrow-up-right.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/phone-x-mark.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/phone-x-mark.svg
new file mode 100644
index 0000000..50568f0
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/phone-x-mark.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/phone.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/phone.svg
new file mode 100644
index 0000000..a8789d1
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/phone.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/photo.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/photo.svg
new file mode 100644
index 0000000..0e65af2
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/photo.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/play-circle.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/play-circle.svg
new file mode 100644
index 0000000..d83a73f
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/play-circle.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/play-pause.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/play-pause.svg
new file mode 100644
index 0000000..943efcb
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/play-pause.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/play.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/play.svg
new file mode 100644
index 0000000..3c0fad3
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/play.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/plus-circle.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/plus-circle.svg
new file mode 100644
index 0000000..1beab85
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/plus-circle.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/plus-small.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/plus-small.svg
new file mode 100644
index 0000000..7fb7dcb
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/plus-small.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/plus.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/plus.svg
new file mode 100644
index 0000000..06b312f
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/plus.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/power.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/power.svg
new file mode 100644
index 0000000..e471a03
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/power.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/presentation-chart-bar.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/presentation-chart-bar.svg
new file mode 100644
index 0000000..d056439
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/presentation-chart-bar.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/presentation-chart-line.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/presentation-chart-line.svg
new file mode 100644
index 0000000..ed212e5
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/presentation-chart-line.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/printer.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/printer.svg
new file mode 100644
index 0000000..f3c1c54
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/printer.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/puzzle-piece.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/puzzle-piece.svg
new file mode 100644
index 0000000..004855b
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/puzzle-piece.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/qr-code.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/qr-code.svg
new file mode 100644
index 0000000..1cbd5d4
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/qr-code.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/question-mark-circle.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/question-mark-circle.svg
new file mode 100644
index 0000000..e3dd599
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/question-mark-circle.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/queue-list.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/queue-list.svg
new file mode 100644
index 0000000..09f0066
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/queue-list.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/radio.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/radio.svg
new file mode 100644
index 0000000..81c891b
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/radio.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/receipt-percent.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/receipt-percent.svg
new file mode 100644
index 0000000..4dd5996
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/receipt-percent.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/receipt-refund.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/receipt-refund.svg
new file mode 100644
index 0000000..345a897
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/receipt-refund.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/rectangle-group.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/rectangle-group.svg
new file mode 100644
index 0000000..8184b19
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/rectangle-group.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/rectangle-stack.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/rectangle-stack.svg
new file mode 100644
index 0000000..8eb419a
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/rectangle-stack.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/rocket-launch.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/rocket-launch.svg
new file mode 100644
index 0000000..a6a6cb9
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/rocket-launch.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/rss.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/rss.svg
new file mode 100644
index 0000000..ff1e1a8
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/rss.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/scale.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/scale.svg
new file mode 100644
index 0000000..559bcda
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/scale.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/scissors.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/scissors.svg
new file mode 100644
index 0000000..4df73af
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/scissors.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/server-stack.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/server-stack.svg
new file mode 100644
index 0000000..060c820
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/server-stack.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/server.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/server.svg
new file mode 100644
index 0000000..eef8b9e
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/server.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/share.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/share.svg
new file mode 100644
index 0000000..e931243
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/share.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/shield-check.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/shield-check.svg
new file mode 100644
index 0000000..82b9125
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/shield-check.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/shield-exclamation.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/shield-exclamation.svg
new file mode 100644
index 0000000..5a8df17
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/shield-exclamation.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/shopping-bag.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/shopping-bag.svg
new file mode 100644
index 0000000..7c5f29b
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/shopping-bag.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/shopping-cart.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/shopping-cart.svg
new file mode 100644
index 0000000..b86c2ab
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/shopping-cart.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/signal-slash.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/signal-slash.svg
new file mode 100644
index 0000000..b858fdf
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/signal-slash.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/signal.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/signal.svg
new file mode 100644
index 0000000..9daa002
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/signal.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/slash.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/slash.svg
new file mode 100644
index 0000000..782434d
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/slash.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/sparkles.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/sparkles.svg
new file mode 100644
index 0000000..c8df7f1
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/sparkles.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/speaker-wave.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/speaker-wave.svg
new file mode 100644
index 0000000..7bb46f2
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/speaker-wave.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/speaker-x-mark.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/speaker-x-mark.svg
new file mode 100644
index 0000000..e89da3e
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/speaker-x-mark.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/square-2-stack.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/square-2-stack.svg
new file mode 100644
index 0000000..d6d6ca9
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/square-2-stack.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/square-3-stack-3d.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/square-3-stack-3d.svg
new file mode 100644
index 0000000..d11f07b
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/square-3-stack-3d.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/squares-2x2.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/squares-2x2.svg
new file mode 100644
index 0000000..1fe6594
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/squares-2x2.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/squares-plus.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/squares-plus.svg
new file mode 100644
index 0000000..b87a5f1
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/squares-plus.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/star.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/star.svg
new file mode 100644
index 0000000..1f27ec8
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/star.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/stop-circle.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/stop-circle.svg
new file mode 100644
index 0000000..26c2aba
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/stop-circle.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/stop.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/stop.svg
new file mode 100644
index 0000000..2058714
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/stop.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/strikethrough.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/strikethrough.svg
new file mode 100644
index 0000000..9ec9b9d
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/strikethrough.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/sun.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/sun.svg
new file mode 100644
index 0000000..0e49045
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/sun.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/swatch.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/swatch.svg
new file mode 100644
index 0000000..e1e1b27
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/swatch.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/table-cells.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/table-cells.svg
new file mode 100644
index 0000000..3d0f114
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/table-cells.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/tag.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/tag.svg
new file mode 100644
index 0000000..01eb0a3
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/tag.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/ticket.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/ticket.svg
new file mode 100644
index 0000000..f06ce3b
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/ticket.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/trash.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/trash.svg
new file mode 100644
index 0000000..71669b0
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/trash.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/trophy.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/trophy.svg
new file mode 100644
index 0000000..9556227
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/trophy.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/truck.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/truck.svg
new file mode 100644
index 0000000..7f475c8
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/truck.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/tv.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/tv.svg
new file mode 100644
index 0000000..b6fae6a
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/tv.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/underline.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/underline.svg
new file mode 100644
index 0000000..56a2051
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/underline.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/user-circle.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/user-circle.svg
new file mode 100644
index 0000000..74a941e
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/user-circle.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/user-group.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/user-group.svg
new file mode 100644
index 0000000..2a3159e
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/user-group.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/user-minus.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/user-minus.svg
new file mode 100644
index 0000000..878cc7a
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/user-minus.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/user-plus.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/user-plus.svg
new file mode 100644
index 0000000..a3c9bfc
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/user-plus.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/user.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/user.svg
new file mode 100644
index 0000000..9a343c4
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/user.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/users.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/users.svg
new file mode 100644
index 0000000..1de4a72
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/users.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/variable.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/variable.svg
new file mode 100644
index 0000000..4656e2f
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/variable.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/video-camera-slash.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/video-camera-slash.svg
new file mode 100644
index 0000000..c08bd00
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/video-camera-slash.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/video-camera.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/video-camera.svg
new file mode 100644
index 0000000..1af46bd
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/video-camera.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/view-columns.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/view-columns.svg
new file mode 100644
index 0000000..bc9a440
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/view-columns.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/viewfinder-circle.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/viewfinder-circle.svg
new file mode 100644
index 0000000..947dcd8
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/viewfinder-circle.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/wallet.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/wallet.svg
new file mode 100644
index 0000000..58989cf
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/wallet.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/wifi.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/wifi.svg
new file mode 100644
index 0000000..c838da4
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/wifi.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/window.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/window.svg
new file mode 100644
index 0000000..fcccbe2
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/window.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/wrench-screwdriver.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/wrench-screwdriver.svg
new file mode 100644
index 0000000..8d05c28
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/wrench-screwdriver.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/wrench.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/wrench.svg
new file mode 100644
index 0000000..56c6f8d
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/wrench.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/x-circle.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/x-circle.svg
new file mode 100644
index 0000000..e8d86ef
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/x-circle.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/x-mark.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/x-mark.svg
new file mode 100644
index 0000000..19b4ac7
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/outline/x-mark.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/academic-cap.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/academic-cap.svg
new file mode 100644
index 0000000..caf08cf
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/academic-cap.svg
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/adjustments-horizontal.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/adjustments-horizontal.svg
new file mode 100644
index 0000000..5941a18
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/adjustments-horizontal.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/adjustments-vertical.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/adjustments-vertical.svg
new file mode 100644
index 0000000..67922e6
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/adjustments-vertical.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/archive-box-arrow-down.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/archive-box-arrow-down.svg
new file mode 100644
index 0000000..a55eaac
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/archive-box-arrow-down.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/archive-box-x-mark.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/archive-box-x-mark.svg
new file mode 100644
index 0000000..d317024
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/archive-box-x-mark.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/archive-box.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/archive-box.svg
new file mode 100644
index 0000000..045a553
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/archive-box.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-down-circle.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-down-circle.svg
new file mode 100644
index 0000000..63783da
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-down-circle.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-down-left.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-down-left.svg
new file mode 100644
index 0000000..2a04263
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-down-left.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-down-on-square-stack.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-down-on-square-stack.svg
new file mode 100644
index 0000000..3ebf0cf
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-down-on-square-stack.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-down-on-square.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-down-on-square.svg
new file mode 100644
index 0000000..dcc413d
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-down-on-square.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-down-right.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-down-right.svg
new file mode 100644
index 0000000..dc6984c
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-down-right.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-down-tray.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-down-tray.svg
new file mode 100644
index 0000000..a574281
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-down-tray.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-down.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-down.svg
new file mode 100644
index 0000000..e8334d5
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-down.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-left-circle.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-left-circle.svg
new file mode 100644
index 0000000..d958af3
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-left-circle.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-left-end-on-rectangle.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-left-end-on-rectangle.svg
new file mode 100644
index 0000000..cf507b8
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-left-end-on-rectangle.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-left-on-rectangle.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-left-on-rectangle.svg
new file mode 100644
index 0000000..cf507b8
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-left-on-rectangle.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-left-start-on-rectangle.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-left-start-on-rectangle.svg
new file mode 100644
index 0000000..9fbe1ee
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-left-start-on-rectangle.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-left.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-left.svg
new file mode 100644
index 0000000..3f84fb7
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-left.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-long-down.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-long-down.svg
new file mode 100644
index 0000000..9176936
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-long-down.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-long-left.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-long-left.svg
new file mode 100644
index 0000000..b374f26
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-long-left.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-long-right.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-long-right.svg
new file mode 100644
index 0000000..654992f
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-long-right.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-long-up.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-long-up.svg
new file mode 100644
index 0000000..71b5c3c
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-long-up.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-path-rounded-square.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-path-rounded-square.svg
new file mode 100644
index 0000000..e7e2019
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-path-rounded-square.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-path.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-path.svg
new file mode 100644
index 0000000..11083b2
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-path.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-right-circle.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-right-circle.svg
new file mode 100644
index 0000000..96ed0f5
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-right-circle.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-right-end-on-rectangle.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-right-end-on-rectangle.svg
new file mode 100644
index 0000000..930a4ac
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-right-end-on-rectangle.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-right-on-rectangle.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-right-on-rectangle.svg
new file mode 100644
index 0000000..17ad554
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-right-on-rectangle.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-right-start-on-rectangle.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-right-start-on-rectangle.svg
new file mode 100644
index 0000000..17ad554
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-right-start-on-rectangle.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-right.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-right.svg
new file mode 100644
index 0000000..c985254
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-right.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-small-down.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-small-down.svg
new file mode 100644
index 0000000..dca2325
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-small-down.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-small-left.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-small-left.svg
new file mode 100644
index 0000000..a3ce231
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-small-left.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-small-right.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-small-right.svg
new file mode 100644
index 0000000..a2abcef
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-small-right.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-small-up.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-small-up.svg
new file mode 100644
index 0000000..8c25bba
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-small-up.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-top-right-on-square.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-top-right-on-square.svg
new file mode 100644
index 0000000..80e5aff
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-top-right-on-square.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-trending-down.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-trending-down.svg
new file mode 100644
index 0000000..b65a016
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-trending-down.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-trending-up.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-trending-up.svg
new file mode 100644
index 0000000..cf1ba1c
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-trending-up.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-turn-down-left.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-turn-down-left.svg
new file mode 100644
index 0000000..e40ed6b
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-turn-down-left.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-turn-down-right.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-turn-down-right.svg
new file mode 100644
index 0000000..92db7a0
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-turn-down-right.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-turn-left-down.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-turn-left-down.svg
new file mode 100644
index 0000000..a630e02
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-turn-left-down.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-turn-left-up.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-turn-left-up.svg
new file mode 100644
index 0000000..7af6dbd
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-turn-left-up.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-turn-right-down.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-turn-right-down.svg
new file mode 100644
index 0000000..2ad2bfc
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-turn-right-down.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-turn-right-up.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-turn-right-up.svg
new file mode 100644
index 0000000..fd0c49a
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-turn-right-up.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-turn-up-left.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-turn-up-left.svg
new file mode 100644
index 0000000..69e8b13
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-turn-up-left.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-turn-up-right.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-turn-up-right.svg
new file mode 100644
index 0000000..f83300e
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-turn-up-right.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-up-circle.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-up-circle.svg
new file mode 100644
index 0000000..83747dc
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-up-circle.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-up-left.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-up-left.svg
new file mode 100644
index 0000000..c6256c7
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-up-left.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-up-on-square-stack.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-up-on-square-stack.svg
new file mode 100644
index 0000000..d38d02d
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-up-on-square-stack.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-up-on-square.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-up-on-square.svg
new file mode 100644
index 0000000..70bf772
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-up-on-square.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-up-right.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-up-right.svg
new file mode 100644
index 0000000..af327a9
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-up-right.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-up-tray.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-up-tray.svg
new file mode 100644
index 0000000..97cea1e
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-up-tray.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-up.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-up.svg
new file mode 100644
index 0000000..31d032b
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-up.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-uturn-down.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-uturn-down.svg
new file mode 100644
index 0000000..04c20f4
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-uturn-down.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-uturn-left.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-uturn-left.svg
new file mode 100644
index 0000000..bfb8063
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-uturn-left.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-uturn-right.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-uturn-right.svg
new file mode 100644
index 0000000..4151694
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-uturn-right.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-uturn-up.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-uturn-up.svg
new file mode 100644
index 0000000..1ea28a7
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrow-uturn-up.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrows-pointing-in.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrows-pointing-in.svg
new file mode 100644
index 0000000..1e398e8
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrows-pointing-in.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrows-pointing-out.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrows-pointing-out.svg
new file mode 100644
index 0000000..a150459
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrows-pointing-out.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrows-right-left.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrows-right-left.svg
new file mode 100644
index 0000000..e0c2cd1
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrows-right-left.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrows-up-down.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrows-up-down.svg
new file mode 100644
index 0000000..65ecb1d
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/arrows-up-down.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/at-symbol.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/at-symbol.svg
new file mode 100644
index 0000000..12b3a4b
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/at-symbol.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/backspace.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/backspace.svg
new file mode 100644
index 0000000..67ead14
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/backspace.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/backward.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/backward.svg
new file mode 100644
index 0000000..77bb887
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/backward.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/banknotes.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/banknotes.svg
new file mode 100644
index 0000000..3537885
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/banknotes.svg
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/bars-2.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/bars-2.svg
new file mode 100644
index 0000000..f202853
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/bars-2.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/bars-3-bottom-left.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/bars-3-bottom-left.svg
new file mode 100644
index 0000000..b6d722a
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/bars-3-bottom-left.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/bars-3-bottom-right.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/bars-3-bottom-right.svg
new file mode 100644
index 0000000..fe4ca2d
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/bars-3-bottom-right.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/bars-3-center-left.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/bars-3-center-left.svg
new file mode 100644
index 0000000..f1837b7
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/bars-3-center-left.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/bars-3.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/bars-3.svg
new file mode 100644
index 0000000..b346ec7
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/bars-3.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/bars-4.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/bars-4.svg
new file mode 100644
index 0000000..a55c244
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/bars-4.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/bars-arrow-down.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/bars-arrow-down.svg
new file mode 100644
index 0000000..a8988f4
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/bars-arrow-down.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/bars-arrow-up.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/bars-arrow-up.svg
new file mode 100644
index 0000000..50e5d62
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/bars-arrow-up.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/battery-0.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/battery-0.svg
new file mode 100644
index 0000000..e3cf12b
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/battery-0.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/battery-100.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/battery-100.svg
new file mode 100644
index 0000000..8c0b12a
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/battery-100.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/battery-50.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/battery-50.svg
new file mode 100644
index 0000000..9da699c
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/battery-50.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/beaker.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/beaker.svg
new file mode 100644
index 0000000..09f8405
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/beaker.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/bell-alert.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/bell-alert.svg
new file mode 100644
index 0000000..ab36d83
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/bell-alert.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/bell-slash.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/bell-slash.svg
new file mode 100644
index 0000000..6c27b45
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/bell-slash.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/bell-snooze.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/bell-snooze.svg
new file mode 100644
index 0000000..fa81242
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/bell-snooze.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/bell.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/bell.svg
new file mode 100644
index 0000000..15c304d
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/bell.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/bold.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/bold.svg
new file mode 100644
index 0000000..4acbb17
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/bold.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/bolt-slash.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/bolt-slash.svg
new file mode 100644
index 0000000..db5bb00
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/bolt-slash.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/bolt.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/bolt.svg
new file mode 100644
index 0000000..ddafddc
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/bolt.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/book-open.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/book-open.svg
new file mode 100644
index 0000000..be9503f
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/book-open.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/bookmark-slash.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/bookmark-slash.svg
new file mode 100644
index 0000000..8029db0
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/bookmark-slash.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/bookmark-square.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/bookmark-square.svg
new file mode 100644
index 0000000..5589364
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/bookmark-square.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/bookmark.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/bookmark.svg
new file mode 100644
index 0000000..4f4f509
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/bookmark.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/briefcase.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/briefcase.svg
new file mode 100644
index 0000000..519aacd
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/briefcase.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/bug-ant.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/bug-ant.svg
new file mode 100644
index 0000000..1278695
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/bug-ant.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/building-library.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/building-library.svg
new file mode 100644
index 0000000..bcecbd9
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/building-library.svg
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/building-office-2.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/building-office-2.svg
new file mode 100644
index 0000000..506b3cf
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/building-office-2.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/building-office.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/building-office.svg
new file mode 100644
index 0000000..a36c69a
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/building-office.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/building-storefront.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/building-storefront.svg
new file mode 100644
index 0000000..8a5f317
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/building-storefront.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/cake.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/cake.svg
new file mode 100644
index 0000000..9626351
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/cake.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/calculator.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/calculator.svg
new file mode 100644
index 0000000..a512ef7
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/calculator.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/calendar-date-range.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/calendar-date-range.svg
new file mode 100644
index 0000000..2336c66
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/calendar-date-range.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/calendar-days.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/calendar-days.svg
new file mode 100644
index 0000000..80d447c
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/calendar-days.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/calendar.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/calendar.svg
new file mode 100644
index 0000000..cc157d6
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/calendar.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/camera.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/camera.svg
new file mode 100644
index 0000000..8f26ad5
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/camera.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/chart-bar-square.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/chart-bar-square.svg
new file mode 100644
index 0000000..13a71f5
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/chart-bar-square.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/chart-bar.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/chart-bar.svg
new file mode 100644
index 0000000..0bb35a1
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/chart-bar.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/chart-pie.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/chart-pie.svg
new file mode 100644
index 0000000..2068099
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/chart-pie.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/chat-bubble-bottom-center-text.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/chat-bubble-bottom-center-text.svg
new file mode 100644
index 0000000..e4b8f13
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/chat-bubble-bottom-center-text.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/chat-bubble-bottom-center.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/chat-bubble-bottom-center.svg
new file mode 100644
index 0000000..8b19aee
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/chat-bubble-bottom-center.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/chat-bubble-left-ellipsis.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/chat-bubble-left-ellipsis.svg
new file mode 100644
index 0000000..bc6cc35
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/chat-bubble-left-ellipsis.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/chat-bubble-left-right.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/chat-bubble-left-right.svg
new file mode 100644
index 0000000..c2b25fd
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/chat-bubble-left-right.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/chat-bubble-left.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/chat-bubble-left.svg
new file mode 100644
index 0000000..d65d62a
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/chat-bubble-left.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/chat-bubble-oval-left-ellipsis.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/chat-bubble-oval-left-ellipsis.svg
new file mode 100644
index 0000000..30603d0
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/chat-bubble-oval-left-ellipsis.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/chat-bubble-oval-left.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/chat-bubble-oval-left.svg
new file mode 100644
index 0000000..583d502
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/chat-bubble-oval-left.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/check-badge.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/check-badge.svg
new file mode 100644
index 0000000..bcf807a
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/check-badge.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/check-circle.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/check-circle.svg
new file mode 100644
index 0000000..4c53408
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/check-circle.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/check.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/check.svg
new file mode 100644
index 0000000..99d96ee
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/check.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/chevron-double-down.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/chevron-double-down.svg
new file mode 100644
index 0000000..a2220ef
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/chevron-double-down.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/chevron-double-left.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/chevron-double-left.svg
new file mode 100644
index 0000000..06446a7
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/chevron-double-left.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/chevron-double-right.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/chevron-double-right.svg
new file mode 100644
index 0000000..f25d457
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/chevron-double-right.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/chevron-double-up.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/chevron-double-up.svg
new file mode 100644
index 0000000..a169797
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/chevron-double-up.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/chevron-down.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/chevron-down.svg
new file mode 100644
index 0000000..9cd2647
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/chevron-down.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/chevron-left.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/chevron-left.svg
new file mode 100644
index 0000000..5fcaa4e
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/chevron-left.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/chevron-right.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/chevron-right.svg
new file mode 100644
index 0000000..6e37a51
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/chevron-right.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/chevron-up-down.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/chevron-up-down.svg
new file mode 100644
index 0000000..4c19e13
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/chevron-up-down.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/chevron-up.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/chevron-up.svg
new file mode 100644
index 0000000..d9695d3
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/chevron-up.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/circle-stack.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/circle-stack.svg
new file mode 100644
index 0000000..51b626e
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/circle-stack.svg
@@ -0,0 +1,6 @@
+
+
+
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/clipboard-document-check.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/clipboard-document-check.svg
new file mode 100644
index 0000000..e5de2b6
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/clipboard-document-check.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/clipboard-document-list.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/clipboard-document-list.svg
new file mode 100644
index 0000000..91ac9b7
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/clipboard-document-list.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/clipboard-document.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/clipboard-document.svg
new file mode 100644
index 0000000..51c90de
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/clipboard-document.svg
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/clipboard.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/clipboard.svg
new file mode 100644
index 0000000..2c98bb3
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/clipboard.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/clock.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/clock.svg
new file mode 100644
index 0000000..fb8943a
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/clock.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/cloud-arrow-down.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/cloud-arrow-down.svg
new file mode 100644
index 0000000..6bc1ece
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/cloud-arrow-down.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/cloud-arrow-up.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/cloud-arrow-up.svg
new file mode 100644
index 0000000..59814e6
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/cloud-arrow-up.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/cloud.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/cloud.svg
new file mode 100644
index 0000000..88edfce
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/cloud.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/code-bracket-square.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/code-bracket-square.svg
new file mode 100644
index 0000000..b2e0ec1
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/code-bracket-square.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/code-bracket.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/code-bracket.svg
new file mode 100644
index 0000000..9420564
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/code-bracket.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/cog-6-tooth.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/cog-6-tooth.svg
new file mode 100644
index 0000000..4eebfb6
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/cog-6-tooth.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/cog-8-tooth.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/cog-8-tooth.svg
new file mode 100644
index 0000000..17fcb97
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/cog-8-tooth.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/cog.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/cog.svg
new file mode 100644
index 0000000..762d109
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/cog.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/command-line.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/command-line.svg
new file mode 100644
index 0000000..d7a1426
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/command-line.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/computer-desktop.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/computer-desktop.svg
new file mode 100644
index 0000000..a7ad833
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/computer-desktop.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/cpu-chip.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/cpu-chip.svg
new file mode 100644
index 0000000..b9da5a1
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/cpu-chip.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/credit-card.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/credit-card.svg
new file mode 100644
index 0000000..bd92d58
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/credit-card.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/cube-transparent.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/cube-transparent.svg
new file mode 100644
index 0000000..56b08b8
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/cube-transparent.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/cube.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/cube.svg
new file mode 100644
index 0000000..875c5f8
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/cube.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/currency-bangladeshi.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/currency-bangladeshi.svg
new file mode 100644
index 0000000..8d8833c
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/currency-bangladeshi.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/currency-dollar.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/currency-dollar.svg
new file mode 100644
index 0000000..e8aaeea
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/currency-dollar.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/currency-euro.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/currency-euro.svg
new file mode 100644
index 0000000..0d3f66d
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/currency-euro.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/currency-pound.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/currency-pound.svg
new file mode 100644
index 0000000..832100e
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/currency-pound.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/currency-rupee.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/currency-rupee.svg
new file mode 100644
index 0000000..6a70776
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/currency-rupee.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/currency-yen.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/currency-yen.svg
new file mode 100644
index 0000000..1c55b60
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/currency-yen.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/cursor-arrow-rays.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/cursor-arrow-rays.svg
new file mode 100644
index 0000000..cfe7553
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/cursor-arrow-rays.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/cursor-arrow-ripple.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/cursor-arrow-ripple.svg
new file mode 100644
index 0000000..7386ee2
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/cursor-arrow-ripple.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/device-phone-mobile.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/device-phone-mobile.svg
new file mode 100644
index 0000000..a81f966
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/device-phone-mobile.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/device-tablet.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/device-tablet.svg
new file mode 100644
index 0000000..e576b7f
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/device-tablet.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/divide.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/divide.svg
new file mode 100644
index 0000000..bc10e9d
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/divide.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/document-arrow-down.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/document-arrow-down.svg
new file mode 100644
index 0000000..fab4cd1
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/document-arrow-down.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/document-arrow-up.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/document-arrow-up.svg
new file mode 100644
index 0000000..62bf419
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/document-arrow-up.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/document-chart-bar.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/document-chart-bar.svg
new file mode 100644
index 0000000..3ef54d8
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/document-chart-bar.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/document-check.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/document-check.svg
new file mode 100644
index 0000000..357c565
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/document-check.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/document-currency-bangladeshi.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/document-currency-bangladeshi.svg
new file mode 100644
index 0000000..0e84d61
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/document-currency-bangladeshi.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/document-currency-dollar.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/document-currency-dollar.svg
new file mode 100644
index 0000000..dcf6abe
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/document-currency-dollar.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/document-currency-euro.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/document-currency-euro.svg
new file mode 100644
index 0000000..5d217ce
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/document-currency-euro.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/document-currency-pound.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/document-currency-pound.svg
new file mode 100644
index 0000000..5a36501
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/document-currency-pound.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/document-currency-rupee.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/document-currency-rupee.svg
new file mode 100644
index 0000000..bc2e664
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/document-currency-rupee.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/document-currency-yen.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/document-currency-yen.svg
new file mode 100644
index 0000000..4236e49
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/document-currency-yen.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/document-duplicate.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/document-duplicate.svg
new file mode 100644
index 0000000..55d74d5
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/document-duplicate.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/document-magnifying-glass.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/document-magnifying-glass.svg
new file mode 100644
index 0000000..63cd019
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/document-magnifying-glass.svg
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/document-minus.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/document-minus.svg
new file mode 100644
index 0000000..13b0924
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/document-minus.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/document-plus.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/document-plus.svg
new file mode 100644
index 0000000..1ce84c4
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/document-plus.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/document-text.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/document-text.svg
new file mode 100644
index 0000000..4f747fb
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/document-text.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/document.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/document.svg
new file mode 100644
index 0000000..de025c4
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/document.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/ellipsis-horizontal-circle.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/ellipsis-horizontal-circle.svg
new file mode 100644
index 0000000..4db4853
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/ellipsis-horizontal-circle.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/ellipsis-horizontal.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/ellipsis-horizontal.svg
new file mode 100644
index 0000000..5f7a532
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/ellipsis-horizontal.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/ellipsis-vertical.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/ellipsis-vertical.svg
new file mode 100644
index 0000000..5ada537
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/ellipsis-vertical.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/envelope-open.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/envelope-open.svg
new file mode 100644
index 0000000..6393663
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/envelope-open.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/envelope.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/envelope.svg
new file mode 100644
index 0000000..94fc56c
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/envelope.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/equals.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/equals.svg
new file mode 100644
index 0000000..18133df
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/equals.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/exclamation-circle.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/exclamation-circle.svg
new file mode 100644
index 0000000..3fb372d
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/exclamation-circle.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/exclamation-triangle.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/exclamation-triangle.svg
new file mode 100644
index 0000000..2024fae
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/exclamation-triangle.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/eye-dropper.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/eye-dropper.svg
new file mode 100644
index 0000000..a6cd394
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/eye-dropper.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/eye-slash.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/eye-slash.svg
new file mode 100644
index 0000000..f067f2a
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/eye-slash.svg
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/eye.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/eye.svg
new file mode 100644
index 0000000..675bb24
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/eye.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/face-frown.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/face-frown.svg
new file mode 100644
index 0000000..06fa63c
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/face-frown.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/face-smile.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/face-smile.svg
new file mode 100644
index 0000000..0231474
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/face-smile.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/film.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/film.svg
new file mode 100644
index 0000000..f94903c
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/film.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/finger-print.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/finger-print.svg
new file mode 100644
index 0000000..5e4955a
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/finger-print.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/fire.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/fire.svg
new file mode 100644
index 0000000..f61bdc1
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/fire.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/flag.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/flag.svg
new file mode 100644
index 0000000..97e5377
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/flag.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/folder-arrow-down.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/folder-arrow-down.svg
new file mode 100644
index 0000000..f6b5e27
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/folder-arrow-down.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/folder-minus.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/folder-minus.svg
new file mode 100644
index 0000000..6bf869b
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/folder-minus.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/folder-open.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/folder-open.svg
new file mode 100644
index 0000000..e81ebee
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/folder-open.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/folder-plus.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/folder-plus.svg
new file mode 100644
index 0000000..97ce688
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/folder-plus.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/folder.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/folder.svg
new file mode 100644
index 0000000..fa0733d
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/folder.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/forward.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/forward.svg
new file mode 100644
index 0000000..4895530
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/forward.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/funnel.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/funnel.svg
new file mode 100644
index 0000000..8fd3b26
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/funnel.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/gif.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/gif.svg
new file mode 100644
index 0000000..5047d63
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/gif.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/gift-top.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/gift-top.svg
new file mode 100644
index 0000000..96ec097
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/gift-top.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/gift.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/gift.svg
new file mode 100644
index 0000000..461b34b
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/gift.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/globe-alt.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/globe-alt.svg
new file mode 100644
index 0000000..bf5ac60
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/globe-alt.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/globe-americas.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/globe-americas.svg
new file mode 100644
index 0000000..faee6a1
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/globe-americas.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/globe-asia-australia.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/globe-asia-australia.svg
new file mode 100644
index 0000000..2883841
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/globe-asia-australia.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/globe-europe-africa.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/globe-europe-africa.svg
new file mode 100644
index 0000000..9b1ad10
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/globe-europe-africa.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/h1.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/h1.svg
new file mode 100644
index 0000000..56c22b1
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/h1.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/h2.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/h2.svg
new file mode 100644
index 0000000..5acc000
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/h2.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/h3.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/h3.svg
new file mode 100644
index 0000000..904df6e
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/h3.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/hand-raised.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/hand-raised.svg
new file mode 100644
index 0000000..ad64f49
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/hand-raised.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/hand-thumb-down.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/hand-thumb-down.svg
new file mode 100644
index 0000000..4a4996d
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/hand-thumb-down.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/hand-thumb-up.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/hand-thumb-up.svg
new file mode 100644
index 0000000..cb2396b
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/hand-thumb-up.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/hashtag.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/hashtag.svg
new file mode 100644
index 0000000..7bf54c2
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/hashtag.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/heart.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/heart.svg
new file mode 100644
index 0000000..27c7d20
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/heart.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/home-modern.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/home-modern.svg
new file mode 100644
index 0000000..755bf33
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/home-modern.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/home.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/home.svg
new file mode 100644
index 0000000..4ee93c4
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/home.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/identification.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/identification.svg
new file mode 100644
index 0000000..03adf30
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/identification.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/inbox-arrow-down.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/inbox-arrow-down.svg
new file mode 100644
index 0000000..f593e2f
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/inbox-arrow-down.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/inbox-stack.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/inbox-stack.svg
new file mode 100644
index 0000000..646629e
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/inbox-stack.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/inbox.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/inbox.svg
new file mode 100644
index 0000000..0de433b
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/inbox.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/information-circle.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/information-circle.svg
new file mode 100644
index 0000000..46cbb61
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/information-circle.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/italic.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/italic.svg
new file mode 100644
index 0000000..15ffd3d
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/italic.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/key.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/key.svg
new file mode 100644
index 0000000..b4d50f5
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/key.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/language.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/language.svg
new file mode 100644
index 0000000..41570fe
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/language.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/lifebuoy.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/lifebuoy.svg
new file mode 100644
index 0000000..2876a9c
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/lifebuoy.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/light-bulb.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/light-bulb.svg
new file mode 100644
index 0000000..414cbf0
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/light-bulb.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/link-slash.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/link-slash.svg
new file mode 100644
index 0000000..4e28b41
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/link-slash.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/link.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/link.svg
new file mode 100644
index 0000000..0c0509b
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/link.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/list-bullet.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/list-bullet.svg
new file mode 100644
index 0000000..15b29ca
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/list-bullet.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/lock-closed.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/lock-closed.svg
new file mode 100644
index 0000000..08ff114
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/lock-closed.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/lock-open.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/lock-open.svg
new file mode 100644
index 0000000..1fde51e
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/lock-open.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/magnifying-glass-circle.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/magnifying-glass-circle.svg
new file mode 100644
index 0000000..eb482b3
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/magnifying-glass-circle.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/magnifying-glass-minus.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/magnifying-glass-minus.svg
new file mode 100644
index 0000000..b63c1a6
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/magnifying-glass-minus.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/magnifying-glass-plus.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/magnifying-glass-plus.svg
new file mode 100644
index 0000000..3a896f4
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/magnifying-glass-plus.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/magnifying-glass.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/magnifying-glass.svg
new file mode 100644
index 0000000..49657ff
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/magnifying-glass.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/map-pin.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/map-pin.svg
new file mode 100644
index 0000000..15e9fff
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/map-pin.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/map.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/map.svg
new file mode 100644
index 0000000..c8b9dbd
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/map.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/megaphone.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/megaphone.svg
new file mode 100644
index 0000000..8d250a4
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/megaphone.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/microphone.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/microphone.svg
new file mode 100644
index 0000000..db719be
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/microphone.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/minus-circle.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/minus-circle.svg
new file mode 100644
index 0000000..4848ae5
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/minus-circle.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/minus-small.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/minus-small.svg
new file mode 100644
index 0000000..0c98430
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/minus-small.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/minus.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/minus.svg
new file mode 100644
index 0000000..f0853c4
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/minus.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/moon.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/moon.svg
new file mode 100644
index 0000000..21421e1
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/moon.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/musical-note.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/musical-note.svg
new file mode 100644
index 0000000..2647ae2
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/musical-note.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/newspaper.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/newspaper.svg
new file mode 100644
index 0000000..dd093eb
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/newspaper.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/no-symbol.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/no-symbol.svg
new file mode 100644
index 0000000..214f8d0
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/no-symbol.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/numbered-list.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/numbered-list.svg
new file mode 100644
index 0000000..e612e5d
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/numbered-list.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/paint-brush.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/paint-brush.svg
new file mode 100644
index 0000000..fd66d60
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/paint-brush.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/paper-airplane.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/paper-airplane.svg
new file mode 100644
index 0000000..fe0dba2
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/paper-airplane.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/paper-clip.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/paper-clip.svg
new file mode 100644
index 0000000..7b84732
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/paper-clip.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/pause-circle.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/pause-circle.svg
new file mode 100644
index 0000000..39d5c9b
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/pause-circle.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/pause.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/pause.svg
new file mode 100644
index 0000000..58b58c5
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/pause.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/pencil-square.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/pencil-square.svg
new file mode 100644
index 0000000..05f70cf
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/pencil-square.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/pencil.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/pencil.svg
new file mode 100644
index 0000000..4b9897a
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/pencil.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/percent-badge.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/percent-badge.svg
new file mode 100644
index 0000000..eeb025e
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/percent-badge.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/phone-arrow-down-left.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/phone-arrow-down-left.svg
new file mode 100644
index 0000000..ccba05f
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/phone-arrow-down-left.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/phone-arrow-up-right.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/phone-arrow-up-right.svg
new file mode 100644
index 0000000..8aff3e8
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/phone-arrow-up-right.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/phone-x-mark.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/phone-x-mark.svg
new file mode 100644
index 0000000..2858b73
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/phone-x-mark.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/phone.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/phone.svg
new file mode 100644
index 0000000..2e9b05a
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/phone.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/photo.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/photo.svg
new file mode 100644
index 0000000..bb42a4d
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/photo.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/play-circle.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/play-circle.svg
new file mode 100644
index 0000000..d6a90c5
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/play-circle.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/play-pause.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/play-pause.svg
new file mode 100644
index 0000000..088f26a
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/play-pause.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/play.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/play.svg
new file mode 100644
index 0000000..d177bab
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/play.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/plus-circle.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/plus-circle.svg
new file mode 100644
index 0000000..8e2d74d
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/plus-circle.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/plus-small.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/plus-small.svg
new file mode 100644
index 0000000..18096b9
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/plus-small.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/plus.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/plus.svg
new file mode 100644
index 0000000..3d22cdd
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/plus.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/power.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/power.svg
new file mode 100644
index 0000000..ec3eb81
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/power.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/presentation-chart-bar.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/presentation-chart-bar.svg
new file mode 100644
index 0000000..637b71e
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/presentation-chart-bar.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/presentation-chart-line.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/presentation-chart-line.svg
new file mode 100644
index 0000000..6ebdcef
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/presentation-chart-line.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/printer.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/printer.svg
new file mode 100644
index 0000000..eb04c85
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/printer.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/puzzle-piece.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/puzzle-piece.svg
new file mode 100644
index 0000000..e519678
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/puzzle-piece.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/qr-code.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/qr-code.svg
new file mode 100644
index 0000000..6be372e
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/qr-code.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/question-mark-circle.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/question-mark-circle.svg
new file mode 100644
index 0000000..94150f1
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/question-mark-circle.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/queue-list.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/queue-list.svg
new file mode 100644
index 0000000..7f45d2f
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/queue-list.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/radio.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/radio.svg
new file mode 100644
index 0000000..aab76e8
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/radio.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/receipt-percent.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/receipt-percent.svg
new file mode 100644
index 0000000..319028f
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/receipt-percent.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/receipt-refund.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/receipt-refund.svg
new file mode 100644
index 0000000..c48dd03
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/receipt-refund.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/rectangle-group.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/rectangle-group.svg
new file mode 100644
index 0000000..fb09df7
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/rectangle-group.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/rectangle-stack.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/rectangle-stack.svg
new file mode 100644
index 0000000..c505e47
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/rectangle-stack.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/rocket-launch.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/rocket-launch.svg
new file mode 100644
index 0000000..0571660
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/rocket-launch.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/rss.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/rss.svg
new file mode 100644
index 0000000..d44d001
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/rss.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/scale.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/scale.svg
new file mode 100644
index 0000000..2287ace
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/scale.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/scissors.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/scissors.svg
new file mode 100644
index 0000000..33d2770
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/scissors.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/server-stack.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/server-stack.svg
new file mode 100644
index 0000000..9d9ee19
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/server-stack.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/server.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/server.svg
new file mode 100644
index 0000000..ea22cde
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/server.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/share.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/share.svg
new file mode 100644
index 0000000..aecf6a3
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/share.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/shield-check.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/shield-check.svg
new file mode 100644
index 0000000..7a74601
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/shield-check.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/shield-exclamation.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/shield-exclamation.svg
new file mode 100644
index 0000000..94dd996
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/shield-exclamation.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/shopping-bag.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/shopping-bag.svg
new file mode 100644
index 0000000..87a0e41
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/shopping-bag.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/shopping-cart.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/shopping-cart.svg
new file mode 100644
index 0000000..43f8602
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/shopping-cart.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/signal-slash.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/signal-slash.svg
new file mode 100644
index 0000000..ee7bc2c
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/signal-slash.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/signal.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/signal.svg
new file mode 100644
index 0000000..6034aec
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/signal.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/slash.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/slash.svg
new file mode 100644
index 0000000..29b1378
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/slash.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/sparkles.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/sparkles.svg
new file mode 100644
index 0000000..9c38ace
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/sparkles.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/speaker-wave.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/speaker-wave.svg
new file mode 100644
index 0000000..2366120
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/speaker-wave.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/speaker-x-mark.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/speaker-x-mark.svg
new file mode 100644
index 0000000..47637aa
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/speaker-x-mark.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/square-2-stack.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/square-2-stack.svg
new file mode 100644
index 0000000..4bbdc7e
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/square-2-stack.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/square-3-stack-3d.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/square-3-stack-3d.svg
new file mode 100644
index 0000000..50c33a9
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/square-3-stack-3d.svg
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/squares-2x2.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/squares-2x2.svg
new file mode 100644
index 0000000..84651b9
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/squares-2x2.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/squares-plus.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/squares-plus.svg
new file mode 100644
index 0000000..f5b9779
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/squares-plus.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/star.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/star.svg
new file mode 100644
index 0000000..875d16f
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/star.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/stop-circle.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/stop-circle.svg
new file mode 100644
index 0000000..a2d87ea
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/stop-circle.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/stop.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/stop.svg
new file mode 100644
index 0000000..56cdb3e
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/stop.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/strikethrough.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/strikethrough.svg
new file mode 100644
index 0000000..56fcfb0
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/strikethrough.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/sun.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/sun.svg
new file mode 100644
index 0000000..afca1bd
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/sun.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/swatch.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/swatch.svg
new file mode 100644
index 0000000..404c0bb
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/swatch.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/table-cells.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/table-cells.svg
new file mode 100644
index 0000000..da3bcaf
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/table-cells.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/tag.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/tag.svg
new file mode 100644
index 0000000..2969e32
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/tag.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/ticket.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/ticket.svg
new file mode 100644
index 0000000..1bb654c
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/ticket.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/trash.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/trash.svg
new file mode 100644
index 0000000..8791a17
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/trash.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/trophy.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/trophy.svg
new file mode 100644
index 0000000..f8a5c8c
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/trophy.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/truck.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/truck.svg
new file mode 100644
index 0000000..c919e58
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/truck.svg
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/tv.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/tv.svg
new file mode 100644
index 0000000..2b33fba
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/tv.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/underline.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/underline.svg
new file mode 100644
index 0000000..c6fef0e
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/underline.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/user-circle.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/user-circle.svg
new file mode 100644
index 0000000..26bc7ff
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/user-circle.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/user-group.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/user-group.svg
new file mode 100644
index 0000000..36c5e38
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/user-group.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/user-minus.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/user-minus.svg
new file mode 100644
index 0000000..4fedc83
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/user-minus.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/user-plus.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/user-plus.svg
new file mode 100644
index 0000000..eabe7c7
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/user-plus.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/user.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/user.svg
new file mode 100644
index 0000000..194979b
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/user.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/users.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/users.svg
new file mode 100644
index 0000000..d098e72
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/users.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/variable.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/variable.svg
new file mode 100644
index 0000000..922baf0
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/variable.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/video-camera-slash.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/video-camera-slash.svg
new file mode 100644
index 0000000..96ef401
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/video-camera-slash.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/video-camera.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/video-camera.svg
new file mode 100644
index 0000000..7749fcd
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/video-camera.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/view-columns.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/view-columns.svg
new file mode 100644
index 0000000..352e81a
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/view-columns.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/viewfinder-circle.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/viewfinder-circle.svg
new file mode 100644
index 0000000..fc68de6
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/viewfinder-circle.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/wallet.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/wallet.svg
new file mode 100644
index 0000000..57b676a
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/wallet.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/wifi.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/wifi.svg
new file mode 100644
index 0000000..b05d487
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/wifi.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/window.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/window.svg
new file mode 100644
index 0000000..0f683fb
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/window.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/wrench-screwdriver.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/wrench-screwdriver.svg
new file mode 100644
index 0000000..bb691b9
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/wrench-screwdriver.svg
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/wrench.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/wrench.svg
new file mode 100644
index 0000000..7ad17c8
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/wrench.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/x-circle.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/x-circle.svg
new file mode 100644
index 0000000..d510ee1
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/x-circle.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/x-mark.svg b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/x-mark.svg
new file mode 100644
index 0000000..5561f00
--- /dev/null
+++ b/apps/elektrine/assets/vendor/heroicons/optimized/24/solid/x-mark.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/apps/elektrine/assets/vendor/topbar.js b/apps/elektrine/assets/vendor/topbar.js
new file mode 100644
index 0000000..4195727
--- /dev/null
+++ b/apps/elektrine/assets/vendor/topbar.js
@@ -0,0 +1,165 @@
+/**
+ * @license MIT
+ * topbar 2.0.0, 2023-02-04
+ * https://buunguyen.github.io/topbar
+ * Copyright (c) 2021 Buu Nguyen
+ */
+(function (window, document) {
+ "use strict";
+
+ // https://gist.github.com/paulirish/1579671
+ (function () {
+ var lastTime = 0;
+ var vendors = ["ms", "moz", "webkit", "o"];
+ for (var x = 0; x < vendors.length && !window.requestAnimationFrame; ++x) {
+ window.requestAnimationFrame =
+ window[vendors[x] + "RequestAnimationFrame"];
+ window.cancelAnimationFrame =
+ window[vendors[x] + "CancelAnimationFrame"] ||
+ window[vendors[x] + "CancelRequestAnimationFrame"];
+ }
+ if (!window.requestAnimationFrame)
+ window.requestAnimationFrame = function (callback, element) {
+ var currTime = new Date().getTime();
+ var timeToCall = Math.max(0, 16 - (currTime - lastTime));
+ var id = window.setTimeout(function () {
+ callback(currTime + timeToCall);
+ }, timeToCall);
+ lastTime = currTime + timeToCall;
+ return id;
+ };
+ if (!window.cancelAnimationFrame)
+ window.cancelAnimationFrame = function (id) {
+ clearTimeout(id);
+ };
+ })();
+
+ var canvas,
+ currentProgress,
+ showing,
+ progressTimerId = null,
+ fadeTimerId = null,
+ delayTimerId = null,
+ addEvent = function (elem, type, handler) {
+ if (elem.addEventListener) elem.addEventListener(type, handler, false);
+ else if (elem.attachEvent) elem.attachEvent("on" + type, handler);
+ else elem["on" + type] = handler;
+ },
+ options = {
+ autoRun: true,
+ barThickness: 3,
+ barColors: {
+ 0: "rgba(26, 188, 156, .9)",
+ ".25": "rgba(52, 152, 219, .9)",
+ ".50": "rgba(241, 196, 15, .9)",
+ ".75": "rgba(230, 126, 34, .9)",
+ "1.0": "rgba(211, 84, 0, .9)",
+ },
+ shadowBlur: 10,
+ shadowColor: "rgba(0, 0, 0, .6)",
+ className: null,
+ },
+ repaint = function () {
+ canvas.width = window.innerWidth;
+ canvas.height = options.barThickness * 5; // need space for shadow
+
+ var ctx = canvas.getContext("2d");
+ ctx.shadowBlur = options.shadowBlur;
+ ctx.shadowColor = options.shadowColor;
+
+ var lineGradient = ctx.createLinearGradient(0, 0, canvas.width, 0);
+ for (var stop in options.barColors)
+ lineGradient.addColorStop(stop, options.barColors[stop]);
+ ctx.lineWidth = options.barThickness;
+ ctx.beginPath();
+ ctx.moveTo(0, options.barThickness / 2);
+ ctx.lineTo(
+ Math.ceil(currentProgress * canvas.width),
+ options.barThickness / 2
+ );
+ ctx.strokeStyle = lineGradient;
+ ctx.stroke();
+ },
+ createCanvas = function () {
+ canvas = document.createElement("canvas");
+ var style = canvas.style;
+ style.position = "fixed";
+ style.top = style.left = style.right = style.margin = style.padding = 0;
+ style.zIndex = 100001;
+ style.display = "none";
+ if (options.className) canvas.classList.add(options.className);
+ document.body.appendChild(canvas);
+ addEvent(window, "resize", repaint);
+ },
+ topbar = {
+ config: function (opts) {
+ for (var key in opts)
+ if (options.hasOwnProperty(key)) options[key] = opts[key];
+ },
+ show: function (delay) {
+ if (showing) return;
+ if (delay) {
+ if (delayTimerId) return;
+ delayTimerId = setTimeout(() => topbar.show(), delay);
+ } else {
+ showing = true;
+ if (fadeTimerId !== null) window.cancelAnimationFrame(fadeTimerId);
+ if (!canvas) createCanvas();
+ canvas.style.opacity = 1;
+ canvas.style.display = "block";
+ topbar.progress(0);
+ if (options.autoRun) {
+ (function loop() {
+ progressTimerId = window.requestAnimationFrame(loop);
+ topbar.progress(
+ "+" + 0.05 * Math.pow(1 - Math.sqrt(currentProgress), 2)
+ );
+ })();
+ }
+ }
+ },
+ progress: function (to) {
+ if (typeof to === "undefined") return currentProgress;
+ if (typeof to === "string") {
+ to =
+ (to.indexOf("+") >= 0 || to.indexOf("-") >= 0
+ ? currentProgress
+ : 0) + parseFloat(to);
+ }
+ currentProgress = to > 1 ? 1 : to;
+ repaint();
+ return currentProgress;
+ },
+ hide: function () {
+ clearTimeout(delayTimerId);
+ delayTimerId = null;
+ if (!showing) return;
+ showing = false;
+ if (progressTimerId != null) {
+ window.cancelAnimationFrame(progressTimerId);
+ progressTimerId = null;
+ }
+ (function loop() {
+ if (topbar.progress("+.1") >= 1) {
+ canvas.style.opacity -= 0.05;
+ if (canvas.style.opacity <= 0.05) {
+ canvas.style.display = "none";
+ fadeTimerId = null;
+ return;
+ }
+ }
+ fadeTimerId = window.requestAnimationFrame(loop);
+ })();
+ },
+ };
+
+ if (typeof module === "object" && typeof module.exports === "object") {
+ module.exports = topbar;
+ } else if (typeof define === "function" && define.amd) {
+ define(function () {
+ return topbar;
+ });
+ } else {
+ this.topbar = topbar;
+ }
+}.call(this, window, document));
diff --git a/apps/elektrine/lib/elektrine.ex b/apps/elektrine/lib/elektrine.ex
new file mode 100644
index 0000000..98d82c0
--- /dev/null
+++ b/apps/elektrine/lib/elektrine.ex
@@ -0,0 +1,9 @@
+defmodule Elektrine do
+ @moduledoc """
+ Elektrine keeps the contexts that define your domain
+ and business logic.
+
+ Contexts are also responsible for managing your data, regardless
+ if it comes from the database, an external API or others.
+ """
+end
diff --git a/apps/elektrine/lib/elektrine/account_identifiers.ex b/apps/elektrine/lib/elektrine/account_identifiers.ex
new file mode 100644
index 0000000..74732db
--- /dev/null
+++ b/apps/elektrine/lib/elektrine/account_identifiers.ex
@@ -0,0 +1,58 @@
+defmodule Elektrine.AccountIdentifiers do
+ @moduledoc """
+ Shared helpers for rendering local account handles and public contact addresses.
+ """
+
+ alias Elektrine.Domains
+
+ def local_handle(%{} = user) do
+ user
+ |> Map.get(:handle)
+ |> case do
+ value when is_binary(value) ->
+ case String.trim(value) do
+ "" -> Map.get(user, :username)
+ trimmed -> trimmed
+ end
+
+ _ ->
+ Map.get(user, :username)
+ end
+ |> local_handle()
+ end
+
+ def local_handle(value) when is_binary(value) do
+ case String.trim(value) do
+ "" -> nil
+ identifier -> identifier <> "@" <> Domains.default_user_handle_domain()
+ end
+ end
+
+ def local_handle(_), do: nil
+
+ def at_local_handle(user_or_value) do
+ case local_handle(user_or_value) do
+ value when is_binary(value) -> "@" <> value
+ _ -> nil
+ end
+ end
+
+ def public_contact_email(%{} = user) do
+ case Map.get(user, :username) do
+ username when is_binary(username) and username != "" ->
+ String.trim(username) <> "@" <> Domains.default_user_handle_domain()
+
+ _ ->
+ nil
+ end
+ end
+
+ def public_contact_email(_), do: nil
+
+ def public_contact_mailto(user) do
+ case public_contact_email(user) do
+ email when is_binary(email) -> "mailto:" <> email
+ _ -> nil
+ end
+ end
+end
diff --git a/apps/elektrine/lib/elektrine/accounts.ex b/apps/elektrine/lib/elektrine/accounts.ex
new file mode 100644
index 0000000..0b85dd4
--- /dev/null
+++ b/apps/elektrine/lib/elektrine/accounts.ex
@@ -0,0 +1,1716 @@
+defmodule Elektrine.Accounts do
+ @moduledoc """
+ The Accounts context.
+ Handles user accounts, authentication, and related functionality.
+
+ This module serves as the main entry point for account-related operations
+ and delegates to specialized sub-contexts for specific functionality:
+
+ - `Elektrine.Accounts.Authentication` - Password and 2FA authentication
+ - `Elektrine.Accounts.Blocking` - User blocking functionality
+ - `Elektrine.Accounts.Muting` - User muting functionality
+ - `Elektrine.Accounts.Moderation` - Admin moderation operations
+ - `Elektrine.Accounts.Tracking` - User activity tracking
+ """
+
+ import Ecto.Query, warn: false
+ alias Elektrine.Repo
+
+ alias Elektrine.Accounts.Cached
+ alias Elektrine.Accounts.ConnectedAccount
+ alias Elektrine.Accounts.InviteCode
+ alias Elektrine.Accounts.InviteCodeUse
+ alias Elektrine.Accounts.User
+ alias Elektrine.Accounts.UsernameHistory
+ alias Elektrine.Accounts.UserSession
+ alias Elektrine.Async
+ alias Elektrine.Platform.Modules
+
+ # Import sub-context modules for delegation
+ alias Elektrine.Accounts.AccountNotes
+ alias Elektrine.Accounts.Authentication
+ alias Elektrine.Accounts.Blocking
+ alias Elektrine.Accounts.Endorsements
+ alias Elektrine.Accounts.Moderation
+ alias Elektrine.Accounts.Muting
+ alias Elektrine.Accounts.Subscriptions
+ alias Elektrine.Accounts.Tracking
+ alias Elektrine.Subscriptions.RegistrationCheckout
+
+ require Logger
+
+ @self_service_invite_active_limit 5
+ @self_service_invite_monthly_generation_limit 5
+ @self_service_invite_monthly_use_limit 5
+ @self_service_invite_max_uses 1
+ @self_service_invite_expiry_days 14
+ @seconds_per_day 86_400
+ @activitypub_actor_update_fields [:avatar]
+
+ ## Core User Management
+
+ @doc "Lists external OAuth/OIDC accounts connected to a user."
+ def list_connected_accounts(user_id) when is_integer(user_id) do
+ ConnectedAccount
+ |> where([account], account.user_id == ^user_id)
+ |> order_by([account], asc: account.provider, asc: account.username)
+ |> Repo.all()
+ end
+
+ def list_connected_accounts(_), do: []
+
+ @doc "Gets a connected account by provider identity."
+ def get_connected_account_by_provider(provider, provider_account_id)
+ when is_binary(provider) and is_binary(provider_account_id) do
+ Repo.get_by(ConnectedAccount,
+ provider: provider |> String.trim() |> String.downcase(),
+ provider_account_id: provider_account_id |> String.trim() |> String.downcase()
+ )
+ end
+
+ @doc "Creates or updates a user's connected provider account after OAuth succeeds."
+ def upsert_connected_account(%User{} = user, attrs) when is_map(attrs) do
+ user_id = user.id
+
+ attrs =
+ attrs
+ |> stringify_account_attrs()
+ |> Map.put("user_id", user_id)
+ |> Map.put_new("last_verified_at", DateTime.utc_now() |> DateTime.truncate(:second))
+
+ case get_connected_account_by_provider(attrs["provider"], attrs["provider_account_id"]) do
+ %ConnectedAccount{user_id: ^user_id} = connected_account ->
+ connected_account
+ |> ConnectedAccount.changeset(attrs)
+ |> Repo.update()
+
+ %ConnectedAccount{} ->
+ {:error, :provider_account_already_connected}
+
+ nil ->
+ %ConnectedAccount{}
+ |> ConnectedAccount.changeset(attrs)
+ |> Repo.insert()
+ end
+ end
+
+ defp stringify_account_attrs(attrs) do
+ Map.new(attrs, fn {key, value} -> {to_string(key), value} end)
+ end
+
+ defp stringify_session_attrs(attrs), do: stringify_account_attrs(attrs)
+
+ @doc """
+ Returns the list of users.
+
+ ## Examples
+
+ iex> list_users()
+ [%User{}, ...]
+
+ """
+ def list_users do
+ from(u in User,
+ order_by: [desc: u.inserted_at],
+ select: [
+ :id,
+ :username,
+ :is_admin,
+ :banned,
+ :inserted_at,
+ :registration_ip,
+ :last_login_ip,
+ :last_login_at,
+ :login_count
+ ]
+ )
+ |> Repo.all()
+ end
+
+ @doc """
+ Searches for users by username or display name, excluding the current user.
+ """
+ def search_users(query, current_user_id) do
+ # Sanitize search term to prevent LIKE pattern injection
+ safe_query = sanitize_search_term(query)
+ query_term = "%#{String.downcase(safe_query)}%"
+
+ from(u in User,
+ where: u.id != ^current_user_id,
+ where:
+ fragment("LOWER(?) LIKE ?", u.username, ^query_term) or
+ fragment("LOWER(?) LIKE ?", u.display_name, ^query_term) or
+ fragment("LOWER(?) LIKE ?", u.handle, ^query_term),
+ order_by: [asc: u.username],
+ limit: 10,
+ select: [:id, :username, :display_name, :avatar, :handle]
+ )
+ |> Repo.all()
+ end
+
+ # Sanitize search terms to prevent LIKE pattern injection
+ defp sanitize_search_term(term), do: Elektrine.TextHelpers.sanitize_search_term(term)
+
+ @doc """
+ Gets a single user.
+
+ Raises `Ecto.NoResultsError` if the User does not exist.
+
+ ## Examples
+
+ iex> get_user!(123)
+ %User{}
+
+ iex> get_user!(456)
+ ** (Ecto.NoResultsError)
+
+ """
+ def get_user!(id), do: Repo.get!(User, id)
+
+ @doc """
+ Gets only the fields needed to validate a signed web session.
+
+ This stays intentionally narrow because it runs on hot request paths. Callers
+ that need the full user should load it separately after validating the stamp.
+ """
+ def get_user_session_stamp(id) when is_integer(id) do
+ from(u in User,
+ where: u.id == ^id,
+ select: %{
+ id: u.id,
+ is_admin: u.is_admin,
+ banned: u.banned,
+ suspended: u.suspended,
+ suspended_until: u.suspended_until,
+ last_password_change: u.last_password_change,
+ auth_valid_after: u.auth_valid_after
+ }
+ )
+ |> Repo.one()
+ end
+
+ def get_user_session_stamp(_), do: nil
+
+ @doc """
+ Creates a tracked browser session for a successful sign-in.
+ """
+ def create_user_session(user_or_id, attrs \\ %{})
+
+ def create_user_session(%User{id: user_id}, attrs) when is_map(attrs) do
+ create_user_session(user_id, attrs)
+ end
+
+ def create_user_session(user_id, attrs) when is_integer(user_id) and is_map(attrs) do
+ %UserSession{}
+ |> UserSession.changeset(Map.put(stringify_session_attrs(attrs), "user_id", user_id))
+ |> Repo.insert()
+ end
+
+ @doc """
+ Lists recent browser sessions for account security settings.
+ """
+ def list_user_sessions(user_id) when is_integer(user_id) do
+ UserSession
+ |> where([session], session.user_id == ^user_id)
+ |> order_by([session],
+ asc: fragment("? IS NOT NULL", session.revoked_at),
+ desc: session.last_seen_at,
+ desc: session.inserted_at
+ )
+ |> limit(50)
+ |> Repo.all()
+ end
+
+ def list_user_sessions(_), do: []
+
+ def get_active_user_session(user_id, session_id)
+ when is_integer(user_id) and is_integer(session_id) do
+ UserSession
+ |> where([session], session.id == ^session_id)
+ |> where([session], session.user_id == ^user_id)
+ |> where([session], is_nil(session.revoked_at))
+ |> Repo.one()
+ end
+
+ def get_active_user_session(_user_id, _session_id), do: nil
+
+ def touch_user_session(%UserSession{} = session) do
+ touch_user_session(session.id)
+ end
+
+ def touch_user_session(session_id) when is_integer(session_id) do
+ now = DateTime.utc_now() |> DateTime.truncate(:second)
+ cutoff = DateTime.add(now, -5 * 60, :second)
+
+ from(session in UserSession,
+ where:
+ session.id == ^session_id and is_nil(session.revoked_at) and
+ session.last_seen_at < ^cutoff
+ )
+ |> Repo.update_all(set: [last_seen_at: now])
+ end
+
+ def touch_user_session(_session_id), do: {0, nil}
+
+ def revoke_user_session(user_id, session_id, reason \\ "revoked")
+
+ def revoke_user_session(user_id, session_id, reason)
+ when is_integer(user_id) and is_integer(session_id) do
+ case Repo.get_by(UserSession, id: session_id, user_id: user_id) do
+ %UserSession{} = session ->
+ session
+ |> UserSession.revoke_changeset(reason)
+ |> Repo.update()
+
+ nil ->
+ {:error, :not_found}
+ end
+ end
+
+ def revoke_user_session(_user_id, _session_id, _reason), do: {:error, :not_found}
+
+ def revoke_other_user_sessions(
+ user_id,
+ current_session_id,
+ reason \\ "signed_out_other_sessions"
+ )
+
+ def revoke_other_user_sessions(user_id, current_session_id, reason)
+ when is_integer(user_id) and is_integer(current_session_id) do
+ now = DateTime.utc_now() |> DateTime.truncate(:second)
+
+ from(session in UserSession,
+ where:
+ session.user_id == ^user_id and session.id != ^current_session_id and
+ is_nil(session.revoked_at)
+ )
+ |> Repo.update_all(set: [revoked_at: now, revoked_reason: reason])
+ end
+
+ def revoke_other_user_sessions(user_id, _current_session_id, reason) when is_integer(user_id) do
+ revoke_all_user_sessions(user_id, reason)
+ end
+
+ def revoke_all_user_sessions(user_id, reason \\ "signed_out_all_sessions")
+
+ def revoke_all_user_sessions(user_id, reason) when is_integer(user_id) do
+ now = DateTime.utc_now() |> DateTime.truncate(:second)
+
+ from(session in UserSession,
+ where: session.user_id == ^user_id and is_nil(session.revoked_at)
+ )
+ |> Repo.update_all(set: [revoked_at: now, revoked_reason: reason])
+ end
+
+ def revoke_all_user_sessions(_user_id, _reason), do: {0, nil}
+
+ @doc """
+ Gets a user by username.
+
+ Returns nil if the User does not exist.
+
+ ## Examples
+
+ iex> get_user_by_username("username")
+ %User{}
+
+ iex> get_user_by_username("nonexistent")
+ nil
+
+ """
+ def get_user_by_username(username) when is_binary(username) do
+ Repo.get_by(User, username: username)
+ end
+
+ @doc """
+ Gets a local user by the identifier used for ActivityPub actor URLs/WebFinger.
+
+ Handles are canonical for federation, but older username-based identifiers
+ still resolve for older identifiers.
+ """
+ def get_user_by_activitypub_identifier(identifier) when is_binary(identifier) do
+ normalized_identifier =
+ identifier
+ |> String.trim()
+ |> String.trim_leading("@")
+ |> String.downcase()
+
+ cond do
+ normalized_identifier == "" ->
+ nil
+
+ user = get_user_by_handle(normalized_identifier) ->
+ user
+
+ true ->
+ get_user_by_username(normalized_identifier)
+ end
+ end
+
+ def get_user_by_activitypub_identifier(_), do: nil
+
+ @doc """
+ Gets a user by username or handle (case-insensitive for handle).
+ Useful for @mentions where users might use either.
+ """
+ def get_user_by_username_or_handle(identifier) when is_binary(identifier) do
+ # Try username first (exact match)
+ case Repo.get_by(User, username: identifier) do
+ nil ->
+ # Try handle (case-insensitive)
+ Repo.one(
+ from u in User, where: fragment("lower(?)", u.handle) == ^String.downcase(identifier)
+ )
+
+ user ->
+ user
+ end
+ end
+
+ @doc """
+ Creates a user.
+
+ ## Examples
+
+ iex> create_user(%{field: value})
+ {:ok, %User{}}
+
+ iex> create_user(%{field: bad_value})
+ {:error, %Ecto.Changeset{}}
+
+ """
+ def create_user(attrs \\ %{}) do
+ result =
+ %User{}
+ |> User.registration_changeset(attrs)
+ |> Repo.insert()
+
+ case result do
+ {:ok, user} ->
+ maybe_create_user_mailbox(user)
+
+ # Keys will be generated lazily when first needed for federation
+
+ # Reload user to get database default values (onboarding_completed, etc.)
+ user = Repo.get!(User, user.id)
+
+ {:ok, user}
+
+ error ->
+ error
+ end
+ end
+
+ @doc """
+ Updates a user.
+
+ ## Examples
+
+ iex> update_user(user, %{field: new_value})
+ {:ok, %User{}}
+
+ iex> update_user(user, %{field: bad_value})
+ {:error, %Ecto.Changeset{}}
+
+ """
+ def update_user(%User{} = user, attrs) do
+ attrs = sanitize_update_attrs(attrs)
+ changeset = User.changeset(user, attrs)
+
+ case Repo.update(changeset) do
+ {:ok, updated_user} ->
+ # Record username history and update mailbox if username changed
+ case Ecto.Changeset.get_change(changeset, :username) do
+ nil ->
+ :ok
+
+ new_username ->
+ record_username_change(user.id, user.username, new_username)
+ update_mailbox_email_for_username_change(updated_user)
+ # Clear lookup caches keyed by the old username
+ Cached.invalidate_user_cache(user)
+ end
+
+ maybe_federate_actor_update(updated_user, changeset)
+ Cached.invalidate_user_cache(updated_user)
+ {:ok, updated_user}
+
+ error ->
+ error
+ end
+ end
+
+ @doc """
+ Updates notification-only user settings without validating unrelated profile fields.
+ """
+ def update_notification_settings(%User{} = user, attrs) do
+ attrs = sanitize_update_attrs(attrs)
+
+ user
+ |> User.notification_settings_changeset(attrs)
+ |> Repo.update()
+ |> invalidate_on_update()
+ end
+
+ defp invalidate_on_update({:ok, %User{} = user} = result) do
+ Cached.invalidate_user_cache(user)
+ result
+ end
+
+ defp invalidate_on_update(result), do: result
+
+ defp sanitize_update_attrs(attrs) when is_map(attrs) do
+ Map.new(attrs, fn {key, value} -> {key, sanitize_update_value(value)} end)
+ end
+
+ defp sanitize_update_attrs(attrs), do: attrs
+
+ defp sanitize_update_value(value) when is_binary(value) do
+ value
+ |> ensure_valid_update_utf8()
+ |> String.replace(<<0>>, "")
+ end
+
+ defp sanitize_update_value(%_{} = value), do: value
+
+ defp sanitize_update_value(value) when is_map(value), do: sanitize_update_attrs(value)
+
+ defp sanitize_update_value(value) when is_list(value),
+ do: Enum.map(value, &sanitize_update_value/1)
+
+ defp sanitize_update_value(value), do: value
+
+ defp ensure_valid_update_utf8(value) when is_binary(value) do
+ if String.valid?(value) do
+ value
+ else
+ case :unicode.characters_to_binary(value, :latin1, :utf8) do
+ converted when is_binary(converted) -> converted
+ {:error, good, _bad} when is_binary(good) -> good
+ {:incomplete, good, _rest} when is_binary(good) -> good
+ _ -> ""
+ end
+ end
+ end
+
+ defp maybe_federate_actor_update(%User{activitypub_enabled: true} = user, changeset) do
+ if actor_update_field_changed?(changeset) do
+ Async.run(fn ->
+ Elektrine.ActivityPub.Outbox.federate_profile_update(user.id)
+ end)
+ end
+
+ :ok
+ end
+
+ defp maybe_federate_actor_update(_user, _changeset), do: :ok
+
+ defp actor_update_field_changed?(%Ecto.Changeset{changes: changes}) do
+ Enum.any?(@activitypub_actor_update_fields, &Map.has_key?(changes, &1))
+ end
+
+ @doc """
+ Updates the addressbook sync token (ctag) for a user.
+ Used by CardDAV for sync detection.
+ """
+ def update_addressbook_ctag(%User{} = user, ctag) do
+ user
+ |> Ecto.Changeset.change(%{addressbook_ctag: ctag})
+ |> Repo.update()
+ |> invalidate_on_update()
+ end
+
+ @doc """
+ Deletes a user and all associated data.
+
+ ## Examples
+
+ iex> delete_user(user)
+ {:ok, %User{}}
+
+ iex> delete_user(user)
+ {:error, %Ecto.Changeset{}}
+
+ """
+ def delete_user(%User{} = user) do
+ # Delete all user's data first
+ Repo.transaction(fn ->
+ maybe_delete_email_data(user)
+
+ # Finally delete the user
+ case Repo.delete(user) do
+ {:ok, user} -> user
+ {:error, changeset} -> Repo.rollback(changeset)
+ end
+ end)
+ end
+
+ @doc """
+ Returns an `%Ecto.Changeset{}` for tracking user changes.
+
+ ## Examples
+
+ iex> change_user(user)
+ %Ecto.Changeset{data: %User{}}
+
+ """
+ def change_user(%User{} = user, attrs \\ %{}) do
+ User.changeset(user, attrs)
+ end
+
+ @doc """
+ Returns an `%Ecto.Changeset{}` for tracking user registration changes.
+
+ ## Examples
+
+ iex> change_user_registration(user)
+ %Ecto.Changeset{data: %User{}}
+
+ """
+ def change_user_registration(%User{} = user, attrs \\ %{}) do
+ User.registration_changeset(user, attrs)
+ end
+
+ @doc """
+ Get user's sequential number based on account creation order.
+ Returns the position of this user in the chronological list of all users.
+ """
+ def get_user_number(user) do
+ from(u in User,
+ where: u.inserted_at <= ^user.inserted_at,
+ select: count(u.id)
+ )
+ |> Repo.one()
+ end
+
+ @doc """
+ Updates a user's locale and/or timezone preference.
+ """
+ def update_user_locale(%User{} = user, attrs) when is_map(attrs) do
+ user
+ |> User.locale_changeset(attrs)
+ |> Repo.update()
+ |> invalidate_on_update()
+ end
+
+ def update_user_locale(%User{} = user, locale) when is_binary(locale) do
+ update_user_locale(user, %{locale: locale})
+ end
+
+ ## Authentication Functions - Delegated to Authentication module
+
+ defdelegate authenticate_user(username, password), to: Authentication
+ defdelegate verify_user_password(user, password), to: Authentication
+ defdelegate update_user_password(user, attrs, opts \\ []), to: Authentication
+ defdelegate change_user_password(user, attrs \\ %{}), to: Authentication
+
+ # Two-Factor Authentication
+ defdelegate initiate_two_factor_setup(user), to: Authentication
+ defdelegate enable_two_factor(user, secret, backup_codes, totp_code), to: Authentication
+ defdelegate disable_two_factor(user), to: Authentication
+ defdelegate verify_two_factor_code(user, code), to: Authentication
+ defdelegate verify_totp_only(user, code), to: Authentication
+ defdelegate regenerate_backup_codes(user), to: Authentication
+ defdelegate admin_reset_2fa(user), to: Authentication
+
+ # App Passwords
+ defdelegate list_app_passwords(user_id), to: Authentication
+ defdelegate create_app_password(user_id, attrs), to: Authentication
+ defdelegate delete_app_password(app_password_id, user_id), to: Authentication
+ defdelegate authenticate_with_app_password(username, token), to: Authentication
+ defdelegate authenticate_with_app_password_info(username, token), to: Authentication
+ defdelegate app_password_exists?(app_password_id, user_id), to: Authentication
+ defdelegate mail_auth_subject(identifier), to: Authentication
+ defdelegate verify_app_password(user_id, token, ip_address \\ nil), to: Authentication
+
+ # Password Recovery
+ defdelegate update_recovery_email(user, attrs), to: Authentication
+ defdelegate initiate_password_reset(username_or_email), to: Authentication
+ defdelegate get_users_by_recovery_email(email), to: Authentication
+ defdelegate get_user_by_password_reset_token(token), to: Authentication
+ defdelegate reset_password_with_token(token, attrs), to: Authentication
+ defdelegate validate_password_reset_token(token), to: Authentication
+ defdelegate clear_password_reset_token(user), to: Authentication
+ defdelegate get_users_with_old_passwords(max_days), to: Authentication
+ defdelegate count_users_with_old_passwords(max_days), to: Authentication
+ defdelegate admin_reset_password(user, attrs), to: Authentication
+
+ ## Blocking Functions - Delegated to Blocking module
+
+ defdelegate block_user(blocker_id, blocked_id, reason \\ nil), to: Blocking
+ defdelegate unblock_user(blocker_id, blocked_id), to: Blocking
+ defdelegate user_blocked?(blocker_id, blocked_id), to: Blocking
+ defdelegate list_blocked_users(blocker_id), to: Blocking
+ defdelegate list_users_who_blocked(blocked_id), to: Blocking
+ defdelegate block_remote_actor(user_id, remote_actor_id), to: Blocking
+ defdelegate unblock_remote_actor(user_id, remote_actor_id), to: Blocking
+ defdelegate remote_actor_blocked?(user_id, remote_actor_id), to: Blocking
+ defdelegate list_blocked_remote_actors(user_id), to: Blocking
+ defdelegate mute_remote_actor(user_id, remote_actor_id), to: Blocking
+ defdelegate unmute_remote_actor(user_id, remote_actor_id), to: Blocking
+ defdelegate remote_actor_muted?(user_id, remote_actor_id), to: Blocking
+ defdelegate list_muted_remote_actors(user_id), to: Blocking
+ defdelegate block_domain(user_id, domain), to: Blocking
+ defdelegate unblock_domain(user_id, domain), to: Blocking
+ defdelegate list_blocked_domains(user_id), to: Blocking
+ defdelegate domain_blocked?(user_id, domain), to: Blocking
+
+ ## Muting Functions - Delegated to Muting module
+
+ defdelegate mute_user(muter_id, muted_id, mute_notifications \\ false, expires_at \\ nil),
+ to: Muting
+
+ defdelegate unmute_user(muter_id, muted_id), to: Muting
+ defdelegate user_muted?(muter_id, muted_id), to: Muting
+ defdelegate user_muting_notifications?(muter_id, muted_id), to: Muting
+ defdelegate list_muted_users(muter_id), to: Muting
+ defdelegate expire_due_mutes(now \\ Elektrine.Time.utc_now()), to: Muting
+ defdelegate account_note_comment(source_user_id, target), to: AccountNotes, as: :note_comment
+ defdelegate put_account_note(source_user_id, target, comment), to: AccountNotes, as: :put_note
+
+ defdelegate endorse_account(user_id, account), to: Endorsements
+ defdelegate unendorse_account(user_id, account), to: Endorsements
+ defdelegate account_endorsed?(user_id, account), to: Endorsements
+ defdelegate list_endorsed_accounts(user_id), to: Endorsements
+ defdelegate subscribe_to_account(user_id, account), to: Subscriptions
+ defdelegate unsubscribe_from_account(user_id, account), to: Subscriptions
+ defdelegate account_subscribed?(user_id, account), to: Subscriptions
+ defdelegate notify_subscribers_for_message(message), to: Subscriptions
+
+ def import_relationships(user_id, import_type, identifiers) when is_list(identifiers) do
+ Elektrine.Accounts.AccountImportWorker.enqueue_many(user_id, import_type, identifiers)
+ end
+
+ ## Moderation Functions - Delegated to Moderation module
+
+ defdelegate ban_user(user, attrs \\ %{}), to: Moderation
+ defdelegate unban_user(user), to: Moderation
+ defdelegate suspend_user(user, attrs), to: Moderation
+ defdelegate unsuspend_user(user), to: Moderation
+ defdelegate user_suspended?(user), to: Moderation
+ defdelegate unsuspend_expired_users(), to: Moderation
+ defdelegate update_user_admin_status(user, is_admin), to: Moderation
+ defdelegate admin_create_user(attrs \\ %{}), to: Moderation
+ defdelegate admin_update_user(user, attrs), to: Moderation
+ defdelegate admin_delete_user(user), to: Moderation
+ defdelegate change_user_admin(user, attrs \\ %{}), to: Moderation
+ defdelegate change_user_admin_registration(user, attrs \\ %{}), to: Moderation
+
+ # Account Deletion Requests
+ defdelegate create_deletion_request(user, attrs \\ %{}), to: Moderation
+ defdelegate get_pending_deletion_request(user), to: Moderation
+ defdelegate list_deletion_requests(), to: Moderation
+ defdelegate get_deletion_request!(id), to: Moderation
+ defdelegate review_deletion_request(request, admin, status, attrs \\ %{}), to: Moderation
+
+ ## Tracking Functions - Delegated to Tracking module
+
+ defdelegate update_user_login_info(user, ip_address), to: Tracking
+ defdelegate record_imap_access(user_id), to: Tracking
+ defdelegate record_pop3_access(user_id), to: Tracking
+ defdelegate update_last_seen(user_id), to: Tracking
+ defdelegate update_last_seen_async(user_id), to: Tracking
+ defdelegate update_user_status(user, status, message \\ nil), to: Tracking
+ defdelegate get_user_status(user_id), to: Tracking
+
+ @doc """
+ Invalidates existing browser/session tokens for a user.
+ """
+ def invalidate_auth_sessions(%User{} = user) do
+ case user
+ |> Ecto.Changeset.change(%{
+ auth_valid_after: DateTime.utc_now() |> DateTime.truncate(:second)
+ })
+ |> Repo.update() do
+ {:ok, updated_user} ->
+ _ = revoke_all_user_sessions(user.id, "global_invalidation")
+ {:ok, updated_user}
+
+ error ->
+ error
+ end
+ end
+
+ ## Invite Codes
+
+ @doc """
+ Returns the list of invite codes.
+ """
+ def list_invite_codes do
+ InviteCode
+ |> order_by(desc: :inserted_at)
+ |> preload(:created_by)
+ |> Repo.all()
+ end
+
+ @doc """
+ Gets a single invite code.
+
+ Returns nil if the InviteCode does not exist.
+ """
+ def get_invite_code(id), do: Repo.get(InviteCode, id) |> Repo.preload(:created_by)
+
+ @doc """
+ Gets a single invite code.
+
+ Raises if the InviteCode does not exist.
+ """
+ def get_invite_code!(id), do: Repo.get!(InviteCode, id) |> Repo.preload(:created_by)
+
+ @doc """
+ Gets an invite code by its code string.
+
+ Returns nil if the InviteCode does not exist.
+ """
+ def get_invite_code_by_code(code) do
+ normalized_code = InviteCode.normalize_code(code)
+
+ InviteCode
+ |> where([i], fragment("upper(?)", i.code) == ^normalized_code)
+ |> preload(:created_by)
+ |> Repo.one()
+ end
+
+ @doc """
+ Creates an invite code.
+ """
+ def create_invite_code(attrs \\ %{}) do
+ attrs =
+ attrs
+ |> Enum.map(fn {k, v} -> {to_string(k), v} end)
+ |> Enum.into(%{})
+
+ if blank_invite_code?(attrs["code"]) do
+ create_generated_invite_code(attrs, 3)
+ else
+ %InviteCode{}
+ |> InviteCode.create_changeset(attrs)
+ |> Repo.insert()
+ end
+ end
+
+ @doc """
+ Updates an invite code.
+ """
+ def update_invite_code(%InviteCode{} = invite_code, attrs) do
+ invite_code
+ |> InviteCode.update_changeset(attrs)
+ |> Repo.update()
+ end
+
+ @doc """
+ Deletes an invite code.
+ """
+ def delete_invite_code(%InviteCode{} = invite_code) do
+ Repo.delete(invite_code)
+ end
+
+ @doc """
+ Returns an `%Ecto.Changeset{}` for tracking invite code changes.
+ """
+ def change_invite_code(%InviteCode{} = invite_code, attrs \\ %{}) do
+ InviteCode.changeset(invite_code, attrs)
+ end
+
+ @doc """
+ Registers a new user and claims an invite code atomically.
+ """
+ def register_user_with_invite(attrs) do
+ invite_code = extract_invite_code(attrs)
+
+ Ecto.Multi.new()
+ |> Ecto.Multi.insert(:user, User.registration_changeset(%User{}, attrs))
+ |> Ecto.Multi.run(:invite_use, fn repo, %{user: user} ->
+ claim_invite_code(repo, invite_code, user.id)
+ end)
+ |> Repo.transaction()
+ |> case do
+ {:ok, %{user: user}} ->
+ maybe_create_user_mailbox(user)
+ {:ok, Repo.get!(User, user.id)}
+
+ {:error, :user, changeset, _changes_so_far} ->
+ {:error, changeset}
+
+ {:error, :invite_use, reason, _changes_so_far} ->
+ {:error, invite_registration_changeset(attrs, reason)}
+ end
+ end
+
+ @doc """
+ Registers a new user using either a valid invite code or a fulfilled paid registration checkout.
+ """
+ def register_user_with_access(attrs) do
+ invite_code = extract_invite_code(attrs)
+ registration_access_token = extract_registration_access_token(attrs)
+
+ Ecto.Multi.new()
+ |> Ecto.Multi.insert(:user, User.registration_changeset(%User{}, attrs))
+ |> Ecto.Multi.run(:registration_access, fn repo, %{user: user} ->
+ cond do
+ !blank_invite_code?(invite_code) ->
+ claim_invite_code(repo, invite_code, user.id)
+
+ !blank_registration_access_token?(registration_access_token) ->
+ claim_registration_checkout(repo, registration_access_token, user.id)
+
+ true ->
+ {:error, :invite_or_payment_required}
+ end
+ end)
+ |> Repo.transaction()
+ |> case do
+ {:ok, %{user: user}} ->
+ maybe_create_user_mailbox(user)
+ {:ok, Repo.get!(User, user.id)}
+
+ {:error, :user, changeset, _changes_so_far} ->
+ {:error, changeset}
+
+ {:error, :registration_access, reason, _changes_so_far} ->
+ {:error, access_registration_changeset(attrs, reason)}
+ end
+ end
+
+ @doc """
+ Validates and uses an invite code for a user registration.
+ """
+ def use_invite_code(code, user_id) do
+ Repo.transaction(fn ->
+ case claim_invite_code(Repo, code, user_id) do
+ {:ok, invite_code} -> invite_code
+ {:error, reason} -> Repo.rollback(reason)
+ end
+ end)
+ |> case do
+ {:ok, invite_code} -> {:ok, invite_code}
+ {:error, reason} -> {:error, reason}
+ end
+ end
+
+ @doc """
+ Gets statistics for invite codes.
+ """
+ def get_invite_code_stats do
+ now = DateTime.utc_now()
+
+ %{
+ total: Repo.aggregate(InviteCode, :count),
+ active:
+ Repo.aggregate(
+ from(i in InviteCode,
+ where:
+ i.is_active == true and
+ (is_nil(i.expires_at) or i.expires_at >= ^now) and
+ i.uses_count < i.max_uses
+ ),
+ :count
+ ),
+ expired:
+ Repo.aggregate(
+ from(i in InviteCode,
+ where: not is_nil(i.expires_at) and i.expires_at < ^now
+ ),
+ :count
+ ),
+ exhausted: Repo.aggregate(from(i in InviteCode, where: i.uses_count >= i.max_uses), :count)
+ }
+ end
+
+ @doc """
+ Returns the invite-code policy used by self-service invite creation.
+ """
+ def self_service_invite_policy do
+ %{
+ min_trust_level: Elektrine.System.self_service_invite_min_trust_level(),
+ max_active_codes: @self_service_invite_active_limit,
+ max_codes_per_month: @self_service_invite_monthly_generation_limit,
+ max_uses_per_month: @self_service_invite_monthly_use_limit,
+ max_uses: @self_service_invite_max_uses,
+ expires_in_days: @self_service_invite_expiry_days
+ }
+ end
+
+ @doc """
+ Returns whether a user can create invite codes from account settings.
+ """
+ def user_can_create_invite_codes?(%User{} = user) do
+ user.is_admin || user.trust_level >= self_service_invite_policy().min_trust_level
+ end
+
+ def user_can_create_invite_codes?(_), do: false
+
+ @doc """
+ Returns invite codes created by a specific user.
+ """
+ def list_user_invite_codes(user_id) do
+ InviteCode
+ |> where([i], i.created_by_id == ^user_id)
+ |> order_by([i], desc: i.inserted_at)
+ |> preload(:created_by)
+ |> Repo.all()
+ end
+
+ @doc """
+ Creates a constrained self-service invite code for a user.
+ """
+ def create_self_service_invite_code(%User{} = user, attrs \\ %{}) do
+ attrs =
+ attrs
+ |> Enum.map(fn {k, v} -> {to_string(k), v} end)
+ |> Enum.into(%{})
+
+ with :ok <- ensure_self_service_invites_enabled(),
+ :ok <- authorize_self_service_invite_creation(user) do
+ Repo.transaction(fn ->
+ with :ok <- ensure_self_service_invite_creation_capacity(Repo, user),
+ {:ok, invite_code} <-
+ create_invite_code(%{
+ "created_by_id" => user.id,
+ "max_uses" => @self_service_invite_max_uses,
+ "expires_at" => self_service_invite_expiration(),
+ "note" => normalize_optional_string(attrs["note"])
+ }) do
+ invite_code
+ else
+ {:error, reason} -> Repo.rollback(reason)
+ end
+ end)
+ |> case do
+ {:ok, invite_code} -> {:ok, invite_code}
+ {:error, reason} -> {:error, reason}
+ end
+ end
+ end
+
+ @doc """
+ Deactivates a self-service invite code owned by the user.
+ """
+ def deactivate_self_service_invite_code(%User{} = user, invite_code_id)
+ when is_integer(invite_code_id) do
+ case get_user_invite_code(user.id, invite_code_id) do
+ nil ->
+ {:error, :not_found}
+
+ invite_code ->
+ update_invite_code(invite_code, %{is_active: false})
+ end
+ end
+
+ ## Handle Management
+
+ @doc """
+ Get a user by handle.
+ """
+ def get_user_by_handle(handle) when is_binary(handle) do
+ Repo.one(from u in User, where: fragment("lower(?)", u.handle) == ^String.downcase(handle))
+ end
+
+ @doc """
+ Update a user's handle.
+ Enforces 30-day cooldown and 90-day reservation.
+ """
+ def update_user_handle(%User{} = user, handle) do
+ changeset = User.handle_changeset(user, %{handle: handle})
+
+ Repo.transaction(fn ->
+ case Repo.update(changeset) do
+ {:ok, updated_user} ->
+ # Record old handle in history if it changed
+ if user.handle && user.handle != updated_user.handle do
+ record_handle_history(user)
+ end
+
+ updated_user
+
+ {:error, changeset} ->
+ Repo.rollback(changeset)
+ end
+ end)
+ |> invalidate_on_update()
+ end
+
+ @doc """
+ Update a user's display name.
+ """
+ def update_user_display_name(%User{} = user, display_name) do
+ user
+ |> User.handle_changeset(%{display_name: display_name})
+ |> Repo.update()
+ |> invalidate_on_update()
+ end
+
+ @doc """
+ Check if a handle is available for claiming.
+ """
+ def handle_available?(handle) when is_binary(handle) do
+ User.handle_available?(handle)
+ end
+
+ # Check if user has priority claim (their original username)
+
+ # Force claim a handle (bump current holder if necessary)
+
+ # Generate alternative handle for displaced user
+
+ # Record handle in history for 90-day reservation
+ defp record_handle_history(%User{} = user) do
+ if user.handle do
+ # Mark previous history entry as ended
+ from(h in "handle_history",
+ where: h.user_id == ^user.id and is_nil(h.used_until),
+ update: [
+ set: [
+ used_until: ^DateTime.utc_now(),
+ reserved_until: ^(DateTime.utc_now() |> DateTime.add(90 * 24 * 60 * 60, :second))
+ ]
+ ]
+ )
+ |> Repo.update_all([])
+
+ # Create new history entry
+ Repo.insert_all("handle_history", [
+ %{
+ user_id: user.id,
+ handle: user.handle,
+ used_from: user.handle_changed_at || user.inserted_at,
+ used_until: DateTime.utc_now(),
+ reserved_until: DateTime.utc_now() |> DateTime.add(90 * 24 * 60 * 60, :second),
+ inserted_at: DateTime.utc_now(),
+ updated_at: DateTime.utc_now()
+ }
+ ])
+ end
+ end
+
+ ## Privacy Settings
+
+ @doc """
+ Checks if a user can add another user to a group/channel based on privacy settings.
+ Returns {:ok, :allowed} or {:error, :privacy_restriction}
+ """
+ def can_add_to_group?(target_user, requesting_user) do
+ check_privacy_permission(target_user, requesting_user, :allow_group_adds_from)
+ end
+
+ @doc """
+ Checks if a user can send a direct message to another user based on privacy settings.
+ Returns {:ok, :allowed} or {:error, :privacy_restriction}
+ """
+ def can_send_direct_message?(target_user, requesting_user) do
+ check_privacy_permission(target_user, requesting_user, :allow_direct_messages_from)
+ end
+
+ @doc """
+ Checks if a user can mention another user based on privacy settings.
+ Returns {:ok, :allowed} or {:error, :privacy_restriction}
+ """
+ def can_mention?(target_user, requesting_user) do
+ check_privacy_permission(target_user, requesting_user, :allow_mentions_from)
+ end
+
+ @doc """
+ Checks if a user can view another user's profile based on privacy settings.
+ Returns {:ok, :allowed} or {:error, :privacy_restriction}
+ """
+ def can_view_profile?(target_user, requesting_user) do
+ setting = Map.get(target_user, :profile_visibility, "public")
+
+ case setting do
+ "public" ->
+ {:ok, :allowed}
+
+ "followers" ->
+ cond do
+ # If no requesting user (logged out), deny access
+ requesting_user == nil ->
+ {:error, :privacy_restriction}
+
+ # If it's the user viewing their own profile, allow
+ requesting_user.id == target_user.id ->
+ {:ok, :allowed}
+
+ # Check if requesting user follows target user
+ following?(requesting_user.id, target_user.id) ->
+ {:ok, :allowed}
+
+ # Default case
+ true ->
+ {:error, :privacy_restriction}
+ end
+
+ "private" ->
+ # Only allow the user to view their own profile
+ if requesting_user && requesting_user.id == target_user.id do
+ {:ok, :allowed}
+ else
+ {:error, :privacy_restriction}
+ end
+
+ _ ->
+ # Default to public if setting is not recognized
+ {:ok, :allowed}
+ end
+ end
+
+ # Private helper function to check privacy permissions
+ defp check_privacy_permission(target_user, requesting_user, field) do
+ # Always allow users to access their own resources
+ if requesting_user && requesting_user.id == target_user.id do
+ {:ok, :allowed}
+ else
+ # If requesting user is nil (anonymous), only allow if setting is "everyone"
+ if requesting_user == nil do
+ setting = Map.get(target_user, field, "everyone")
+
+ if setting == "everyone" do
+ {:ok, :allowed}
+ else
+ {:error, :privacy_restriction}
+ end
+ else
+ # Check the privacy setting
+ setting = Map.get(target_user, field, "everyone")
+
+ case setting do
+ "everyone" ->
+ {:ok, :allowed}
+
+ "following" ->
+ # Check if target user follows the requesting user
+ if following?(target_user.id, requesting_user.id) do
+ {:ok, :allowed}
+ else
+ {:error, :privacy_restriction}
+ end
+
+ "followers" ->
+ # Check if requesting user follows the target user
+ if following?(requesting_user.id, target_user.id) do
+ {:ok, :allowed}
+ else
+ {:error, :privacy_restriction}
+ end
+
+ "mutual" ->
+ # Check if both users follow each other
+ if following?(target_user.id, requesting_user.id) &&
+ following?(requesting_user.id, target_user.id) do
+ {:ok, :allowed}
+ else
+ {:error, :privacy_restriction}
+ end
+
+ "nobody" ->
+ {:error, :privacy_restriction}
+
+ _ ->
+ # Default to most permissive for unknown values
+ {:ok, :allowed}
+ end
+ end
+ end
+ end
+
+ @doc """
+ Checks if a user is following another user.
+ """
+ def following?(follower_id, followed_id) do
+ # This assumes you have a follows/followers system.
+ query =
+ from f in "follows",
+ where: f.follower_id == ^follower_id and f.followed_id == ^followed_id,
+ select: true
+
+ Repo.exists?(query)
+ end
+
+ # Record username change in history for 1-year blocking
+ defp record_username_change(user_id, old_username, _new_username) do
+ # Record the old username being abandoned
+ history_attrs = %{
+ user_id: user_id,
+ username: old_username,
+ previous_username: nil,
+ changed_at: DateTime.utc_now() |> DateTime.truncate(:second)
+ }
+
+ case %UsernameHistory{}
+ |> UsernameHistory.changeset(history_attrs)
+ |> Repo.insert() do
+ {:ok, history_record} ->
+ {:ok, history_record}
+
+ {:error, changeset} ->
+ Logger.error("Failed to record username history: #{inspect(changeset.errors)}")
+ # Don't fail the username change if history recording fails
+ {:error, changeset}
+ end
+ end
+
+ # Updates the user's mailbox email address to match their new username.
+ # This prevents duplicate mailboxes when usernames change.
+ defp update_mailbox_email_for_username_change(user) do
+ if email_module_compiled?() do
+ case Elektrine.Email.get_user_mailbox(user.id) do
+ nil ->
+ :ok
+
+ mailbox ->
+ # Calculate the new email address based on current username
+ domain = Elektrine.Domains.primary_email_domain()
+ new_email = "#{user.username}@#{domain}"
+
+ # Only process if the email is different
+ if mailbox.email != new_email do
+ # Check if the new email conflicts with an existing mailbox
+ case Elektrine.Email.get_mailbox_by_email(new_email) do
+ nil ->
+ # New email is available - update the mailbox in place.
+ case Elektrine.Email.transition_mailbox_for_username_change(
+ user,
+ mailbox,
+ new_email
+ ) do
+ {:ok, _new_mailbox} ->
+ :ok
+
+ {:error, reason} ->
+ Logger.error(
+ "Failed to transition mailbox for user #{user.id}: #{inspect(reason)}"
+ )
+
+ :ok
+ end
+
+ _existing_mailbox ->
+ # Conflict! The new username's email is already taken
+ Logger.error(
+ "Cannot create #{new_email} for user #{user.id} - email already taken"
+ )
+
+ :ok
+ end
+ else
+ :ok
+ end
+ end
+ else
+ :ok
+ end
+ end
+
+ defp maybe_create_user_mailbox(user) do
+ if email_module_compiled?() do
+ Elektrine.Email.create_mailbox(user)
+ else
+ :ok
+ end
+ end
+
+ defp create_generated_invite_code(attrs, attempts_remaining) when attempts_remaining > 0 do
+ generated_attrs = Map.put(attrs, "code", InviteCode.generate_code())
+
+ case %InviteCode{} |> InviteCode.create_changeset(generated_attrs) |> Repo.insert() do
+ {:error, %Ecto.Changeset{} = changeset} = error ->
+ if autogenerated_code_collision?(changeset) and attempts_remaining > 1 do
+ create_generated_invite_code(attrs, attempts_remaining - 1)
+ else
+ error
+ end
+
+ success ->
+ success
+ end
+ end
+
+ defp create_generated_invite_code(attrs, _attempts_remaining) do
+ %InviteCode{}
+ |> InviteCode.create_changeset(Map.put(attrs, "code", InviteCode.generate_code()))
+ |> Repo.insert()
+ end
+
+ defp autogenerated_code_collision?(%Ecto.Changeset{} = changeset) do
+ Enum.any?(changeset.errors, fn
+ {:code, {_message, opts}} -> Keyword.get(opts, :constraint) == :unique
+ _other -> false
+ end)
+ end
+
+ defp claim_invite_code(repo, code, user_id) do
+ case get_invite_code_for_update(repo, code) do
+ nil ->
+ {:error, :invalid_code}
+
+ %InviteCode{} = invite_code ->
+ case invite_code_validation_error(invite_code) do
+ nil ->
+ with :ok <- ensure_self_service_invite_use_capacity(repo, invite_code),
+ {:ok, _use} <- insert_invite_code_use(repo, invite_code.id, user_id),
+ {1, _} <- increment_invite_code_usage(repo, invite_code.id) do
+ {:ok, %{invite_code | uses_count: invite_code.uses_count + 1}}
+ else
+ {:error, :monthly_invite_use_limit_reached} ->
+ {:error, :monthly_invite_use_limit_reached}
+
+ {:error, %Ecto.Changeset{} = changeset} ->
+ {:error, invite_code_use_error(changeset)}
+
+ {0, _} ->
+ {:error, :code_exhausted}
+ end
+
+ reason ->
+ {:error, reason}
+ end
+ end
+ end
+
+ defp get_invite_code_for_update(repo, code) do
+ normalized_code = InviteCode.normalize_code(code)
+
+ InviteCode
+ |> where([i], fragment("upper(?)", i.code) == ^normalized_code)
+ |> lock("FOR UPDATE")
+ |> repo.one()
+ end
+
+ defp invite_code_validation_error(%InviteCode{} = invite_code) do
+ cond do
+ !invite_code.is_active -> :code_inactive
+ InviteCode.expired?(invite_code) -> :code_expired
+ InviteCode.exhausted?(invite_code) -> :code_exhausted
+ true -> nil
+ end
+ end
+
+ defp insert_invite_code_use(repo, invite_code_id, user_id) do
+ %InviteCodeUse{}
+ |> InviteCodeUse.changeset(%{invite_code_id: invite_code_id, user_id: user_id})
+ |> repo.insert()
+ end
+
+ defp claim_registration_checkout(repo, lookup_token, user_id) do
+ case get_registration_checkout_for_update(repo, lookup_token) do
+ nil ->
+ {:error, :invalid_registration_access}
+
+ %RegistrationCheckout{} = checkout ->
+ case registration_checkout_validation_error(checkout) do
+ nil ->
+ checkout
+ |> RegistrationCheckout.fulfill_changeset(%{
+ redeemed_at: DateTime.utc_now() |> DateTime.truncate(:second),
+ redeemed_by_user_id: user_id
+ })
+ |> repo.update()
+ |> case do
+ {:ok, updated_checkout} -> {:ok, updated_checkout}
+ {:error, _changeset} -> {:error, :invalid_registration_access}
+ end
+
+ reason ->
+ {:error, reason}
+ end
+ end
+ end
+
+ defp get_registration_checkout_for_update(repo, lookup_token) do
+ normalized_token = String.trim(to_string(lookup_token))
+
+ RegistrationCheckout
+ |> where([c], c.lookup_token == ^RegistrationCheckout.hash_lookup_token(normalized_token))
+ |> lock("FOR UPDATE")
+ |> repo.one()
+ end
+
+ defp registration_checkout_validation_error(%RegistrationCheckout{} = checkout) do
+ cond do
+ checkout.status != "fulfilled" -> :registration_payment_pending
+ not is_nil(checkout.redeemed_at) -> :registration_access_already_used
+ true -> nil
+ end
+ end
+
+ defp increment_invite_code_usage(repo, invite_code_id) do
+ from(i in InviteCode,
+ where: i.id == ^invite_code_id and i.uses_count < i.max_uses
+ )
+ |> repo.update_all(inc: [uses_count: 1])
+ end
+
+ defp invite_code_use_error(%Ecto.Changeset{} = changeset) do
+ case Enum.find(changeset.errors, fn {field, _error} -> field == :user_id end) do
+ {:user_id, {_message, opts}} ->
+ if Keyword.get(opts, :constraint) == :unique do
+ :already_used
+ else
+ :code_not_valid
+ end
+
+ _other ->
+ :code_not_valid
+ end
+ end
+
+ defp invite_registration_changeset(attrs, reason) do
+ %User{}
+ |> User.registration_changeset(attrs)
+ |> Ecto.Changeset.add_error(:invite_code, invite_code_error_message(reason))
+ end
+
+ defp access_registration_changeset(attrs, reason) do
+ changeset = User.registration_changeset(%User{}, attrs)
+
+ case reason do
+ reason
+ when reason in [
+ :invalid_code,
+ :code_expired,
+ :code_exhausted,
+ :code_inactive,
+ :monthly_invite_use_limit_reached,
+ :already_used
+ ] ->
+ Ecto.Changeset.add_error(changeset, :invite_code, invite_code_error_message(reason))
+
+ _ ->
+ Ecto.Changeset.add_error(
+ changeset,
+ :registration_access_token,
+ registration_access_error_message(reason)
+ )
+ end
+ end
+
+ defp invite_code_error_message(:invalid_code), do: "Invalid invite code"
+ defp invite_code_error_message(:code_expired), do: "This invite code has expired"
+
+ defp invite_code_error_message(:code_exhausted),
+ do: "This invite code has reached its usage limit"
+
+ defp invite_code_error_message(:code_inactive), do: "This invite code is no longer active"
+
+ defp invite_code_error_message(:monthly_invite_use_limit_reached),
+ do: "This invite code is temporarily unavailable right now"
+
+ defp invite_code_error_message(:already_used),
+ do: "This account has already used an invite code"
+
+ defp invite_code_error_message(_reason), do: "Invalid invite code"
+
+ defp registration_access_error_message(:registration_payment_pending),
+ do: "Your payment is still being confirmed. Please refresh and try again in a moment"
+
+ defp registration_access_error_message(:registration_access_already_used),
+ do: "This paid registration access has already been used"
+
+ defp registration_access_error_message(:invite_or_payment_required),
+ do: "An invite code or paid access is required to create an account"
+
+ defp registration_access_error_message(:invalid_registration_access),
+ do: "Paid registration access could not be verified"
+
+ defp registration_access_error_message(_reason),
+ do: "An invite code or paid access is required to create an account"
+
+ defp extract_invite_code(attrs) when is_map(attrs) do
+ Map.get(attrs, "invite_code") || Map.get(attrs, :invite_code)
+ end
+
+ defp extract_registration_access_token(attrs) when is_map(attrs) do
+ Map.get(attrs, "registration_access_token") || Map.get(attrs, :registration_access_token)
+ end
+
+ defp blank_invite_code?(code) do
+ is_nil(code) or not Elektrine.Strings.present?(to_string(code))
+ end
+
+ defp blank_registration_access_token?(token) do
+ is_nil(token) or not Elektrine.Strings.present?(to_string(token))
+ end
+
+ defp ensure_self_service_invites_enabled do
+ if Elektrine.System.invite_codes_enabled?() do
+ :ok
+ else
+ {:error, :invite_codes_disabled}
+ end
+ end
+
+ defp authorize_self_service_invite_creation(%User{} = user) do
+ if user_can_create_invite_codes?(user) do
+ :ok
+ else
+ {:error, :insufficient_trust_level}
+ end
+ end
+
+ defp ensure_self_service_invite_creation_capacity(repo, %User{} = user) do
+ user =
+ case lock_user_for_update(repo, user.id) do
+ %User{} = locked_user -> locked_user
+ nil -> user
+ end
+
+ with :ok <- ensure_self_service_invite_capacity(repo, user.id) do
+ ensure_self_service_monthly_generation_capacity(repo, user)
+ end
+ end
+
+ defp ensure_self_service_invite_capacity(repo, user_id) do
+ now = DateTime.utc_now()
+
+ active_count =
+ from(i in InviteCode,
+ where:
+ i.created_by_id == ^user_id and
+ i.is_active == true and
+ (is_nil(i.expires_at) or i.expires_at >= ^now) and
+ i.uses_count < i.max_uses
+ )
+ |> repo.aggregate(:count)
+
+ if active_count < @self_service_invite_active_limit do
+ :ok
+ else
+ {:error, :invite_code_limit_reached}
+ end
+ end
+
+ defp ensure_self_service_monthly_generation_capacity(_repo, %User{is_admin: true}), do: :ok
+
+ defp ensure_self_service_monthly_generation_capacity(repo, %User{id: user_id}) do
+ {month_start, next_month_start} = current_month_naive_window()
+
+ generated_count =
+ from(i in InviteCode,
+ where:
+ i.created_by_id == ^user_id and
+ i.inserted_at >= ^month_start and
+ i.inserted_at < ^next_month_start
+ )
+ |> repo.aggregate(:count)
+
+ if generated_count < @self_service_invite_monthly_generation_limit do
+ :ok
+ else
+ {:error, :monthly_invite_code_limit_reached}
+ end
+ end
+
+ defp ensure_self_service_invite_use_capacity(_repo, %InviteCode{created_by_id: nil}), do: :ok
+
+ defp ensure_self_service_invite_use_capacity(repo, %InviteCode{created_by_id: created_by_id}) do
+ case lock_user_for_update(repo, created_by_id) do
+ %User{} = creator -> ensure_self_service_monthly_use_capacity(repo, creator)
+ nil -> :ok
+ end
+ end
+
+ defp ensure_self_service_monthly_use_capacity(_repo, %User{is_admin: true}), do: :ok
+
+ defp ensure_self_service_monthly_use_capacity(repo, %User{id: user_id}) do
+ {month_start, next_month_start} = current_month_datetime_window()
+
+ used_count =
+ from(invite_use in InviteCodeUse,
+ join: invite_code in InviteCode,
+ on: invite_code.id == invite_use.invite_code_id,
+ where:
+ invite_code.created_by_id == ^user_id and
+ invite_use.used_at >= ^month_start and
+ invite_use.used_at < ^next_month_start
+ )
+ |> repo.aggregate(:count)
+
+ if used_count < @self_service_invite_monthly_use_limit do
+ :ok
+ else
+ {:error, :monthly_invite_use_limit_reached}
+ end
+ end
+
+ defp lock_user_for_update(repo, user_id) do
+ from(u in User,
+ where: u.id == ^user_id,
+ lock: "FOR UPDATE"
+ )
+ |> repo.one()
+ end
+
+ defp get_user_invite_code(user_id, invite_code_id) do
+ InviteCode
+ |> where([i], i.id == ^invite_code_id and i.created_by_id == ^user_id)
+ |> preload(:created_by)
+ |> Repo.one()
+ end
+
+ defp self_service_invite_expiration do
+ DateTime.utc_now()
+ |> DateTime.add(@self_service_invite_expiry_days * @seconds_per_day, :second)
+ |> DateTime.truncate(:second)
+ end
+
+ defp current_month_naive_window do
+ {month_start, next_month_start} = current_month_date_window()
+
+ {
+ NaiveDateTime.new!(month_start, ~T[00:00:00]),
+ NaiveDateTime.new!(next_month_start, ~T[00:00:00])
+ }
+ end
+
+ defp current_month_datetime_window do
+ {month_start, next_month_start} = current_month_date_window()
+
+ {
+ DateTime.new!(month_start, ~T[00:00:00], "Etc/UTC"),
+ DateTime.new!(next_month_start, ~T[00:00:00], "Etc/UTC")
+ }
+ end
+
+ defp current_month_date_window do
+ month_start = Date.utc_today() |> Date.beginning_of_month()
+ next_month_start = month_start |> Date.end_of_month() |> Date.add(1)
+ {month_start, next_month_start}
+ end
+
+ defp normalize_optional_string(nil), do: nil
+
+ defp normalize_optional_string(value) do
+ value
+ |> to_string()
+ |> String.trim()
+ |> case do
+ "" -> nil
+ trimmed -> trimmed
+ end
+ end
+
+ defp maybe_delete_email_data(user) do
+ if email_module_compiled?() do
+ # Delete user's messages through their mailboxes
+ from(m in Elektrine.Email.Message,
+ join: mb in Elektrine.Email.Mailbox,
+ on: m.mailbox_id == mb.id,
+ where: mb.user_id == ^user.id
+ )
+ |> Repo.delete_all()
+
+ # Delete user's mailboxes
+ from(mb in Elektrine.Email.Mailbox, where: mb.user_id == ^user.id)
+ |> Repo.delete_all()
+
+ # Delete user's email aliases
+ from(a in Elektrine.Email.Alias, where: a.user_id == ^user.id)
+ |> Repo.delete_all()
+ else
+ :ok
+ end
+ end
+
+ defp email_module_compiled? do
+ Modules.compiled?(:email)
+ end
+end
diff --git a/apps/elektrine/lib/elektrine/accounts/account_deletion_request.ex b/apps/elektrine/lib/elektrine/accounts/account_deletion_request.ex
new file mode 100644
index 0000000..8cdf642
--- /dev/null
+++ b/apps/elektrine/lib/elektrine/accounts/account_deletion_request.ex
@@ -0,0 +1,63 @@
+defmodule Elektrine.Accounts.AccountDeletionRequest do
+ @moduledoc false
+ use Ecto.Schema
+ import Ecto.Changeset
+
+ alias Elektrine.Accounts.User
+
+ schema "account_deletion_requests" do
+ field :reason, :string
+ field :status, :string, default: "pending"
+ field :requested_at, :utc_datetime
+ field :reviewed_at, :utc_datetime
+ field :admin_notes, :string
+
+ belongs_to :user, User
+ belongs_to :reviewed_by, User
+
+ timestamps(type: :utc_datetime)
+ end
+
+ @valid_statuses ~w(pending approved denied)
+
+ @doc """
+ Changeset for creating a new account deletion request.
+ """
+ def changeset(request, attrs) do
+ request
+ |> cast(attrs, [:user_id, :reason, :requested_at])
+ |> validate_required([:user_id, :requested_at])
+ |> validate_length(:reason, max: 1000)
+ |> put_change(:status, "pending")
+ |> unique_constraint(:user_id, message: "You already have a pending deletion request")
+ end
+
+ @doc """
+ Changeset for admin review of deletion request.
+ """
+ def review_changeset(request, attrs) do
+ request
+ |> cast(attrs, [:status, :reviewed_at, :reviewed_by_id, :admin_notes])
+ |> validate_required([:status, :reviewed_at, :reviewed_by_id])
+ |> validate_inclusion(:status, @valid_statuses)
+ |> validate_length(:admin_notes, max: 1000)
+ end
+
+ @doc """
+ Returns true if the request is pending review.
+ """
+ def pending?(%__MODULE__{status: "pending"}), do: true
+ def pending?(_), do: false
+
+ @doc """
+ Returns true if the request has been approved.
+ """
+ def approved?(%__MODULE__{status: "approved"}), do: true
+ def approved?(_), do: false
+
+ @doc """
+ Returns true if the request has been denied.
+ """
+ def denied?(%__MODULE__{status: "denied"}), do: true
+ def denied?(_), do: false
+end
diff --git a/apps/elektrine/lib/elektrine/accounts/account_endorsement.ex b/apps/elektrine/lib/elektrine/accounts/account_endorsement.ex
new file mode 100644
index 0000000..bd951a5
--- /dev/null
+++ b/apps/elektrine/lib/elektrine/accounts/account_endorsement.ex
@@ -0,0 +1,55 @@
+defmodule Elektrine.Accounts.AccountEndorsement do
+ @moduledoc false
+ use Ecto.Schema
+ import Ecto.Changeset
+
+ schema "account_endorsements" do
+ belongs_to :user, Elektrine.Accounts.User
+ belongs_to :endorsed_user, Elektrine.Accounts.User
+ belongs_to :remote_actor, Elektrine.ActivityPub.Actor
+
+ timestamps(type: :utc_datetime)
+ end
+
+ def changeset(endorsement, attrs) do
+ endorsement
+ |> cast(attrs, [:user_id, :endorsed_user_id, :remote_actor_id])
+ |> validate_required([:user_id])
+ |> validate_exactly_one_target()
+ |> validate_not_self()
+ |> unique_constraint([:user_id, :endorsed_user_id],
+ name: :account_endorsements_user_local_unique_idx
+ )
+ |> unique_constraint([:user_id, :remote_actor_id],
+ name: :account_endorsements_user_remote_unique_idx
+ )
+ |> check_constraint(:endorsed_user_id, name: :account_endorsements_exactly_one_target)
+ |> check_constraint(:endorsed_user_id, name: :account_endorsements_not_self)
+ |> foreign_key_constraint(:user_id)
+ |> foreign_key_constraint(:endorsed_user_id)
+ |> foreign_key_constraint(:remote_actor_id)
+ end
+
+ defp validate_exactly_one_target(changeset) do
+ local_id = get_field(changeset, :endorsed_user_id)
+ remote_id = get_field(changeset, :remote_actor_id)
+
+ case {local_id, remote_id} do
+ {nil, nil} -> add_error(changeset, :endorsed_user_id, "must select an account")
+ {_, nil} -> changeset
+ {nil, _} -> changeset
+ {_, _} -> add_error(changeset, :remote_actor_id, "cannot select multiple accounts")
+ end
+ end
+
+ defp validate_not_self(changeset) do
+ user_id = get_field(changeset, :user_id)
+ endorsed_user_id = get_field(changeset, :endorsed_user_id)
+
+ if user_id && endorsed_user_id && user_id == endorsed_user_id do
+ add_error(changeset, :endorsed_user_id, "cannot endorse yourself")
+ else
+ changeset
+ end
+ end
+end
diff --git a/apps/elektrine/lib/elektrine/accounts/account_import_worker.ex b/apps/elektrine/lib/elektrine/accounts/account_import_worker.ex
new file mode 100644
index 0000000..50d1995
--- /dev/null
+++ b/apps/elektrine/lib/elektrine/accounts/account_import_worker.ex
@@ -0,0 +1,228 @@
+defmodule Elektrine.Accounts.AccountImportWorker do
+ @moduledoc """
+ Imports follow, mute, block, and domain-block relationships from identifiers.
+ """
+ use Oban.Worker, queue: :default, max_attempts: 3, priority: 8
+
+ require Logger
+
+ alias Elektrine.Accounts
+ alias Elektrine.ActivityPub
+ alias Elektrine.ActivityPub.Actor
+ alias Elektrine.Profiles
+
+ @types ~w(follow mute block domain_block)
+ @max_identifiers 5_000
+
+ def max_identifiers, do: @max_identifiers
+
+ @impl Oban.Worker
+ def perform(%Oban.Job{
+ args: %{"user_id" => user_id, "type" => type, "identifier" => identifier}
+ })
+ when type in @types do
+ case perform_import(user_id, type, identifier) do
+ :ok ->
+ :ok
+
+ {:error, reason} ->
+ Logger.debug(
+ "Account import #{type} failed for #{inspect(identifier)}: #{inspect(reason)}"
+ )
+
+ {:error, reason}
+ end
+ end
+
+ def perform(_job), do: {:discard, :invalid_import_job}
+
+ def enqueue_many(user_id, type, identifiers) when type in @types and is_list(identifiers) do
+ identifiers =
+ identifiers
+ |> normalize_identifiers()
+
+ if length(identifiers) > @max_identifiers do
+ {:error, :too_many_import_identifiers}
+ else
+ jobs =
+ Enum.map(identifiers, fn identifier ->
+ %{user_id: user_id, type: type, identifier: identifier}
+ |> new()
+ |> Oban.insert()
+ end)
+
+ {:ok, jobs}
+ end
+ end
+
+ def enqueue_many(_user_id, _type, _identifiers), do: {:error, :invalid_import_type}
+
+ defp normalize_identifiers(identifiers) do
+ identifiers
+ |> Enum.map(&normalize_identifier/1)
+ |> Enum.reject(&is_nil/1)
+ |> Enum.uniq()
+ end
+
+ defp perform_import(user_id, "follow", identifier) do
+ with {:ok, target} <- resolve_target(identifier) do
+ case target do
+ {:user, target_user_id} ->
+ normalize_idempotent(Profiles.follow_user(user_id, target_user_id))
+
+ {:remote_actor, actor_id} ->
+ normalize_idempotent(Profiles.follow_remote_actor(user_id, actor_id))
+ end
+ end
+ end
+
+ defp perform_import(user_id, "mute", identifier) do
+ with {:ok, target} <- resolve_target(identifier) do
+ case target do
+ {:user, target_user_id} ->
+ normalize_idempotent(Accounts.mute_user(user_id, target_user_id))
+
+ {:remote_actor, actor_id} ->
+ normalize_idempotent(Accounts.mute_remote_actor(user_id, actor_id))
+ end
+ end
+ end
+
+ defp perform_import(user_id, "block", identifier) do
+ with {:ok, target} <- resolve_target(identifier) do
+ case target do
+ {:user, target_user_id} ->
+ normalize_idempotent(Accounts.block_user(user_id, target_user_id))
+
+ {:remote_actor, actor_id} ->
+ normalize_idempotent(Accounts.block_remote_actor(user_id, actor_id))
+ end
+ end
+ end
+
+ defp perform_import(user_id, "domain_block", identifier) do
+ normalize_idempotent(Accounts.block_domain(user_id, normalize_identifier(identifier)))
+ end
+
+ defp resolve_target(identifier) do
+ case resolve_local_user(identifier) do
+ {:ok, user} -> {:ok, {:user, user.id}}
+ {:error, :not_found} -> resolve_remote_actor(identifier)
+ end
+ end
+
+ defp resolve_local_user(identifier) do
+ identifier
+ |> normalize_identifier()
+ |> local_part()
+ |> case do
+ nil ->
+ {:error, :not_found}
+
+ local ->
+ case Accounts.get_user_by_username_or_handle(local) do
+ nil -> {:error, :not_found}
+ user -> {:ok, user}
+ end
+ end
+ end
+
+ defp resolve_remote_actor(identifier) do
+ with acct when is_binary(acct) <- normalize_identifier(identifier) do
+ acct = String.trim_leading(acct, "@")
+
+ if actor_uri?(acct) do
+ resolve_remote_actor_uri(acct)
+ else
+ resolve_remote_actor_account(acct)
+ end
+ end
+ end
+
+ defp resolve_remote_actor_uri(uri) do
+ case ActivityPub.get_actor_by_uri(uri) do
+ %Actor{} = actor ->
+ {:ok, {:remote_actor, actor.id}}
+
+ nil ->
+ case ActivityPub.get_or_fetch_actor(uri) do
+ {:ok, %Actor{} = actor} -> {:ok, {:remote_actor, actor.id}}
+ _ -> {:error, :not_found}
+ end
+ end
+ end
+
+ defp resolve_remote_actor_account(acct) do
+ case cached_remote_actor(acct) do
+ %Actor{} = actor ->
+ {:ok, {:remote_actor, actor.id}}
+
+ nil ->
+ with {:ok, actor_uri} <- ActivityPub.webfinger_lookup(acct),
+ {:ok, %Actor{} = actor} <- ActivityPub.get_or_fetch_actor(actor_uri) do
+ {:ok, {:remote_actor, actor.id}}
+ else
+ _ -> {:error, :not_found}
+ end
+ end
+ end
+
+ defp actor_uri?(value) do
+ case URI.parse(value) do
+ %URI{scheme: scheme, host: host} when scheme in ["http", "https"] and is_binary(host) ->
+ true
+
+ _ ->
+ false
+ end
+ end
+
+ defp cached_remote_actor(acct) do
+ case String.split(acct, "@", parts: 2) do
+ [username, domain] when username != "" and domain != "" ->
+ ActivityPub.get_actor_by_username_and_domain(username, domain)
+
+ _ ->
+ nil
+ end
+ end
+
+ defp local_part(identifier) when is_binary(identifier) do
+ trimmed = String.trim_leading(identifier, "@")
+ local_domains = MapSet.new([String.downcase(ActivityPub.instance_domain())])
+
+ case String.split(trimmed, "@", parts: 2) do
+ [name] ->
+ name
+
+ [name, domain] ->
+ if MapSet.member?(local_domains, String.downcase(domain)), do: name, else: nil
+ end
+ end
+
+ defp local_part(_), do: nil
+
+ defp normalize_identifier(nil), do: nil
+
+ defp normalize_identifier(identifier) when is_binary(identifier) do
+ identifier
+ |> strip_utf8_bom()
+ |> String.trim()
+ |> String.trim_leading("acct:")
+ |> case do
+ "" -> nil
+ value -> value
+ end
+ end
+
+ defp normalize_identifier(_), do: nil
+
+ defp strip_utf8_bom(<<0xEF, 0xBB, 0xBF, rest::binary>>), do: rest
+ defp strip_utf8_bom(value), do: value
+
+ defp normalize_idempotent({:ok, _}), do: :ok
+ defp normalize_idempotent({:error, :already_following}), do: :ok
+ defp normalize_idempotent({:error, changeset = %Ecto.Changeset{}}), do: {:error, changeset}
+ defp normalize_idempotent({:error, reason}), do: {:error, reason}
+ defp normalize_idempotent(_), do: :ok
+end
diff --git a/apps/elektrine/lib/elektrine/accounts/account_note.ex b/apps/elektrine/lib/elektrine/accounts/account_note.ex
new file mode 100644
index 0000000..9f7213b
--- /dev/null
+++ b/apps/elektrine/lib/elektrine/accounts/account_note.ex
@@ -0,0 +1,45 @@
+defmodule Elektrine.Accounts.AccountNote do
+ @moduledoc false
+ use Ecto.Schema
+
+ import Ecto.Changeset
+
+ schema "account_notes" do
+ field :comment, :string
+
+ belongs_to :source_user, Elektrine.Accounts.User
+ belongs_to :target_user, Elektrine.Accounts.User
+ belongs_to :target_remote_actor, Elektrine.ActivityPub.Actor
+
+ timestamps(type: :utc_datetime)
+ end
+
+ def changeset(note, attrs) do
+ note
+ |> cast(attrs, [:source_user_id, :target_user_id, :target_remote_actor_id, :comment])
+ |> update_change(:comment, &normalize_comment/1)
+ |> validate_required([:source_user_id])
+ |> validate_target()
+ |> validate_length(:comment, max: 5_000)
+ |> unique_constraint([:source_user_id, :target_user_id])
+ |> unique_constraint([:source_user_id, :target_remote_actor_id])
+ |> check_constraint(:target_user_id, name: :account_notes_exactly_one_target)
+ |> foreign_key_constraint(:source_user_id)
+ |> foreign_key_constraint(:target_user_id)
+ |> foreign_key_constraint(:target_remote_actor_id)
+ end
+
+ defp validate_target(changeset) do
+ target_user_id = get_field(changeset, :target_user_id)
+ target_remote_actor_id = get_field(changeset, :target_remote_actor_id)
+
+ case {is_nil(target_user_id), is_nil(target_remote_actor_id)} do
+ {false, true} -> changeset
+ {true, false} -> changeset
+ _ -> add_error(changeset, :base, "must have exactly one target")
+ end
+ end
+
+ defp normalize_comment(value) when is_binary(value), do: String.trim(value)
+ defp normalize_comment(value), do: value
+end
diff --git a/apps/elektrine/lib/elektrine/accounts/account_notes.ex b/apps/elektrine/lib/elektrine/accounts/account_notes.ex
new file mode 100644
index 0000000..a1340ea
--- /dev/null
+++ b/apps/elektrine/lib/elektrine/accounts/account_notes.ex
@@ -0,0 +1,68 @@
+defmodule Elektrine.Accounts.AccountNotes do
+ @moduledoc """
+ Private account notes, compatible with Mastodon/Pleroma account notes.
+ """
+
+ alias Elektrine.Accounts.AccountNote
+ alias Elektrine.Repo
+
+ def get_note(source_user_id, {:user, target_user_id}) do
+ Repo.get_by(AccountNote, source_user_id: source_user_id, target_user_id: target_user_id)
+ end
+
+ def get_note(source_user_id, {:remote_actor, target_remote_actor_id}) do
+ Repo.get_by(AccountNote,
+ source_user_id: source_user_id,
+ target_remote_actor_id: target_remote_actor_id
+ )
+ end
+
+ def note_comment(source_user_id, target) do
+ case get_note(source_user_id, target) do
+ %AccountNote{comment: comment} when is_binary(comment) -> comment
+ _ -> ""
+ end
+ end
+
+ def put_note(source_user_id, {:user, target_user_id}, comment) do
+ upsert_note(%{
+ source_user_id: source_user_id,
+ target_user_id: target_user_id,
+ comment: comment
+ })
+ end
+
+ def put_note(source_user_id, {:remote_actor, target_remote_actor_id}, comment) do
+ upsert_note(%{
+ source_user_id: source_user_id,
+ target_remote_actor_id: target_remote_actor_id,
+ comment: comment
+ })
+ end
+
+ defp upsert_note(attrs) do
+ lookup =
+ cond do
+ attrs[:target_user_id] ->
+ [source_user_id: attrs.source_user_id, target_user_id: attrs.target_user_id]
+
+ attrs[:target_remote_actor_id] ->
+ [
+ source_user_id: attrs.source_user_id,
+ target_remote_actor_id: attrs.target_remote_actor_id
+ ]
+ end
+
+ case Repo.get_by(AccountNote, lookup) do
+ nil ->
+ %AccountNote{}
+ |> AccountNote.changeset(attrs)
+ |> Repo.insert()
+
+ note ->
+ note
+ |> AccountNote.changeset(attrs)
+ |> Repo.update()
+ end
+ end
+end
diff --git a/apps/elektrine/lib/elektrine/accounts/account_subscription.ex b/apps/elektrine/lib/elektrine/accounts/account_subscription.ex
new file mode 100644
index 0000000..8cbae5a
--- /dev/null
+++ b/apps/elektrine/lib/elektrine/accounts/account_subscription.ex
@@ -0,0 +1,55 @@
+defmodule Elektrine.Accounts.AccountSubscription do
+ @moduledoc false
+ use Ecto.Schema
+ import Ecto.Changeset
+
+ schema "account_subscriptions" do
+ belongs_to :user, Elektrine.Accounts.User
+ belongs_to :subscribed_user, Elektrine.Accounts.User
+ belongs_to :remote_actor, Elektrine.ActivityPub.Actor
+
+ timestamps(type: :utc_datetime)
+ end
+
+ def changeset(subscription, attrs) do
+ subscription
+ |> cast(attrs, [:user_id, :subscribed_user_id, :remote_actor_id])
+ |> validate_required([:user_id])
+ |> validate_exactly_one_target()
+ |> validate_not_self()
+ |> unique_constraint([:user_id, :subscribed_user_id],
+ name: :account_subscriptions_user_local_unique_idx
+ )
+ |> unique_constraint([:user_id, :remote_actor_id],
+ name: :account_subscriptions_user_remote_unique_idx
+ )
+ |> check_constraint(:subscribed_user_id, name: :account_subscriptions_exactly_one_target)
+ |> check_constraint(:subscribed_user_id, name: :account_subscriptions_not_self)
+ |> foreign_key_constraint(:user_id)
+ |> foreign_key_constraint(:subscribed_user_id)
+ |> foreign_key_constraint(:remote_actor_id)
+ end
+
+ defp validate_exactly_one_target(changeset) do
+ local_id = get_field(changeset, :subscribed_user_id)
+ remote_id = get_field(changeset, :remote_actor_id)
+
+ case {local_id, remote_id} do
+ {nil, nil} -> add_error(changeset, :subscribed_user_id, "must select an account")
+ {_, nil} -> changeset
+ {nil, _} -> changeset
+ {_, _} -> add_error(changeset, :remote_actor_id, "cannot select multiple accounts")
+ end
+ end
+
+ defp validate_not_self(changeset) do
+ user_id = get_field(changeset, :user_id)
+ subscribed_user_id = get_field(changeset, :subscribed_user_id)
+
+ if user_id && subscribed_user_id && user_id == subscribed_user_id do
+ add_error(changeset, :subscribed_user_id, "cannot subscribe to yourself")
+ else
+ changeset
+ end
+ end
+end
diff --git a/apps/elektrine/lib/elektrine/accounts/app_password.ex b/apps/elektrine/lib/elektrine/accounts/app_password.ex
new file mode 100644
index 0000000..23b6d19
--- /dev/null
+++ b/apps/elektrine/lib/elektrine/accounts/app_password.ex
@@ -0,0 +1,223 @@
+defmodule Elektrine.Accounts.AppPassword do
+ @moduledoc """
+ Schema for app-specific passwords that bypass 2FA for email clients.
+ """
+ use Ecto.Schema
+ import Ecto.Changeset
+
+ @current_hash_prefix "v3$argon2id$"
+ @hmac_hash_prefix "v2$hmac-sha256$"
+
+ schema "app_passwords" do
+ field :name, :string
+ field :token_hash, :string
+ field :last_used_at, :utc_datetime
+ field :last_used_ip, :string
+ field :expires_at, :utc_datetime
+
+ # Virtual field for the raw token (only available on creation)
+ field :token, :string, virtual: true
+
+ belongs_to :user, Elektrine.Accounts.User
+
+ timestamps(type: :utc_datetime)
+ end
+
+ @doc false
+ def changeset(app_password, attrs) do
+ app_password
+ |> cast(attrs, [:name, :token_hash, :user_id, :expires_at])
+ |> validate_required([:name, :token_hash, :user_id])
+ |> validate_length(:name, max: 100)
+ |> unique_constraint(:token_hash)
+ end
+
+ @doc """
+ Creates a changeset for a new app password with a generated token.
+ """
+ def create_changeset(attrs) do
+ # Generate a secure random token
+ token = generate_token()
+ token_hash = hash_token(token)
+
+ changeset =
+ %__MODULE__{}
+ |> cast(attrs, [:name, :user_id, :expires_at])
+ |> validate_required([:name, :user_id])
+ |> validate_length(:name, max: 100)
+ |> put_change(:token_hash, token_hash)
+
+ # Attach the raw token (WITH dashes) to the struct for display to the user
+ # This is the only time the raw token is available
+ %{changeset | changes: Map.put(changeset.changes, :token, token)}
+ end
+
+ @doc """
+ Updates last used information for an app password.
+ """
+ def update_last_used(app_password, ip_address \\ nil) do
+ app_password
+ |> change(%{
+ last_used_at: DateTime.utc_now() |> DateTime.truncate(:second),
+ last_used_ip: ip_address
+ })
+ end
+
+ @doc """
+ Generates a secure random token for app password.
+ Format: xxxx-xxxx-xxxx-xxxx-xxxx-xxxx-xxxx-xxxx (32 characters)
+ """
+ def generate_token do
+ :crypto.strong_rand_bytes(20)
+ |> Base.encode32(case: :lower, padding: false)
+ |> format_token()
+ end
+
+ defp format_token(token) do
+ token
+ |> String.graphemes()
+ |> Enum.chunk_every(4)
+ |> Enum.map_join("-", &Enum.join/1)
+ end
+
+ @doc """
+ Hashes a token for secure storage.
+ """
+ def hash_token(token) do
+ @current_hash_prefix <> Argon2.hash_pwd_salt(normalize_current_token(token))
+ end
+
+ @doc "Returns deterministic token hashes that can authenticate pre-v3 tokens.
+
+ Argon2id app-password hashes are salted, so they must be verified row-by-row
+ with `verify_token/2` instead of looked up directly by hash.
+ "
+ def candidate_hashes(token) do
+ clean_token = normalize_current_token(token)
+ legacy_token = normalize_legacy_token(token)
+
+ hmac_hashes([clean_token, legacy_token])
+ |> Enum.uniq()
+ end
+
+ defp normalize_current_token(token) when is_binary(token) do
+ token
+ |> String.replace(~r/[^a-z2-7]/i, "")
+ |> String.downcase()
+ end
+
+ defp normalize_current_token(_token), do: ""
+
+ defp normalize_legacy_token(token) when is_binary(token) do
+ String.trim(token)
+ end
+
+ defp normalize_legacy_token(_token), do: ""
+
+ defp hmac_hashes(tokens) do
+ case app_password_pepper() do
+ pepper when is_binary(pepper) ->
+ Enum.map(tokens, fn token -> @hmac_hash_prefix <> hmac_token(token, pepper) end)
+
+ nil ->
+ []
+ end
+ end
+
+ defp hmac_token(token, pepper) do
+ :crypto.mac(:hmac, :sha256, pepper, token)
+ |> Base.url_encode64(padding: false)
+ end
+
+ defp app_password_pepper do
+ Application.get_env(:elektrine, :app_password_pepper) ||
+ Application.get_env(:elektrine, :encryption_master_secret) ||
+ get_in(Application.get_env(:elektrine, ElektrineWeb.Endpoint, []), [:secret_key_base])
+ end
+
+ @doc """
+ Verifies if a given token matches the stored hash.
+ """
+ def verify_token(token, token_hash) do
+ cond do
+ argon2_hash?(token_hash) ->
+ verify_argon2_token(token, token_hash)
+
+ hmac_hash?(token_hash) ->
+ Enum.any?(candidate_hashes(token), &secure_compare(&1, token_hash))
+
+ true ->
+ false
+ end
+ end
+
+ @doc "Returns the stored app-password hash version for diagnostics."
+ def hash_version(token_hash) when is_binary(token_hash) do
+ cond do
+ String.starts_with?(token_hash, @current_hash_prefix) -> :v3_argon2id
+ argon2_hash?(token_hash) -> :argon2id
+ hmac_hash?(token_hash) -> :v2_hmac
+ true -> :unknown
+ end
+ end
+
+ def hash_version(_token_hash), do: :unknown
+
+ defp argon2_hash?(token_hash) when is_binary(token_hash),
+ do: not is_nil(argon2_hash_body(token_hash))
+
+ defp argon2_hash?(_), do: false
+
+ defp hmac_hash?(token_hash) when is_binary(token_hash),
+ do: String.starts_with?(token_hash, @hmac_hash_prefix)
+
+ defp hmac_hash?(_), do: false
+
+ defp verify_argon2_token(token, token_hash) do
+ case argon2_hash_body(token_hash) do
+ nil ->
+ false
+
+ argon2_hash ->
+ token
+ |> argon2_token_candidates()
+ |> Enum.any?(&verify_argon2_candidate(&1, argon2_hash))
+ end
+ end
+
+ defp argon2_hash_body(@current_hash_prefix <> argon2_hash),
+ do: normalize_argon2_hash_body(argon2_hash)
+
+ defp argon2_hash_body("$argon2id$" <> _ = argon2_hash), do: argon2_hash
+ defp argon2_hash_body(_), do: nil
+
+ defp normalize_argon2_hash_body("$argon2id$" <> _ = argon2_hash), do: argon2_hash
+ defp normalize_argon2_hash_body("argon2id$" <> _ = argon2_hash), do: "$" <> argon2_hash
+ defp normalize_argon2_hash_body(_), do: nil
+
+ defp argon2_token_candidates(token) when is_binary(token) do
+ trimmed = String.trim(token)
+
+ [
+ normalize_current_token(token),
+ trimmed,
+ String.downcase(trimmed)
+ ]
+ |> Enum.reject(&(&1 == ""))
+ |> Enum.uniq()
+ end
+
+ defp argon2_token_candidates(_), do: []
+
+ defp verify_argon2_candidate(candidate, argon2_hash) do
+ Argon2.verify_pass(candidate, argon2_hash)
+ rescue
+ _ -> false
+ end
+
+ defp secure_compare(left, right) when is_binary(left) and is_binary(right) do
+ byte_size(left) == byte_size(right) and Plug.Crypto.secure_compare(left, right)
+ end
+
+ defp secure_compare(_left, _right), do: false
+end
diff --git a/apps/elektrine/lib/elektrine/accounts/authentication.ex b/apps/elektrine/lib/elektrine/accounts/authentication.ex
new file mode 100644
index 0000000..e041099
--- /dev/null
+++ b/apps/elektrine/lib/elektrine/accounts/authentication.ex
@@ -0,0 +1,911 @@
+defmodule Elektrine.Accounts.Authentication do
+ @moduledoc ~s|Authentication context for user authentication, password management, and 2FA.\nHandles password verification, 2FA setup/verification, app passwords, and password recovery.\n|
+ import Ecto.Query, warn: false
+ alias Elektrine.Accounts.{AppPassword, TwoFactor, User}
+ alias Elektrine.DB.WriteGuard
+ alias Elektrine.Email
+ alias Elektrine.Email.Mailbox
+ alias Elektrine.OAuth
+ alias Elektrine.Repo
+ alias Elektrine.Vault
+ require Logger
+
+ @doc ~s|Authenticates a user by username and password.\n\nReturns `{:ok, user}` if the username and password are valid,\nor `{:error, :invalid_credentials}` if the username or password are invalid.\n\n## Examples\n\n iex> authenticate_user(\"username\", \"correct_password\")\n {:ok, %User{}}\n\n iex> authenticate_user(\"username\", \"wrong_password\")\n {:error, :invalid_credentials}\n\n iex> authenticate_user(\"nonexistent\", \"any_password\")\n {:error, :invalid_credentials}\n\n|
+ def authenticate_user(username, password) when is_binary(username) and is_binary(password) do
+ user = get_user_by_username_case_insensitive(username)
+
+ cond do
+ is_nil(user) ->
+ Argon2.no_user_verify(to_charlist(password))
+ {:error, :invalid_credentials}
+
+ user.banned ->
+ {:error, {:banned, user.banned_reason}}
+
+ user_suspended?(user) ->
+ {:error, {:suspended, user.suspended_until, user.suspension_reason}}
+
+ verify_password_hash(password, user.password_hash) ->
+ if bcrypt_hash?(user.password_hash) do
+ user
+ |> User.password_changeset(%{password: password, password_confirmation: password})
+ |> Repo.update()
+ end
+
+ if user.suspended && user.suspended_until &&
+ DateTime.compare(user.suspended_until, DateTime.utc_now()) == :lt do
+ Elektrine.Accounts.Moderation.unsuspend_user(user)
+ end
+
+ {:ok, user}
+
+ true ->
+ {:error, :invalid_credentials}
+ end
+ end
+
+ @doc ~s|Verifies password for an existing user without re-querying the database.\nUse this when you already have the user struct loaded.\nReturns {:ok, user} if password is valid, {:error, reason} otherwise.\n|
+ def verify_user_password(%User{} = user, password) when is_binary(password) do
+ cond do
+ user.banned ->
+ {:error, {:banned, user.banned_reason}}
+
+ user_suspended?(user) ->
+ {:error, {:suspended, user.suspended_until, user.suspension_reason}}
+
+ verify_password_hash(password, user.password_hash) ->
+ {:ok, user}
+
+ true ->
+ {:error, :invalid_credentials}
+ end
+ end
+
+ @doc "Returns :ok when a user is allowed to access authenticated surfaces."
+ def ensure_user_active(%User{} = user) do
+ cond do
+ user.banned -> {:error, :account_banned}
+ user_suspended?(user) -> {:error, :account_suspended}
+ true -> :ok
+ end
+ end
+
+ defp verify_password_hash(password, hash) do
+ if bcrypt_hash?(hash) do
+ Bcrypt.verify_pass(password, hash)
+ else
+ Argon2.verify_pass(password, hash)
+ end
+ end
+
+ defp bcrypt_hash?(hash) when is_binary(hash) do
+ String.starts_with?(hash, ["$2", "$2a$", "$2b$", "$2y$"])
+ end
+
+ defp bcrypt_hash?(_) do
+ false
+ end
+
+ defp user_suspended?(%User{} = user) do
+ user.suspended &&
+ (is_nil(user.suspended_until) ||
+ DateTime.compare(user.suspended_until, DateTime.utc_now()) == :gt)
+ end
+
+ @doc ~s|Updates a user's password.\n\n## Examples\n\n iex> update_user_password(user, %{password: \"new password\", password_confirmation: \"new password\"})\n {:ok, %User{}}\n\n iex> update_user_password(user, %{password: \"invalid\"})\n {:error, %Ecto.Changeset{}}\n\n|
+ def update_user_password(%User{} = user, attrs, opts \\ []) do
+ password_changeset = User.password_changeset(user, attrs)
+ encrypted_data_rewrap = Keyword.get(opts, :encrypted_data_rewrap)
+ mailbox_rewrap = Keyword.get(opts, :private_mailbox_rewrap)
+
+ if password_changeset.valid? do
+ case Repo.transaction(fn ->
+ case Repo.update(password_changeset) do
+ {:ok, updated_user} ->
+ with :ok <- maybe_rewrap_encrypted_data(user.id, encrypted_data_rewrap),
+ :ok <- maybe_rewrap_private_mailbox(user.id, mailbox_rewrap) do
+ updated_user
+ else
+ {:error, reason} -> Repo.rollback(reason)
+ end
+
+ {:error, changeset} ->
+ Repo.rollback(changeset)
+ end
+ end) do
+ {:ok, updated_user} ->
+ invalidate_long_lived_credentials(updated_user)
+ {:ok, updated_user}
+
+ {:error, %Ecto.Changeset{} = changeset} ->
+ {:error, changeset}
+
+ {:error, reason} ->
+ {:error, password_changeset_error(password_changeset, reason)}
+ end
+ else
+ {:error, password_changeset}
+ end
+ end
+
+ @doc ~s|Returns an `%Ecto.Changeset{}` for tracking user password changes.\n|
+ def change_user_password(%User{} = user, attrs \\ %{}) do
+ User.password_changeset(user, attrs)
+ end
+
+ @doc ~s|Initiates 2FA setup for a user by generating a secret and backup codes.\n\nReturns the secret and provisioning URI for QR code generation.\n|
+ def initiate_two_factor_setup(%User{} = user) do
+ secret = TwoFactor.generate_secret()
+ {plain_codes, hashed_codes} = TwoFactor.generate_backup_codes()
+ provisioning_uri = TwoFactor.generate_provisioning_uri(secret, user.username)
+
+ {:ok,
+ %{
+ secret: secret,
+ plain_backup_codes: plain_codes,
+ hashed_backup_codes: hashed_codes,
+ provisioning_uri: provisioning_uri
+ }}
+ rescue
+ _ -> {:error, :setup_failed}
+ end
+
+ @doc ~s|Enables 2FA for a user after verifying the TOTP code.\nExpects hashed_backup_codes (not plain codes) for storage.\n|
+ def enable_two_factor(%User{} = user, secret, hashed_backup_codes, totp_code) do
+ db_check =
+ try do
+ case Repo.query("SELECT 1", []) do
+ {:ok, _} ->
+ :ok
+
+ {:error, reason} ->
+ Logger.error("2FA: Database connectivity check failed: #{inspect(reason)}")
+ {:error, :database_unavailable}
+ end
+ rescue
+ exception ->
+ Logger.error("2FA: Database connectivity exception: #{inspect(exception)}")
+ {:error, :database_exception}
+ end
+
+ case db_check do
+ :ok ->
+ if TwoFactor.verify_totp(secret, totp_code) do
+ encoded_secret = Base.encode64(secret)
+
+ changeset =
+ User.enable_two_factor_changeset(user, %{
+ two_factor_secret: encoded_secret,
+ two_factor_backup_codes: hashed_backup_codes
+ })
+
+ if changeset.valid? do
+ try do
+ case Repo.update(changeset) do
+ {:ok, updated_user} ->
+ Elektrine.Accounts.invalidate_auth_sessions(updated_user)
+ invalidate_long_lived_credentials(updated_user)
+ broadcast_mail_auth_changed(updated_user.id)
+
+ case Repo.reload(updated_user) do
+ nil ->
+ Logger.error("2FA: User reload failed after update")
+ {:error, :reload_failed}
+
+ reloaded_user ->
+ {:ok, reloaded_user}
+ end
+
+ {:error, changeset} ->
+ Logger.error("2FA database update failed: #{inspect(changeset.errors)}")
+ {:error, changeset}
+ end
+ rescue
+ exception ->
+ Logger.error("2FA: Database update exception: #{inspect(exception)}")
+ {:error, :database_update_exception}
+ end
+ else
+ Logger.error("2FA changeset errors: #{inspect(changeset.errors)}")
+ {:error, :invalid_changeset}
+ end
+ else
+ {:error, :invalid_totp_code}
+ end
+
+ {:error, reason} ->
+ Logger.error("2FA: Database connectivity failed: #{inspect(reason)}")
+ {:error, reason}
+ end
+ end
+
+ @doc ~s|Disables 2FA for a user.\n|
+ def disable_two_factor(%User{} = user) do
+ case user |> User.disable_two_factor_changeset() |> Repo.update() do
+ {:ok, updated_user} ->
+ Elektrine.Accounts.invalidate_auth_sessions(updated_user)
+ invalidate_long_lived_credentials(updated_user)
+ {:ok, updated_user}
+
+ error ->
+ error
+ end
+ end
+
+ @doc ~s|Verifies a 2FA code (TOTP or backup code) for a user.\n|
+ def verify_two_factor_code(%User{two_factor_enabled: true} = user, code) do
+ decoded_secret =
+ if is_binary(user.two_factor_secret) do
+ case Base.decode64(user.two_factor_secret) do
+ {:ok, secret} -> secret
+ :error -> user.two_factor_secret
+ end
+ else
+ user.two_factor_secret
+ end
+
+ cond do
+ TwoFactor.verify_totp(decoded_secret, code) ->
+ {:ok, :totp}
+
+ user.two_factor_backup_codes != nil ->
+ Repo.transaction(fn ->
+ locked_user =
+ User
+ |> where([u], u.id == ^user.id)
+ |> lock("FOR UPDATE")
+ |> Repo.one!()
+
+ case TwoFactor.verify_backup_code(locked_user.two_factor_backup_codes, code) do
+ {:ok, remaining_codes} ->
+ case locked_user
+ |> User.update_backup_codes_changeset(remaining_codes)
+ |> Repo.update() do
+ {:ok, _updated_user} -> :backup_code
+ {:error, changeset} -> Repo.rollback({:backup_code_update_failed, changeset})
+ end
+
+ {:error, :invalid} ->
+ Repo.rollback(:invalid_code)
+ end
+ end)
+ |> case do
+ {:ok, :backup_code} -> {:ok, :backup_code}
+ {:error, :invalid_code} -> {:error, :invalid_code}
+ {:error, _reason} -> {:error, :invalid_code}
+ end
+
+ true ->
+ {:error, :invalid_code}
+ end
+ end
+
+ def verify_two_factor_code(%User{two_factor_enabled: false}, _code) do
+ {:error, :two_factor_not_enabled}
+ end
+
+ @doc ~s|Verifies a TOTP code ONLY (not backup codes) for a user.\nThis is used for sensitive operations like disabling 2FA where backup codes should not be allowed.\n|
+ def verify_totp_only(%User{two_factor_enabled: true} = user, code) do
+ decoded_secret =
+ if is_binary(user.two_factor_secret) do
+ case Base.decode64(user.two_factor_secret) do
+ {:ok, secret} -> secret
+ :error -> user.two_factor_secret
+ end
+ else
+ user.two_factor_secret
+ end
+
+ if TwoFactor.verify_totp(decoded_secret, code) do
+ {:ok, :totp}
+ else
+ {:error, :invalid_code}
+ end
+ end
+
+ def verify_totp_only(%User{two_factor_enabled: false}, _code) do
+ {:error, :two_factor_not_enabled}
+ end
+
+ @doc ~s|Regenerates backup codes for a user with 2FA enabled.\nReturns {:ok, {updated_user, plain_backup_codes}} so user can save them.\n|
+ def regenerate_backup_codes(%User{two_factor_enabled: true} = user) do
+ {plain_codes, hashed_codes} = TwoFactor.generate_backup_codes()
+ result = user |> User.update_backup_codes_changeset(hashed_codes) |> Repo.update()
+
+ case result do
+ {:ok, updated_user} -> {:ok, {updated_user, plain_codes}}
+ error -> error
+ end
+ end
+
+ def regenerate_backup_codes(%User{two_factor_enabled: false}) do
+ {:error, :two_factor_not_enabled}
+ end
+
+ @doc ~s|Admin function to reset a user's 2FA (disable and clear all 2FA data).\n|
+ def admin_reset_2fa(user) do
+ user |> User.admin_2fa_reset_changeset() |> Repo.update()
+ end
+
+ defp maybe_rewrap_private_mailbox(user_id, mailbox_rewrap) do
+ case Email.get_user_mailbox(user_id) do
+ mailbox when not is_nil(mailbox) ->
+ cond do
+ !Mailbox.private_storage_account_password?(mailbox) ->
+ :ok
+
+ !valid_private_mailbox_rewrap?(mailbox_rewrap) ->
+ {:error, :private_mailbox_rewrap_required}
+
+ true ->
+ mailbox
+ |> Mailbox.private_storage_changeset(%{
+ private_storage_enabled: mailbox.private_storage_enabled,
+ private_storage_public_key: mailbox.private_storage_public_key,
+ private_storage_wrapped_private_key: mailbox_rewrap.wrapped_private_key,
+ private_storage_verifier: mailbox_rewrap.verifier
+ })
+ |> Repo.update()
+ |> case do
+ {:ok, _updated_mailbox} -> :ok
+ {:error, _changeset} -> {:error, :private_mailbox_rewrap_invalid}
+ end
+ end
+
+ _mailbox ->
+ :ok
+ end
+ end
+
+ defp maybe_rewrap_encrypted_data(user_id, encrypted_data_rewrap) do
+ if Vault.configured?(user_id) do
+ if valid_encrypted_data_rewrap?(encrypted_data_rewrap) do
+ case Vault.rotate(user_id, %{
+ "wrapped_dek" => encrypted_data_rewrap.wrapped_dek,
+ "wrapped_dek_recovery" => encrypted_data_rewrap.wrapped_dek_recovery
+ }) do
+ {:ok, _master_key} -> :ok
+ {:error, _reason} -> {:error, :encrypted_data_rewrap_invalid}
+ end
+ else
+ {:error, :encrypted_data_rewrap_required}
+ end
+ else
+ :ok
+ end
+ end
+
+ defp valid_encrypted_data_rewrap?(%{
+ wrapped_dek: wrapped_dek,
+ wrapped_dek_recovery: wrapped_dek_recovery
+ })
+ when is_map(wrapped_dek) and is_map(wrapped_dek_recovery),
+ do: true
+
+ defp valid_encrypted_data_rewrap?(_value), do: false
+
+ defp valid_private_mailbox_rewrap?(%{
+ wrapped_private_key: wrapped_private_key,
+ verifier: verifier,
+ unlock_mode: "account_password"
+ })
+ when is_map(wrapped_private_key) and is_map(verifier),
+ do: true
+
+ defp valid_private_mailbox_rewrap?(_value), do: false
+
+ defp password_changeset_error(password_changeset, :private_mailbox_rewrap_required) do
+ password_changeset
+ |> Map.put(:action, :update)
+ |> Ecto.Changeset.add_error(
+ :current_password,
+ "Your private mailbox must be rewrapped in this browser before changing your password."
+ )
+ end
+
+ defp password_changeset_error(password_changeset, :encrypted_data_rewrap_required) do
+ password_changeset
+ |> Map.put(:action, :update)
+ |> Ecto.Changeset.add_error(
+ :current_password,
+ "Encrypted data must be rewrapped in this browser before changing your password."
+ )
+ end
+
+ defp password_changeset_error(password_changeset, :encrypted_data_rewrap_invalid) do
+ password_changeset
+ |> Map.put(:action, :update)
+ |> Ecto.Changeset.add_error(
+ :current_password,
+ "Encrypted data could not be updated with the new password."
+ )
+ end
+
+ defp password_changeset_error(password_changeset, :private_mailbox_rewrap_invalid) do
+ password_changeset
+ |> Map.put(:action, :update)
+ |> Ecto.Changeset.add_error(
+ :current_password,
+ "Private mailbox protection could not be updated with the new password."
+ )
+ end
+
+ defp password_changeset_error(password_changeset, _reason) do
+ password_changeset
+ |> Map.put(:action, :update)
+ |> Ecto.Changeset.add_error(:current_password, "Password update failed.")
+ end
+
+ @doc ~s|Lists all app passwords for a user.\n|
+ def list_app_passwords(user_id) do
+ AppPassword |> where(user_id: ^user_id) |> order_by(desc: :inserted_at) |> Repo.all()
+ end
+
+ def revoke_all_app_passwords(user_id) do
+ app_passwords =
+ AppPassword
+ |> where(user_id: ^user_id)
+ |> Repo.all()
+
+ {count, _} =
+ AppPassword
+ |> where(user_id: ^user_id)
+ |> Repo.delete_all()
+
+ Enum.each(app_passwords, &broadcast_app_password_revoked/1)
+
+ {count, nil}
+ end
+
+ @doc ~s|Creates a new app password for a user.\nReturns {:ok, app_password} with the raw token attached, or {:error, changeset}.\n|
+ def create_app_password(user_id, attrs) do
+ attrs = Map.put(attrs, :user_id, user_id)
+ changeset = AppPassword.create_changeset(attrs)
+
+ case Repo.insert(changeset) do
+ {:ok, app_password} ->
+ token = changeset.changes[:token]
+ {:ok, %{app_password | token: token}}
+
+ error ->
+ error
+ end
+ end
+
+ @doc ~s|Deletes an app password.\n|
+ def delete_app_password(app_password_id, user_id) do
+ AppPassword
+ |> where(id: ^app_password_id, user_id: ^user_id)
+ |> Repo.one()
+ |> case do
+ nil ->
+ {:error, :not_found}
+
+ app_password ->
+ case Repo.delete(app_password) do
+ {:ok, deleted_app_password} = result ->
+ broadcast_app_password_revoked(deleted_app_password)
+ result
+
+ error ->
+ error
+ end
+ end
+ end
+
+ @doc ~s|Authenticates a user with an app password.\nReturns:\n - {:ok, user} if valid\n - {:error, :user_not_found} if user doesn't exist\n - {:error, {:invalid_token, user}} if user exists but token is invalid\n|
+ def authenticate_with_app_password(username, token) do
+ case authenticate_with_app_password_info(username, token) do
+ {:ok, user, _app_password} -> {:ok, user}
+ error -> error
+ end
+ end
+
+ def authenticate_with_app_password_info(username, token) do
+ case get_user_by_username_or_email(username) do
+ {:ok, user} ->
+ case ensure_user_active(user) do
+ :ok ->
+ case verify_app_password(user.id, token) do
+ {:ok, app_password} -> {:ok, user, app_password}
+ {:error, _} -> {:error, {:invalid_token, user}}
+ end
+
+ {:error, reason} ->
+ {:error, reason}
+ end
+
+ {:error, _} ->
+ {:error, :user_not_found}
+ end
+ end
+
+ def app_password_exists?(app_password_id, user_id) do
+ AppPassword
+ |> where(id: ^app_password_id, user_id: ^user_id)
+ |> where([ap], is_nil(ap.expires_at) or ap.expires_at > ^DateTime.utc_now())
+ |> Repo.exists?()
+ end
+
+ @doc "Returns a stable mail-auth subject for username or mailbox email identifiers."
+ def mail_auth_subject(identifier) do
+ normalized_identifier = normalize_mail_auth_identifier(identifier)
+
+ case get_user_by_username_or_email(normalized_identifier) do
+ {:ok, %User{id: user_id}} -> "user:#{user_id}"
+ {:error, _} -> fallback_mail_auth_subject(normalized_identifier)
+ end
+ end
+
+ @doc ~s|Verifies an app password token for a user.\nUpdates last used timestamp if valid.\n|
+ def verify_app_password(user_id, token, ip_address \\ nil) do
+ AppPassword
+ |> where(user_id: ^user_id)
+ |> where([ap], is_nil(ap.expires_at) or ap.expires_at > ^DateTime.utc_now())
+ |> Repo.all()
+ |> Enum.find(&AppPassword.verify_token(token, &1.token_hash))
+ |> case do
+ nil ->
+ {:error, :invalid_token}
+
+ app_password ->
+ Logger.debug(
+ "App password verified (hash_vsn=#{AppPassword.hash_version(app_password.token_hash)}) for user_id=#{user_id}"
+ )
+
+ case update_app_password_last_used(app_password, ip_address) do
+ {:ok, updated} ->
+ {:ok, updated}
+
+ {:error, _changeset} ->
+ Logger.warning(
+ "Failed to persist app password last_used_at for user_id=#{app_password.user_id}; allowing authentication"
+ )
+
+ {:ok, app_password}
+ end
+ end
+ end
+
+ defp update_app_password_last_used(app_password, ip_address) do
+ WriteGuard.run(
+ "app password last_used_at update for user_id=#{app_password.user_id}",
+ fn ->
+ app_password
+ |> AppPassword.update_last_used(ip_address)
+ |> Repo.update()
+ end,
+ on_read_only: {:ok, app_password}
+ )
+ end
+
+ defp invalidate_long_lived_credentials(%User{} = user) do
+ OAuth.delete_user_tokens(user)
+ revoke_all_app_passwords(user.id)
+ Elektrine.Developer.revoke_all_api_tokens(user.id)
+ ElektrineWeb.Endpoint.broadcast("user_socket:#{user.id}", "disconnect", %{})
+ end
+
+ defp broadcast_app_password_revoked(%AppPassword{id: id, user_id: user_id}) do
+ Phoenix.PubSub.broadcast(
+ Elektrine.PubSub,
+ "mail_auth:app_password:#{id}",
+ {:app_password_revoked, user_id, id}
+ )
+ end
+
+ defp broadcast_mail_auth_changed(user_id) do
+ Phoenix.PubSub.broadcast(
+ Elektrine.PubSub,
+ "mail_auth:user:#{user_id}",
+ {:mail_auth_changed, user_id}
+ )
+ end
+
+ defp get_user_by_username_or_email(identifier) do
+ normalized_identifier = normalize_mail_auth_identifier(identifier)
+
+ if String.contains?(normalized_identifier, "@") do
+ case String.split(normalized_identifier, "@", parts: 2) do
+ [username, domain] ->
+ username = String.downcase(username)
+ domain = String.downcase(domain)
+
+ user =
+ case get_user_by_username_case_insensitive(username) do
+ nil ->
+ get_user_by_mailbox_email(normalized_identifier)
+
+ candidate_user ->
+ if domain in Elektrine.Domains.available_email_domains_for_user(candidate_user) do
+ candidate_user
+ else
+ get_user_by_mailbox_email(normalized_identifier)
+ end
+ end
+
+ if user do
+ {:ok, user}
+ else
+ {:error, :not_found}
+ end
+
+ _ ->
+ {:error, :not_found}
+ end
+ else
+ case get_user_by_username_case_insensitive(normalized_identifier) do
+ nil -> {:error, :not_found}
+ user -> {:ok, user}
+ end
+ end
+ end
+
+ defp normalize_mail_auth_identifier(identifier) do
+ identifier
+ |> to_string()
+ |> String.trim()
+ |> String.downcase()
+ end
+
+ defp fallback_mail_auth_subject(""), do: "identifier:__empty__"
+
+ defp fallback_mail_auth_subject(identifier) when is_binary(identifier),
+ do: "identifier:#{identifier}"
+
+ defp get_user_by_mailbox_email(email_identifier) do
+ normalized_email = String.downcase(email_identifier)
+
+ case Email.get_mailbox_by_email(normalized_email) do
+ %{user_id: user_id} when is_integer(user_id) ->
+ Repo.get(User, user_id)
+
+ _ ->
+ nil
+ end
+ end
+
+ defp get_user_by_username_case_insensitive(username) when is_binary(username) do
+ User
+ |> where([u], fragment("lower(?)", u.username) == ^String.downcase(username))
+ |> Repo.one()
+ end
+
+ @doc ~s|Updates a user's recovery email.\n|
+ def update_recovery_email(%User{} = user, attrs) do
+ user |> User.recovery_email_changeset(attrs) |> Repo.update()
+ end
+
+ @doc ~s|Initiates a password reset by generating a token and sending email.\nReturns {:ok, user} if successful, even if no recovery email is set.\nIf multiple users have the same recovery email, sends reset emails to all of them.\n|
+ def initiate_password_reset(username_or_email) when is_binary(username_or_email) do
+ users =
+ case String.contains?(username_or_email, "@") do
+ true ->
+ get_users_by_recovery_email(username_or_email)
+
+ false ->
+ case Repo.get_by(User, username: username_or_email) do
+ nil -> []
+ user -> [user]
+ end
+ end
+
+ case users do
+ [] ->
+ {:ok, :user_not_found}
+
+ users_list ->
+ results =
+ Enum.map(users_list, fn user ->
+ case user do
+ %User{is_admin: true} ->
+ Logger.error(
+ "SECURITY ALERT: Attempted password reset for admin user: #{user.username}"
+ )
+
+ {:ok, :admin_blocked}
+
+ %User{recovery_email: recovery_email, recovery_email_verified: true}
+ when not is_nil(recovery_email) ->
+ token = generate_password_reset_token()
+ previous_reset_state = password_reset_state(user)
+
+ case user |> User.password_reset_changeset(token) |> Repo.update() do
+ {:ok, updated_user} ->
+ case send_password_reset_email(updated_user, token) do
+ {:ok, _result} ->
+ {:ok, updated_user}
+
+ {:error, reason} ->
+ restore_password_reset_state(updated_user, previous_reset_state)
+ {:error, reason}
+ end
+
+ {:error, changeset} ->
+ {:error, changeset}
+ end
+
+ %User{recovery_email: recovery_email, recovery_email_verified: verified}
+ when not is_nil(recovery_email) and verified != true ->
+ {:error, :recovery_email_not_verified}
+
+ %User{} ->
+ {:error, :no_recovery_email}
+ end
+ end)
+
+ if Enum.any?(results, fn
+ {:ok, %User{}} -> true
+ _ -> false
+ end) do
+ {:ok, :emails_sent}
+ else
+ if Enum.any?(results, fn
+ {:error, :email_failed} -> true
+ _ -> false
+ end) do
+ {:error, :email_failed}
+ else
+ {:ok, :user_not_found}
+ end
+ end
+ end
+ end
+
+ @doc ~s|Gets all users by recovery email (returns a list to handle potential duplicates).\n|
+ def get_users_by_recovery_email(email) when is_binary(email) do
+ from(u in User, where: u.recovery_email == ^email) |> Repo.all()
+ end
+
+ @doc ~s|Gets a user by password reset token.\n|
+ def get_user_by_password_reset_token(token) when is_binary(token) do
+ hashed_token = User.hash_sensitive_token(token)
+
+ User
+ |> where([u], u.password_reset_token == ^hashed_token)
+ |> Repo.one()
+ end
+
+ @doc ~s|Resets a user's password using a valid token.\n|
+ def reset_password_with_token(token, attrs) when is_binary(token) do
+ case get_user_by_password_reset_token(token) do
+ %User{is_admin: true} = user ->
+ Logger.error(
+ "SECURITY ALERT: Attempted password reset completion for admin user: #{user.username}"
+ )
+
+ {:error, :invalid_token}
+
+ %User{} = user ->
+ if User.valid_password_reset_token?(user) do
+ case user |> User.password_reset_with_token_changeset(attrs) |> Repo.update() do
+ {:ok, updated_user} ->
+ invalidate_long_lived_credentials(updated_user)
+ {:ok, updated_user}
+
+ error ->
+ error
+ end
+ else
+ {:error, :invalid_token}
+ end
+
+ nil ->
+ {:error, :invalid_token}
+ end
+ end
+
+ @doc ~s|Validates a password reset token without using it.\n|
+ def validate_password_reset_token(token) when is_binary(token) do
+ case get_user_by_password_reset_token(token) do
+ %User{is_admin: true} = user ->
+ Logger.error(
+ "SECURITY ALERT: Attempted password reset token validation for admin user: #{user.username}"
+ )
+
+ {:error, :invalid_token}
+
+ %User{} = user ->
+ if User.valid_password_reset_token?(user) do
+ {:ok, user}
+ else
+ {:error, :invalid_token}
+ end
+
+ nil ->
+ {:error, :invalid_token}
+ end
+ end
+
+ @doc ~s|Clears a password reset token.\n|
+ def clear_password_reset_token(%User{} = user) do
+ user |> User.clear_password_reset_changeset() |> Repo.update()
+ end
+
+ @doc ~s|Returns users whose passwords are older than the specified number of days.\n|
+ def get_users_with_old_passwords(max_days) when is_integer(max_days) do
+ cutoff_date = DateTime.utc_now() |> DateTime.add(-max_days, :day)
+
+ from(u in User,
+ where: not is_nil(u.last_password_change) and u.last_password_change < ^cutoff_date,
+ select: [:id, :username, :email, :last_password_change, :is_admin],
+ order_by: [asc: u.last_password_change]
+ )
+ |> Repo.all()
+ end
+
+ @doc ~s|Returns the count of users whose passwords are older than the specified number of days.\n|
+ def count_users_with_old_passwords(max_days) when is_integer(max_days) do
+ cutoff_date = DateTime.utc_now() |> DateTime.add(-max_days, :day)
+
+ from(u in User,
+ where: not is_nil(u.last_password_change) and u.last_password_change < ^cutoff_date
+ )
+ |> Repo.aggregate(:count)
+ end
+
+ @doc ~s|Admin function to reset a user's password with a new temporary password.\n|
+ def admin_reset_password(user, attrs) do
+ case user |> User.admin_password_reset_changeset(attrs) |> Repo.update() do
+ {:ok, updated_user} = result ->
+ invalidate_long_lived_credentials(updated_user)
+ result
+
+ error ->
+ error
+ end
+ end
+
+ defp generate_password_reset_token do
+ :crypto.strong_rand_bytes(32) |> Base.url_encode64(padding: false)
+ end
+
+ defp send_password_reset_email(%User{recovery_email: recovery_email} = user, token)
+ when not is_nil(recovery_email) do
+ case Elektrine.UserNotifier.password_reset_instructions(user, token)
+ |> Elektrine.Mailer.deliver_later() do
+ {:ok, result} ->
+ {:ok, result}
+
+ {:error, reason} ->
+ Logger.error("Failed to send password reset email: #{mail_error_summary(reason)}")
+ {:error, :email_failed}
+ end
+ rescue
+ e ->
+ Logger.error("Failed to send password reset email: #{mail_error_summary(e)}")
+ {:error, :email_failed}
+ end
+
+ defp send_password_reset_email(_, _) do
+ {:error, :no_recovery_email}
+ end
+
+ defp password_reset_state(%User{} = user) do
+ %{
+ password_reset_token: user.password_reset_token,
+ password_reset_token_expires_at: user.password_reset_token_expires_at
+ }
+ end
+
+ defp restore_password_reset_state(%User{} = user, previous_state) do
+ case user |> Ecto.Changeset.change(previous_state) |> Repo.update() do
+ {:ok, _restored_user} ->
+ :ok
+
+ {:error, _restore_reason} ->
+ Logger.error("Failed to restore password reset token state")
+ :error
+ end
+ end
+
+ defp mail_error_summary(%{__struct__: module}) do
+ inspect(module)
+ end
+
+ defp mail_error_summary(reason) when is_atom(reason) do
+ Atom.to_string(reason)
+ end
+
+ defp mail_error_summary(_reason), do: "delivery_failed"
+end
diff --git a/apps/elektrine/lib/elektrine/accounts/blocked_users_cache.ex b/apps/elektrine/lib/elektrine/accounts/blocked_users_cache.ex
new file mode 100644
index 0000000..ade3654
--- /dev/null
+++ b/apps/elektrine/lib/elektrine/accounts/blocked_users_cache.ex
@@ -0,0 +1,56 @@
+defmodule Elektrine.Accounts.BlockedUsersCache do
+ @moduledoc """
+ Cached blocked users lookup to avoid repeated database queries.
+ """
+
+ import Ecto.Query
+ alias Elektrine.Accounts.User
+ alias Elektrine.Repo
+
+ @cache_ttl :timer.minutes(5)
+
+ @doc """
+ Gets all user IDs that are blocked (both ways) for a given user.
+ Cached for 5 minutes to avoid repeated queries on every feed load.
+ """
+ def get_all_blocked_user_ids(user_id) do
+ case Cachex.get(:blocked_users_cache, cache_key(user_id)) do
+ {:ok, nil} ->
+ # Cache miss - fetch from database
+ blocked_ids = fetch_blocked_ids(user_id)
+ Cachex.put(:blocked_users_cache, cache_key(user_id), blocked_ids, ttl: @cache_ttl)
+ blocked_ids
+
+ {:ok, blocked_ids} ->
+ # Cache hit
+ blocked_ids
+
+ {:error, _} ->
+ # Cache error - fetch directly
+ fetch_blocked_ids(user_id)
+ end
+ end
+
+ @doc """
+ Invalidates the blocked users cache for a user.
+ Call this when a user blocks/unblocks someone.
+ """
+ def invalidate(user_id) do
+ Cachex.del(:blocked_users_cache, cache_key(user_id))
+ end
+
+ defp cache_key(user_id), do: "blocked_#{user_id}"
+
+ defp fetch_blocked_ids(user_id) do
+ # Single optimized query to get all blocked user IDs (both directions)
+ from(u in User,
+ left_join: b1 in "user_blocks",
+ on: b1.blocker_id == ^user_id and b1.blocked_id == u.id,
+ left_join: b2 in "user_blocks",
+ on: b2.blocked_id == ^user_id and b2.blocker_id == u.id,
+ where: not is_nil(b1.id) or not is_nil(b2.id),
+ select: u.id
+ )
+ |> Repo.all()
+ end
+end
diff --git a/apps/elektrine/lib/elektrine/accounts/blocking.ex b/apps/elektrine/lib/elektrine/accounts/blocking.ex
new file mode 100644
index 0000000..9830645
--- /dev/null
+++ b/apps/elektrine/lib/elektrine/accounts/blocking.ex
@@ -0,0 +1,424 @@
+defmodule Elektrine.Accounts.Blocking do
+ @moduledoc """
+ User blocking functionality.
+ Handles blocking, unblocking, and checking blocked relationships between users.
+ """
+
+ import Ecto.Query, warn: false
+ alias Elektrine.Accounts.{User, UserBlock}
+ alias Elektrine.ActivityPub.UserBlock, as: ActivityPubUserBlock
+ alias Elektrine.Repo
+
+ @doc """
+ Blocks a user.
+ """
+ def block_user(blocker_id, blocked_id, reason \\ nil) do
+ result =
+ %UserBlock{}
+ |> UserBlock.changeset(%{
+ blocker_id: blocker_id,
+ blocked_id: blocked_id,
+ reason: reason
+ })
+ |> Repo.insert()
+
+ notify_home_feed_policy_changed(blocker_id, :blocked_user)
+ result
+ end
+
+ @doc """
+ Unblocks a user.
+ """
+ def unblock_user(blocker_id, blocked_id) do
+ result =
+ case Repo.get_by(UserBlock, blocker_id: blocker_id, blocked_id: blocked_id) do
+ nil -> {:error, :not_blocked}
+ block -> Repo.delete(block)
+ end
+
+ notify_home_feed_policy_changed(blocker_id, :unblocked_user)
+ result
+ end
+
+ @doc """
+ Checks if a user is blocked by another user.
+ """
+ def user_blocked?(blocker_id, blocked_id) do
+ Repo.exists?(
+ from(ub in UserBlock,
+ where: ub.blocker_id == ^blocker_id and ub.blocked_id == ^blocked_id
+ )
+ )
+ end
+
+ @doc """
+ Gets all users blocked by a user.
+ """
+ def list_blocked_users(blocker_id) do
+ from(u in User,
+ join: ub in UserBlock,
+ on: ub.blocked_id == u.id,
+ where: ub.blocker_id == ^blocker_id,
+ order_by: [desc: ub.inserted_at],
+ preload: [:profile]
+ )
+ |> Repo.all()
+ end
+
+ @doc """
+ Gets all users who have blocked a user.
+ """
+ def list_users_who_blocked(blocked_id) do
+ from(u in User,
+ join: ub in UserBlock,
+ on: ub.blocker_id == u.id,
+ where: ub.blocked_id == ^blocked_id,
+ select: u
+ )
+ |> Repo.all()
+ end
+
+ @doc """
+ Blocks a remote actor (ActivityPub federation).
+ """
+ def block_remote_actor(user_id, remote_actor_id) do
+ alias Elektrine.ActivityPub.UserBlock
+
+ # Get remote actor URI
+ remote_actor = Elektrine.Repo.get(Elektrine.ActivityPub.Actor, remote_actor_id)
+
+ if remote_actor do
+ # Create block record
+ result =
+ %UserBlock{}
+ |> UserBlock.changeset(%{
+ user_id: user_id,
+ blocked_uri: remote_actor.uri,
+ block_type: "user"
+ })
+ |> Repo.insert()
+
+ case result do
+ {:ok, block} ->
+ # Federate Block activity
+ Elektrine.Async.start(fn ->
+ user = Elektrine.Accounts.get_user!(user_id)
+
+ block_activity =
+ Elektrine.ActivityPub.Builder.build_block_activity(user, remote_actor.uri)
+
+ Elektrine.ActivityPub.Publisher.publish(block_activity, user, [remote_actor.inbox_url])
+ end)
+
+ notify_home_feed_policy_changed(user_id, :blocked_remote_actor)
+ {:ok, block}
+
+ error ->
+ error
+ end
+ else
+ {:error, :remote_actor_not_found}
+ end
+ end
+
+ @doc """
+ Unblocks a remote actor (ActivityPub federation).
+ """
+ def unblock_remote_actor(user_id, remote_actor_id) do
+ alias Elektrine.ActivityPub.UserBlock
+
+ # Get remote actor URI
+ remote_actor = Elektrine.Repo.get(Elektrine.ActivityPub.Actor, remote_actor_id)
+
+ if remote_actor do
+ # Delete block record
+ case Repo.get_by(UserBlock, user_id: user_id, blocked_uri: remote_actor.uri) do
+ nil ->
+ {:error, :not_blocked}
+
+ block ->
+ result = Repo.delete(block)
+
+ case result do
+ {:ok, _deleted_block} ->
+ # Federate Undo Block activity
+ Elektrine.Async.start(fn ->
+ user = Elektrine.Accounts.get_user!(user_id)
+
+ # Build original Block activity
+ original_block = %{
+ "type" => "Block",
+ "actor" => "#{Elektrine.ActivityPub.instance_url()}/users/#{user.username}",
+ "object" => remote_actor.uri
+ }
+
+ undo_activity =
+ Elektrine.ActivityPub.Builder.build_undo_activity(user, original_block)
+
+ Elektrine.ActivityPub.Publisher.publish(undo_activity, user, [
+ remote_actor.inbox_url
+ ])
+ end)
+
+ notify_home_feed_policy_changed(user_id, :unblocked_remote_actor)
+ {:ok, :unblocked}
+
+ error ->
+ error
+ end
+ end
+ else
+ {:error, :remote_actor_not_found}
+ end
+ end
+
+ @doc """
+ Checks if a user has blocked a remote actor.
+ """
+ def remote_actor_blocked?(user_id, remote_actor_id) do
+ alias Elektrine.ActivityPub.UserBlock
+
+ remote_actor = Elektrine.Repo.get(Elektrine.ActivityPub.Actor, remote_actor_id)
+
+ if remote_actor do
+ Repo.exists?(
+ from(ub in UserBlock,
+ where:
+ ub.user_id == ^user_id and ub.blocked_uri == ^remote_actor.uri and
+ ub.block_type == "user"
+ )
+ )
+ else
+ false
+ end
+ end
+
+ @doc """
+ Lists all remote actors blocked by a user.
+ """
+ def list_blocked_remote_actors(user_id) do
+ from(ub in ActivityPubUserBlock,
+ where: ub.user_id == ^user_id and ub.block_type == "user",
+ order_by: [desc: ub.inserted_at]
+ )
+ |> Repo.all()
+ end
+
+ @doc """
+ Mutes a remote actor for local timelines and notifications.
+ """
+ def mute_remote_actor(user_id, remote_actor_id) do
+ case Elektrine.Repo.get(Elektrine.ActivityPub.Actor, remote_actor_id) do
+ nil ->
+ {:error, :remote_actor_not_found}
+
+ remote_actor ->
+ result =
+ case Repo.get_by(ActivityPubUserBlock,
+ user_id: user_id,
+ blocked_uri: remote_actor.uri
+ ) do
+ nil ->
+ %ActivityPubUserBlock{}
+ |> ActivityPubUserBlock.changeset(%{
+ user_id: user_id,
+ blocked_uri: remote_actor.uri,
+ block_type: "mute"
+ })
+ |> Repo.insert()
+
+ %ActivityPubUserBlock{} = existing ->
+ {:ok, existing}
+ end
+
+ notify_home_feed_policy_changed(user_id, :muted_remote_actor)
+ result
+ end
+ end
+
+ @doc """
+ Unmutes a remote actor.
+ """
+ def unmute_remote_actor(user_id, remote_actor_id) do
+ case Elektrine.Repo.get(Elektrine.ActivityPub.Actor, remote_actor_id) do
+ nil ->
+ {:error, :remote_actor_not_found}
+
+ remote_actor ->
+ result =
+ case Repo.get_by(ActivityPubUserBlock,
+ user_id: user_id,
+ blocked_uri: remote_actor.uri,
+ block_type: "mute"
+ ) do
+ nil -> {:error, :not_muted}
+ mute -> Repo.delete(mute)
+ end
+
+ notify_home_feed_policy_changed(user_id, :unmuted_remote_actor)
+ result
+ end
+ end
+
+ @doc """
+ Checks if a user has muted a remote actor.
+ """
+ def remote_actor_muted?(user_id, remote_actor_id) do
+ case Elektrine.Repo.get(Elektrine.ActivityPub.Actor, remote_actor_id) do
+ nil ->
+ false
+
+ remote_actor ->
+ Repo.exists?(
+ from ub in ActivityPubUserBlock,
+ where:
+ ub.user_id == ^user_id and ub.blocked_uri == ^remote_actor.uri and
+ ub.block_type == "mute"
+ )
+ end
+ end
+
+ @doc """
+ Lists all remote actor mutes for a user.
+ """
+ def list_muted_remote_actors(user_id) do
+ from(ub in ActivityPubUserBlock,
+ where: ub.user_id == ^user_id and ub.block_type == "mute",
+ order_by: [desc: ub.inserted_at]
+ )
+ |> Repo.all()
+ end
+
+ @doc """
+ Blocks a remote domain for a specific user.
+ """
+ def block_domain(user_id, domain) when is_integer(user_id) and is_binary(domain) do
+ with {:ok, domain} <- normalize_domain_block(domain) do
+ result =
+ case Repo.get_by(ActivityPubUserBlock, user_id: user_id, blocked_uri: domain) do
+ nil ->
+ %ActivityPubUserBlock{}
+ |> ActivityPubUserBlock.changeset(%{
+ user_id: user_id,
+ blocked_uri: domain,
+ block_type: "domain"
+ })
+ |> Repo.insert()
+
+ %ActivityPubUserBlock{} = block ->
+ {:ok, block}
+ end
+
+ notify_home_feed_policy_changed(user_id, :blocked_domain)
+ result
+ end
+ end
+
+ def block_domain(_user_id, _domain), do: {:error, :invalid_domain}
+
+ @doc """
+ Unblocks a remote domain for a specific user.
+ """
+ def unblock_domain(user_id, domain) when is_integer(user_id) and is_binary(domain) do
+ with {:ok, domain} <- normalize_domain_block(domain) do
+ result =
+ case Repo.get_by(ActivityPubUserBlock,
+ user_id: user_id,
+ blocked_uri: domain,
+ block_type: "domain"
+ ) do
+ nil -> {:ok, :not_blocked}
+ block -> Repo.delete(block)
+ end
+
+ notify_home_feed_policy_changed(user_id, :unblocked_domain)
+ result
+ end
+ end
+
+ def unblock_domain(_user_id, _domain), do: {:error, :invalid_domain}
+
+ @doc """
+ Lists remote domains blocked by a specific user.
+ """
+ def list_blocked_domains(user_id) when is_integer(user_id) do
+ from(ub in ActivityPubUserBlock,
+ where: ub.user_id == ^user_id and ub.block_type == "domain",
+ order_by: [asc: ub.blocked_uri],
+ select: ub.blocked_uri
+ )
+ |> Repo.all()
+ end
+
+ def list_blocked_domains(_user_id), do: []
+
+ @doc """
+ Checks whether a remote domain is blocked by a user.
+ """
+ def domain_blocked?(user_id, domain) when is_integer(user_id) and is_binary(domain) do
+ Repo.exists?(
+ from ub in ActivityPubUserBlock,
+ where: ub.user_id == ^user_id and ub.block_type == "domain",
+ where:
+ fragment("lower(?)", ub.blocked_uri) == fragment("lower(?)", ^domain) or
+ fragment(
+ "? LIKE '*.%' AND lower(?) LIKE ('%.' || substring(lower(?) from 3))",
+ ub.blocked_uri,
+ ^domain,
+ ub.blocked_uri
+ )
+ )
+ end
+
+ def domain_blocked?(_user_id, _domain), do: false
+
+ defp normalize_domain_block(value) when is_binary(value) do
+ value =
+ value
+ |> String.trim()
+ |> String.trim_leading("@")
+ |> String.downcase()
+
+ value =
+ case URI.parse(value) do
+ %URI{host: host} when is_binary(host) -> host
+ _ -> value
+ end
+
+ wildcard? = String.starts_with?(value, "*.")
+ domain = if wildcard?, do: String.trim_leading(value, "*."), else: value
+ domain = domain |> String.split("/", parts: 2) |> List.first() |> String.trim(".")
+
+ cond do
+ domain == "" ->
+ {:error, :invalid_domain}
+
+ not Regex.match?(~r/^[a-z0-9.-]+$/, domain) ->
+ {:error, :invalid_domain}
+
+ String.contains?(domain, "..") ->
+ {:error, :invalid_domain}
+
+ not String.contains?(domain, ".") ->
+ {:error, :invalid_domain}
+
+ wildcard? ->
+ {:ok, "*." <> domain}
+
+ true ->
+ {:ok, domain}
+ end
+ end
+
+ defp notify_home_feed_policy_changed(user_id, reason) when is_integer(user_id) do
+ module = Module.concat([Elektrine, Social, HomeFeedInvalidationWorker])
+
+ if Code.ensure_loaded?(module) do
+ _ = module.clear_user(user_id, reason)
+ end
+
+ :ok
+ rescue
+ _ -> :ok
+ end
+end
diff --git a/apps/elektrine/lib/elektrine/accounts/built_in_subdomain.ex b/apps/elektrine/lib/elektrine/accounts/built_in_subdomain.ex
new file mode 100644
index 0000000..a3c43d9
--- /dev/null
+++ b/apps/elektrine/lib/elektrine/accounts/built_in_subdomain.ex
@@ -0,0 +1,12 @@
+defmodule Elektrine.Accounts.BuiltInSubdomain do
+ @moduledoc false
+
+ @modes ~w(path platform external_dns)
+
+ def modes, do: @modes
+
+ def mode(%{built_in_subdomain_mode: mode}) when mode in @modes, do: mode
+ def mode(_), do: "path"
+
+ def hosted_by_platform?(user), do: mode(user) == "platform"
+end
diff --git a/apps/elektrine/lib/elektrine/accounts/cached.ex b/apps/elektrine/lib/elektrine/accounts/cached.ex
new file mode 100644
index 0000000..fabb931
--- /dev/null
+++ b/apps/elektrine/lib/elektrine/accounts/cached.ex
@@ -0,0 +1,107 @@
+defmodule Elektrine.Accounts.Cached do
+ @moduledoc """
+ Cached versions of Accounts context functions.
+ Provides caching for user data, preferences, and authentication.
+ """
+
+ alias Elektrine.Accounts
+ alias Elektrine.AppCache
+ alias Elektrine.EmailAddresses
+
+ @doc """
+ Gets cached user data by ID.
+ """
+ def get_user!(id) do
+ {:ok, user} =
+ AppCache.get_user_data(id, fn ->
+ Accounts.get_user!(id)
+ end)
+
+ user
+ end
+
+ @doc """
+ Gets cached user data by username.
+ """
+ def get_user_by_username(username) do
+ # For username lookups, we cache with a different key
+ {:ok, user} =
+ AppCache.get_system_config("user_by_username:#{username}", fn ->
+ Accounts.get_user_by_username(username)
+ end)
+
+ user
+ end
+
+ @doc """
+ Gets cached user data by ActivityPub identifier (handle canonical, username alias).
+ """
+ def get_user_by_activitypub_identifier(identifier) do
+ {:ok, user} =
+ AppCache.get_system_config("user_by_activitypub_identifier:#{identifier}", fn ->
+ Accounts.get_user_by_activitypub_identifier(identifier)
+ end)
+
+ user
+ end
+
+ @doc """
+ Gets cached user preferences/settings.
+ """
+ def get_user_preferences(user_id) do
+ {:ok, preferences} =
+ AppCache.get_user_preferences(user_id, fn ->
+ # This would need to be implemented in Accounts context
+ # Return a baseline structure until Accounts-backed preferences are wired in.
+ %{
+ email_notifications: true,
+ theme: "light",
+ timezone: "UTC",
+ language: "en",
+ time_format: "12"
+ }
+ end)
+
+ preferences
+ end
+
+ @doc """
+ Invalidates all cached data for a user.
+ Should be called when user data changes. Accepts a `%User{}` (clears the
+ lookup caches keyed by that struct's username/email) or a user id.
+ """
+ def invalidate_user_cache(%Accounts.User{} = user) do
+ AppCache.invalidate_user_cache(user.id)
+ delete_lookup_caches(user)
+ end
+
+ def invalidate_user_cache(user_id) do
+ AppCache.invalidate_user_cache(user_id)
+
+ try do
+ user_id
+ |> Accounts.get_user!()
+ |> delete_lookup_caches()
+ rescue
+ _ -> :ok
+ end
+ end
+
+ defp delete_lookup_caches(user) do
+ AppCache.delete_system_config("user_by_username:#{user.username}")
+ AppCache.delete_system_config("user_by_activitypub_identifier:#{user.username}")
+
+ if user.handle do
+ AppCache.delete_system_config("user_by_activitypub_identifier:#{user.handle}")
+ end
+
+ try do
+ user_email = EmailAddresses.primary_for_user(user)
+ AppCache.delete_system_config("user_by_email:#{user_email}")
+ rescue
+ _ -> :ok
+ end
+
+ :ok
+ end
+end
diff --git a/apps/elektrine/lib/elektrine/accounts/capabilities.ex b/apps/elektrine/lib/elektrine/accounts/capabilities.ex
new file mode 100644
index 0000000..1093635
--- /dev/null
+++ b/apps/elektrine/lib/elektrine/accounts/capabilities.ex
@@ -0,0 +1,420 @@
+defmodule Elektrine.Accounts.Capabilities do
+ @moduledoc """
+ Central account capability policy.
+
+ Proofs and clean account history feed reputation. Reputation, trust level,
+ restrictions, and credits then determine what an account can do and what
+ risky actions cost.
+ """
+
+ alias Elektrine.Accounts.User
+ alias Elektrine.{Friends, Repo, System}
+
+ @personhood_module Module.concat([Atomine, Personhood])
+ @credits_module Module.concat([Atomine, Credits])
+ @credit_earning_policy_module Module.concat([Atomine, CreditEarningPolicy])
+
+ @first_dm_action "first_dm"
+ @send_email_action "send_email"
+ @first_dm_cost 1
+ @send_email_cost 1
+
+ @email_tier_limits %{
+ day_1: %{minute: 1, hour: 5, day: 10, recipients: 3},
+ days_2_3: %{minute: 2, hour: 8, day: 10, recipients: 5},
+ days_4_7: %{minute: 2, hour: 15, day: 10, recipients: 10},
+ week_2: %{minute: 3, hour: 25, day: 10, recipients: 15},
+ weeks_3_4: %{minute: 3, hour: 35, day: 10, recipients: 20},
+ tl1: %{minute: 5, hour: 50, day: 200, recipients: 50},
+ tl2: %{minute: 10, hour: 100, day: 500, recipients: 100},
+ tl3_plus: %{minute: 15, hour: 150, day: 1000, recipients: 200}
+ }
+
+ @vpn_default_bandwidth_quota_bytes 10_737_418_240
+ @vpn_default_rate_limit_mbps 50
+
+ @doc "Returns the complete policy snapshot for an account."
+ def snapshot(user_or_id, opts \\ []) do
+ user = load_user(user_or_id)
+ reputation = reputation_snapshot(user)
+ credits = credit_snapshot(user)
+
+ %{
+ user_id: user && user.id,
+ trust_level: trust_level(user),
+ effective_trust_level: effective_trust_level(user, reputation),
+ reputation: reputation,
+ proofs: %{
+ verified_count: reputation.verified_proof_count,
+ verified_kinds: reputation.verified_proof_kinds
+ },
+ credits: credits,
+ restrictions: restrictions(user),
+ capabilities: %{
+ email: email_capability(user),
+ dm: dm_capability(user, opts),
+ vpn: vpn_capability(user, opts),
+ invites: invite_capability(user)
+ }
+ }
+ end
+
+ @doc "Returns current built-in action pricing for UI/API discovery."
+ def action_prices do
+ [
+ %{
+ action: @first_dm_action,
+ label: "First DM",
+ atomine_cost: @first_dm_cost,
+ gate_enabled: credit_gate_enabled?(:dm),
+ free_for: "TL1+ and admins"
+ },
+ %{
+ action: @send_email_action,
+ label: "External email",
+ atomine_cost: @send_email_cost,
+ gate_enabled: credit_gate_enabled?(:email),
+ free_for: "TL3+ and admins"
+ }
+ ]
+ end
+
+ def credit_gate_enabled?(:dm) do
+ :atomine
+ |> Application.get_env(:credits, [])
+ |> Keyword.get(:dm_gate_enabled, false)
+ end
+
+ def credit_gate_enabled?(:email) do
+ :atomine
+ |> Application.get_env(:credits, [])
+ |> Keyword.get(:email_gate_enabled, false)
+ end
+
+ def credit_gate_enabled?(_gate), do: false
+
+ @doc "Returns whether a first DM is free or requires credits."
+ def first_dm_credit_requirement(user_or_id, opts \\ []) do
+ if credit_gate_enabled?(:dm) do
+ user = load_user(user_or_id)
+ local_recipient_id = Keyword.get(opts, :local_recipient_id)
+
+ cond do
+ is_nil(user) ->
+ {:error, :user_not_found}
+
+ trusted_for_free_action?(user) ->
+ :free
+
+ is_integer(local_recipient_id) and Friends.are_friends?(user.id, local_recipient_id) ->
+ :free
+
+ true ->
+ :required
+ end
+ else
+ :free
+ end
+ end
+
+ @doc "Returns whether an external email send is free or requires credits."
+ def email_credit_requirement(user_or_id, _opts \\ []) do
+ if credit_gate_enabled?(:email) do
+ case load_user(user_or_id) do
+ nil -> {:error, :user_not_found}
+ user -> if trusted_for_free_email?(user), do: :free, else: :required
+ end
+ else
+ :free
+ end
+ end
+
+ @doc "Returns the email send tier and limits for a user."
+ def email_limits(user_or_id) do
+ user = load_user(user_or_id)
+ tier = email_tier(user)
+ limits = Map.fetch!(@email_tier_limits, tier)
+
+ %{
+ tier: tier,
+ minute_limit: limits.minute,
+ hour_limit: limits.hour,
+ day_limit: limits.day,
+ recipient_limit: limits.recipients
+ }
+ end
+
+ @doc "Returns the configured platform-wide VPN minimum trust level."
+ def vpn_minimum_trust_level, do: System.module_min_trust_level(:vpn)
+
+ @doc "Returns VPN access and quota policy for a user."
+ def vpn_capability(user_or_id, opts \\ []) do
+ user = load_user(user_or_id)
+ required_trust_level = vpn_required_trust_level(opts)
+
+ base = %{
+ required_trust_level: required_trust_level,
+ bandwidth_quota_bytes: @vpn_default_bandwidth_quota_bytes,
+ rate_limit_mbps: @vpn_default_rate_limit_mbps
+ }
+
+ cond do
+ is_nil(user) ->
+ Map.merge(base, %{allowed: false, reason: :user_not_found})
+
+ user.is_admin ->
+ Map.merge(base, %{allowed: true, reason: nil})
+
+ user.banned ->
+ Map.merge(base, %{allowed: false, reason: :banned})
+
+ user.suspended ->
+ Map.merge(base, %{allowed: false, reason: :suspended})
+
+ trust_level(user) < required_trust_level ->
+ Map.merge(base, %{allowed: false, reason: :insufficient_trust_level})
+
+ true ->
+ Map.merge(base, %{allowed: true, reason: nil})
+ end
+ end
+
+ @doc "Returns true when a user can access VPN under the current policy."
+ def vpn_allowed?(user_or_id, opts \\ []), do: vpn_capability(user_or_id, opts).allowed
+
+ defp dm_capability(user, opts) do
+ requirement = first_dm_credit_requirement(user, opts)
+
+ %{
+ first_dm_credit_requirement: requirement,
+ first_dm_atomine_cost: @first_dm_cost
+ }
+ end
+
+ defp email_capability(user) do
+ limits = email_limits(user)
+ requirement = email_credit_requirement(user)
+
+ %{
+ allowed: email_allowed?(user),
+ reason: email_block_reason(user),
+ tier: limits.tier,
+ minute_limit: limits.minute_limit,
+ hour_limit: limits.hour_limit,
+ day_limit: limits.day_limit,
+ recipient_limit: limits.recipient_limit,
+ external_send_credit_requirement: requirement,
+ external_send_atomine_cost: @send_email_cost
+ }
+ end
+
+ defp invite_capability(nil) do
+ %{self_service_allowed: false, min_trust_level: System.self_service_invite_min_trust_level()}
+ end
+
+ defp invite_capability(%User{} = user) do
+ min_trust_level = System.self_service_invite_min_trust_level()
+
+ %{
+ self_service_allowed: user.is_admin || trust_level(user) >= min_trust_level,
+ min_trust_level: min_trust_level
+ }
+ end
+
+ defp credit_snapshot(nil) do
+ %{
+ available?: atomine_module_loaded?(@credits_module),
+ balances: %{},
+ rows: [],
+ action_prices: [],
+ earning_paths: []
+ }
+ end
+
+ defp credit_snapshot(%User{id: user_id}) do
+ if atomine_module_loaded?(@credits_module) do
+ credit_types = ["atomine_credit"]
+ accounts = Atomine.Credits.list_accounts(user_id)
+ balances = Map.new(accounts, &{&1.credit_type, &1.balance})
+
+ rows =
+ Enum.map(credit_types, fn credit_type ->
+ %{
+ type: credit_type,
+ label: credit_label(credit_type),
+ balance: Map.get(balances, credit_type, 0)
+ }
+ end)
+
+ %{
+ available?: true,
+ balances: Map.new(rows, &{&1.type, &1.balance}),
+ rows: rows,
+ action_prices: action_prices(),
+ earning_paths: credit_earning_paths()
+ }
+ else
+ %{available?: false, balances: %{}, rows: [], action_prices: [], earning_paths: []}
+ end
+ end
+
+ defp credit_earning_paths do
+ if atomine_module_loaded?(@credit_earning_policy_module) do
+ Atomine.CreditEarningPolicy.earning_paths()
+ else
+ []
+ end
+ end
+
+ defp reputation_snapshot(nil) do
+ breakdown = empty_reputation_breakdown()
+
+ %{
+ score: breakdown.score,
+ level: breakdown.level,
+ verified_proof_count: 0,
+ verified_proof_kinds: [],
+ breakdown: breakdown
+ }
+ end
+
+ defp reputation_snapshot(%User{} = user) do
+ breakdown = personhood_breakdown(user)
+
+ %{
+ score: breakdown.score,
+ level: breakdown.level,
+ verified_proof_count: breakdown.verified_proof_count,
+ verified_proof_kinds: breakdown.verified_proof_kinds,
+ breakdown: breakdown
+ }
+ end
+
+ defp personhood_breakdown(user) do
+ if atomine_module_loaded?(@personhood_module) do
+ Atomine.Personhood.personhood_breakdown(user)
+ else
+ empty_reputation_breakdown()
+ end
+ end
+
+ defp restrictions(nil) do
+ %{banned: false, suspended: false, email_sending_restricted: false}
+ end
+
+ defp restrictions(%User{} = user) do
+ %{
+ banned: user.banned || false,
+ suspended: user.suspended || false,
+ email_sending_restricted: user.email_sending_restricted || false,
+ email_rate_limit_violations: user.email_rate_limit_violations || 0,
+ email_restriction_reason: user.email_restriction_reason
+ }
+ end
+
+ defp email_allowed?(%User{} = user) do
+ !user.banned && !user.suspended && !user.email_sending_restricted
+ end
+
+ defp email_allowed?(_), do: false
+
+ defp email_block_reason(nil), do: :user_not_found
+ defp email_block_reason(%User{banned: true}), do: :banned
+ defp email_block_reason(%User{suspended: true}), do: :suspended
+ defp email_block_reason(%User{email_sending_restricted: true}), do: :email_sending_restricted
+ defp email_block_reason(%User{}), do: nil
+
+ defp email_tier(nil), do: :day_1
+
+ defp email_tier(%User{} = user) do
+ account_age_days = account_age_days(user.inserted_at)
+ trust_level = effective_trust_level(user)
+
+ cond do
+ trust_level >= 3 -> :tl3_plus
+ trust_level == 2 -> :tl2
+ trust_level == 1 -> :tl1
+ account_age_days < 1 -> :day_1
+ account_age_days < 3 -> :days_2_3
+ account_age_days < 7 -> :days_4_7
+ account_age_days < 14 -> :week_2
+ account_age_days < 30 -> :weeks_3_4
+ true -> :weeks_3_4
+ end
+ end
+
+ defp vpn_required_trust_level(opts) do
+ server_min_trust_level = Keyword.get(opts, :server_min_trust_level, 0) || 0
+ max(vpn_minimum_trust_level(), server_min_trust_level)
+ end
+
+ defp trusted_for_free_action?(%User{is_admin: true}), do: true
+ defp trusted_for_free_action?(%User{} = user), do: effective_trust_level(user) >= 1
+
+ defp trusted_for_free_email?(%User{is_admin: true}), do: true
+ defp trusted_for_free_email?(%User{} = user), do: effective_trust_level(user) >= 3
+
+ defp effective_trust_level(%User{} = user) do
+ effective_trust_level(user, reputation_snapshot(user))
+ end
+
+ defp effective_trust_level(%User{} = user, reputation) do
+ max(trust_level(user), reputation_trust_level(reputation))
+ end
+
+ defp effective_trust_level(_user, _reputation), do: 0
+
+ defp reputation_trust_level(%{level: :high}), do: 3
+ defp reputation_trust_level(%{level: :medium}), do: 2
+ defp reputation_trust_level(%{level: :low}), do: 1
+ defp reputation_trust_level(_reputation), do: 0
+
+ defp trust_level(%User{trust_level: trust_level}) when is_integer(trust_level), do: trust_level
+ defp trust_level(_), do: 0
+
+ defp load_user(%User{} = user), do: user
+ defp load_user(user_id) when is_integer(user_id), do: Repo.get(User, user_id)
+ defp load_user(_), do: nil
+
+ defp account_age_days(%DateTime{} = inserted_at),
+ do: DateTime.diff(DateTime.utc_now(), inserted_at, :day)
+
+ defp account_age_days(%NaiveDateTime{} = inserted_at) do
+ inserted_at
+ |> DateTime.from_naive!("Etc/UTC")
+ |> account_age_days()
+ end
+
+ defp account_age_days(_), do: 0
+
+ defp atomine_module_loaded?(module), do: Code.ensure_loaded?(module)
+
+ defp credit_label("atomine_credit"), do: "Identity Credits"
+
+ defp credit_label(value) do
+ value
+ |> to_string()
+ |> String.replace("_", " ")
+ |> String.split()
+ |> Enum.map_join(" ", &String.capitalize/1)
+ end
+
+ defp empty_reputation_breakdown do
+ %{
+ score: 0,
+ raw_score: 0,
+ level: :unknown,
+ positive: %{
+ proofs: 0,
+ proof_diversity: 0,
+ account_age: 0,
+ security: 0,
+ account_history: 0,
+ platform_trust: 0
+ },
+ penalties: %{account_restrictions: 0, onion_registration: 0, proof_rejections: 0},
+ verified_proof_count: 0,
+ verified_proof_kinds: []
+ }
+ end
+end
diff --git a/apps/elektrine/lib/elektrine/accounts/client_app_setting.ex b/apps/elektrine/lib/elektrine/accounts/client_app_setting.ex
new file mode 100644
index 0000000..83e3fd1
--- /dev/null
+++ b/apps/elektrine/lib/elektrine/accounts/client_app_setting.ex
@@ -0,0 +1,39 @@
+defmodule Elektrine.Accounts.ClientAppSetting do
+ @moduledoc false
+ use Ecto.Schema
+
+ import Ecto.Changeset
+
+ schema "client_app_settings" do
+ field :app, :string
+ field :settings, :map, default: %{}
+
+ belongs_to :user, Elektrine.Accounts.User
+
+ timestamps(type: :utc_datetime)
+ end
+
+ def changeset(setting, attrs) do
+ setting
+ |> cast(attrs, [:user_id, :app, :settings])
+ |> update_change(:app, &normalize_app/1)
+ |> validate_required([:user_id, :app, :settings])
+ |> validate_length(:app, min: 1, max: 100)
+ |> validate_format(:app, ~r/^[A-Za-z0-9_.:-]+$/,
+ message: "may only contain letters, numbers, dots, underscores, colons, and hyphens"
+ )
+ |> validate_settings_map()
+ |> unique_constraint([:user_id, :app])
+ |> foreign_key_constraint(:user_id)
+ end
+
+ def normalize_app(app) when is_binary(app), do: String.trim(app)
+ def normalize_app(app), do: app
+
+ defp validate_settings_map(changeset) do
+ case get_field(changeset, :settings) do
+ settings when is_map(settings) -> changeset
+ _ -> add_error(changeset, :settings, "must be a map")
+ end
+ end
+end
diff --git a/apps/elektrine/lib/elektrine/accounts/client_app_settings.ex b/apps/elektrine/lib/elektrine/accounts/client_app_settings.ex
new file mode 100644
index 0000000..5c81e0b
--- /dev/null
+++ b/apps/elektrine/lib/elektrine/accounts/client_app_settings.ex
@@ -0,0 +1,65 @@
+defmodule Elektrine.Accounts.ClientAppSettings do
+ @moduledoc """
+ Per-user settings persisted by API clients.
+ """
+
+ alias Elektrine.Accounts.ClientAppSetting
+ alias Elektrine.Repo
+
+ def get_settings(user_id, app) when is_integer(user_id) and is_binary(app) do
+ app = ClientAppSetting.normalize_app(app)
+
+ case Repo.get_by(ClientAppSetting, user_id: user_id, app: app) do
+ %ClientAppSetting{settings: settings} when is_map(settings) -> settings
+ _ -> %{}
+ end
+ end
+
+ def update_settings(user_id, app, patch)
+ when is_integer(user_id) and is_binary(app) and is_map(patch) do
+ app = ClientAppSetting.normalize_app(app)
+ existing = Repo.get_by(ClientAppSetting, user_id: user_id, app: app)
+ current = settings_from(existing)
+ merged = deep_merge_delete(current, patch)
+
+ attrs = %{user_id: user_id, app: app, settings: merged}
+
+ case existing do
+ nil ->
+ %ClientAppSetting{}
+ |> ClientAppSetting.changeset(attrs)
+ |> Repo.insert()
+ |> unwrap_settings()
+
+ %ClientAppSetting{} = setting ->
+ setting
+ |> ClientAppSetting.changeset(attrs)
+ |> Repo.update()
+ |> unwrap_settings()
+ end
+ end
+
+ def update_settings(_user_id, _app, _patch), do: {:error, :invalid_settings}
+
+ defp settings_from(%ClientAppSetting{settings: settings}) when is_map(settings), do: settings
+ defp settings_from(_), do: %{}
+
+ defp unwrap_settings({:ok, %ClientAppSetting{settings: settings}}), do: {:ok, settings}
+ defp unwrap_settings(error), do: error
+
+ defp deep_merge_delete(current, patch) do
+ Enum.reduce(patch, current, fn
+ {key, nil}, acc ->
+ Map.delete(acc, key)
+
+ {key, value}, acc when is_map(value) ->
+ case Map.get(acc, key) do
+ existing when is_map(existing) -> Map.put(acc, key, deep_merge_delete(existing, value))
+ _ -> Map.put(acc, key, deep_merge_delete(%{}, value))
+ end
+
+ {key, value}, acc ->
+ Map.put(acc, key, value)
+ end)
+ end
+end
diff --git a/apps/elektrine/lib/elektrine/accounts/connected_account.ex b/apps/elektrine/lib/elektrine/accounts/connected_account.ex
new file mode 100644
index 0000000..cec396b
--- /dev/null
+++ b/apps/elektrine/lib/elektrine/accounts/connected_account.ex
@@ -0,0 +1,84 @@
+defmodule Elektrine.Accounts.ConnectedAccount do
+ @moduledoc """
+ A reusable external account connection owned by a user.
+
+ These records are created by provider OAuth/OIDC flows and can be consumed by
+ modules such as Atomine proofs, static-site deploy integrations, importers, or
+ webhook setup without each module owning a separate provider identity table.
+ """
+
+ use Ecto.Schema
+ import Ecto.Changeset
+
+ @providers ~w(github gitlab google discord x mastodon bluesky)
+
+ schema "connected_accounts" do
+ belongs_to :user, Elektrine.Accounts.User
+ field :provider, :string
+ field :provider_account_id, :string
+ field :username, :string
+ field :display_name, :string
+ field :email, :string
+ field :profile_url, :string
+ field :avatar_url, :string
+ field :scopes, {:array, :string}, default: []
+ field :metadata, :map, default: %{}
+ field :last_verified_at, :utc_datetime
+
+ timestamps(type: :utc_datetime)
+ end
+
+ def providers, do: @providers
+
+ def changeset(connected_account, attrs) do
+ connected_account
+ |> cast(attrs, [
+ :user_id,
+ :provider,
+ :provider_account_id,
+ :username,
+ :display_name,
+ :email,
+ :profile_url,
+ :avatar_url,
+ :scopes,
+ :metadata,
+ :last_verified_at
+ ])
+ |> normalize_string(:provider)
+ |> normalize_string(:provider_account_id)
+ |> normalize_optional_string(:username)
+ |> normalize_optional_string(:display_name)
+ |> normalize_optional_string(:email)
+ |> validate_required([:user_id, :provider, :provider_account_id])
+ |> validate_inclusion(:provider, @providers)
+ |> validate_length(:provider_account_id, min: 1, max: 500)
+ |> validate_length(:username, max: 500)
+ |> validate_length(:display_name, max: 500)
+ |> validate_length(:email, max: 500)
+ |> validate_length(:profile_url, max: 2_000)
+ |> validate_length(:avatar_url, max: 2_000)
+ |> foreign_key_constraint(:user_id)
+ |> unique_constraint([:provider, :provider_account_id])
+ |> unique_constraint([:user_id, :provider, :provider_account_id],
+ name: :connected_accounts_user_provider_account_unique
+ )
+ end
+
+ defp normalize_string(changeset, field) do
+ update_change(changeset, field, fn
+ value when is_binary(value) -> value |> String.trim() |> String.downcase()
+ value -> value
+ end)
+ end
+
+ defp normalize_optional_string(changeset, field) do
+ update_change(changeset, field, fn
+ value when is_binary(value) -> value |> String.trim() |> blank_to_nil()
+ value -> value
+ end)
+ end
+
+ defp blank_to_nil(""), do: nil
+ defp blank_to_nil(value), do: value
+end
diff --git a/apps/elektrine/lib/elektrine/accounts/endorsements.ex b/apps/elektrine/lib/elektrine/accounts/endorsements.ex
new file mode 100644
index 0000000..f1b74bd
--- /dev/null
+++ b/apps/elektrine/lib/elektrine/accounts/endorsements.ex
@@ -0,0 +1,93 @@
+defmodule Elektrine.Accounts.Endorsements do
+ @moduledoc """
+ User-owned endorsed account relationships.
+ """
+
+ import Ecto.Query, warn: false
+
+ alias Elektrine.Accounts.{AccountEndorsement, User}
+ alias Elektrine.ActivityPub.Actor
+ alias Elektrine.Repo
+
+ def endorse_account(user_id, %User{id: user_id}), do: {:error, :self_endorse}
+
+ def endorse_account(user_id, %User{} = account) when is_integer(user_id) do
+ attrs = %{user_id: user_id, endorsed_user_id: account.id}
+
+ case Repo.get_by(AccountEndorsement, attrs) do
+ %AccountEndorsement{} = endorsement -> {:ok, endorsement}
+ nil -> insert_endorsement(attrs)
+ end
+ end
+
+ def endorse_account(user_id, %Actor{} = actor) when is_integer(user_id) do
+ attrs = %{user_id: user_id, remote_actor_id: actor.id}
+
+ case Repo.get_by(AccountEndorsement, attrs) do
+ %AccountEndorsement{} = endorsement -> {:ok, endorsement}
+ nil -> insert_endorsement(attrs)
+ end
+ end
+
+ def endorse_account(_user_id, _account), do: {:error, :invalid_account}
+
+ def unendorse_account(user_id, %User{} = account) when is_integer(user_id) do
+ delete_endorsement(user_id, endorsed_user_id: account.id)
+ end
+
+ def unendorse_account(user_id, %Actor{} = actor) when is_integer(user_id) do
+ delete_endorsement(user_id, remote_actor_id: actor.id)
+ end
+
+ def unendorse_account(_user_id, _account), do: {:error, :invalid_account}
+
+ def account_endorsed?(user_id, %User{} = account) when is_integer(user_id) do
+ endorsement_exists?(user_id, endorsed_user_id: account.id)
+ end
+
+ def account_endorsed?(user_id, %Actor{} = actor) when is_integer(user_id) do
+ endorsement_exists?(user_id, remote_actor_id: actor.id)
+ end
+
+ def account_endorsed?(_user_id, _account), do: false
+
+ def list_endorsed_accounts(user_id) when is_integer(user_id) do
+ endorsements =
+ AccountEndorsement
+ |> where([endorsement], endorsement.user_id == ^user_id)
+ |> order_by([endorsement], desc: endorsement.inserted_at, desc: endorsement.id)
+ |> preload([:endorsed_user, :remote_actor])
+ |> Repo.all()
+
+ Enum.flat_map(endorsements, fn
+ %AccountEndorsement{endorsed_user: %User{} = user} -> [user]
+ %AccountEndorsement{remote_actor: %Actor{} = actor} -> [actor]
+ _endorsement -> []
+ end)
+ end
+
+ def list_endorsed_accounts(_user_id), do: []
+
+ defp insert_endorsement(attrs) do
+ %AccountEndorsement{}
+ |> AccountEndorsement.changeset(attrs)
+ |> Repo.insert()
+ end
+
+ defp delete_endorsement(user_id, clauses) do
+ case get_endorsement(user_id, clauses) do
+ %AccountEndorsement{} = endorsement -> Repo.delete(endorsement)
+ nil -> {:ok, :not_endorsed}
+ end
+ end
+
+ defp endorsement_exists?(user_id, clauses) do
+ not is_nil(get_endorsement(user_id, clauses))
+ end
+
+ defp get_endorsement(user_id, clauses) do
+ clauses
+ |> Keyword.put(:user_id, user_id)
+ |> then(&Repo.get_by(AccountEndorsement, &1))
+ end
+end
diff --git a/apps/elektrine/lib/elektrine/accounts/invite_code.ex b/apps/elektrine/lib/elektrine/accounts/invite_code.ex
new file mode 100644
index 0000000..ff1cf22
--- /dev/null
+++ b/apps/elektrine/lib/elektrine/accounts/invite_code.ex
@@ -0,0 +1,100 @@
+defmodule Elektrine.Accounts.InviteCode do
+ @moduledoc false
+ use Ecto.Schema
+ import Ecto.Changeset
+
+ @code_regex ~r/^[A-Z0-9]{6,}$/
+
+ schema "invite_codes" do
+ field :code, :string
+ field :max_uses, :integer, default: 1
+ field :uses_count, :integer, default: 0
+ field :expires_at, :utc_datetime
+ field :note, :string
+ field :is_active, :boolean, default: true
+
+ belongs_to :created_by, Elektrine.Accounts.User
+ has_many :uses, Elektrine.Accounts.InviteCodeUse
+
+ timestamps()
+ end
+
+ @doc false
+ def changeset(invite_code, attrs) do
+ if invite_code.id do
+ update_changeset(invite_code, attrs)
+ else
+ create_changeset(invite_code, attrs)
+ end
+ end
+
+ @doc false
+ def create_changeset(invite_code, attrs) do
+ invite_code
+ |> cast(attrs, [:code, :max_uses, :expires_at, :note, :is_active, :created_by_id])
+ |> normalize_code_field()
+ |> validate_required([:code])
+ |> validate_number(:max_uses, greater_than: 0)
+ |> unique_constraint(:code)
+ |> unique_constraint(:code, name: :invite_codes_code_upper_unique)
+ |> validate_code_format()
+ end
+
+ @doc false
+ def update_changeset(invite_code, attrs) do
+ invite_code
+ |> cast(attrs, [:max_uses, :expires_at, :note, :is_active])
+ |> validate_number(:max_uses, greater_than: 0)
+ |> validate_max_uses_not_below_current_usage()
+ |> validate_code_format()
+ end
+
+ defp validate_code_format(changeset) do
+ validate_change(changeset, :code, fn :code, code ->
+ if String.match?(code, @code_regex) do
+ []
+ else
+ [code: "must be at least 6 characters long and contain only letters and numbers"]
+ end
+ end)
+ end
+
+ defp validate_max_uses_not_below_current_usage(%Ecto.Changeset{} = changeset) do
+ max_uses = get_field(changeset, :max_uses)
+ current_uses = changeset.data.uses_count || 0
+
+ if is_integer(max_uses) and max_uses < current_uses do
+ add_error(changeset, :max_uses, "cannot be less than current uses (#{current_uses})")
+ else
+ changeset
+ end
+ end
+
+ defp normalize_code_field(changeset) do
+ update_change(changeset, :code, &normalize_code/1)
+ end
+
+ def normalize_code(code) when is_binary(code) do
+ code
+ |> String.trim()
+ |> String.upcase()
+ end
+
+ def normalize_code(_), do: nil
+
+ def generate_code do
+ :crypto.strong_rand_bytes(16)
+ |> Base.encode32(padding: false)
+ |> String.upcase()
+ end
+
+ def expired?(%__MODULE__{expires_at: nil}), do: false
+
+ def expired?(%__MODULE__{expires_at: expires_at}) do
+ DateTime.compare(expires_at, DateTime.utc_now()) == :lt
+ end
+
+ def exhausted?(%__MODULE__{uses_count: uses_count, max_uses: max_uses}) do
+ uses_count >= max_uses
+ end
+end
diff --git a/apps/elektrine/lib/elektrine/accounts/invite_code_use.ex b/apps/elektrine/lib/elektrine/accounts/invite_code_use.ex
new file mode 100644
index 0000000..5b70415
--- /dev/null
+++ b/apps/elektrine/lib/elektrine/accounts/invite_code_use.ex
@@ -0,0 +1,22 @@
+defmodule Elektrine.Accounts.InviteCodeUse do
+ @moduledoc false
+ use Ecto.Schema
+ import Ecto.Changeset
+
+ schema "invite_code_uses" do
+ belongs_to :invite_code, Elektrine.Accounts.InviteCode
+ belongs_to :user, Elektrine.Accounts.User
+ field :used_at, :utc_datetime
+ end
+
+ @doc false
+ def changeset(invite_code_use, attrs) do
+ invite_code_use
+ |> cast(attrs, [:invite_code_id, :user_id])
+ |> put_change(:used_at, DateTime.utc_now() |> DateTime.truncate(:second))
+ |> validate_required([:invite_code_id, :user_id])
+ |> foreign_key_constraint(:invite_code_id)
+ |> foreign_key_constraint(:user_id)
+ |> unique_constraint(:user_id, name: :invite_code_uses_user_id_unique)
+ end
+end
diff --git a/apps/elektrine/lib/elektrine/accounts/moderation.ex b/apps/elektrine/lib/elektrine/accounts/moderation.ex
new file mode 100644
index 0000000..47263c2
--- /dev/null
+++ b/apps/elektrine/lib/elektrine/accounts/moderation.ex
@@ -0,0 +1,521 @@
+defmodule Elektrine.Accounts.Moderation do
+ @moduledoc """
+ User moderation functionality for administrators.
+ Handles banning, suspending, and administrative operations on user accounts.
+ """
+
+ import Ecto.Query, warn: false
+ alias Elektrine.Accounts.{AccountDeletionRequest, TrustLevel, User}
+ alias Elektrine.Accounts.Authentication
+ alias Elektrine.Accounts.Cached
+ alias Elektrine.Platform.Modules
+ alias Elektrine.Repo
+
+ require Logger
+
+ ## Ban/Suspend Operations
+
+ @doc """
+ Bans a user with an optional reason.
+ Admins cannot be banned for security reasons.
+
+ ## Examples
+
+ iex> ban_user(user, %{banned_reason: "Violation of terms"})
+ {:ok, %User{}}
+
+ iex> ban_user(admin_user)
+ {:error, :cannot_ban_admin}
+
+ """
+ def ban_user(%User{} = user, attrs \\ %{}) do
+ if user.is_admin do
+ {:error, :cannot_ban_admin}
+ else
+ user
+ |> User.ban_changeset(attrs)
+ |> Repo.update()
+ |> maybe_reconcile_trust_level()
+ |> revoke_banned_user_credentials()
+ end
+ end
+
+ @doc """
+ Unbans a user.
+
+ ## Examples
+
+ iex> unban_user(user)
+ {:ok, %User{}}
+
+ """
+ def unban_user(%User{} = user) do
+ user
+ |> User.unban_changeset()
+ |> Repo.update()
+ |> maybe_reconcile_trust_level()
+ end
+
+ @doc """
+ Suspends a user until a specific date/time.
+ Admins cannot be suspended for security reasons.
+
+ ## Examples
+
+ iex> suspend_user(user, %{suspended_until: ~U[2024-12-31 23:59:59Z], suspension_reason: "Spam"})
+ {:ok, %User{}}
+
+ iex> suspend_user(admin_user, %{suspended_until: ~U[2024-12-31 23:59:59Z]})
+ {:error, :cannot_suspend_admin}
+
+ """
+ def suspend_user(%User{} = user, attrs) do
+ if user.is_admin do
+ {:error, :cannot_suspend_admin}
+ else
+ user
+ |> User.suspend_changeset(attrs)
+ |> Repo.update()
+ |> maybe_record_suspension(user)
+ end
+ end
+
+ @doc """
+ Unsuspends a user.
+
+ ## Examples
+
+ iex> unsuspend_user(user)
+ {:ok, %User{}}
+
+ """
+ def unsuspend_user(%User{} = user) do
+ user
+ |> User.unsuspend_changeset()
+ |> Repo.update()
+ |> maybe_reconcile_trust_level()
+ end
+
+ @doc """
+ Checks if a user is currently suspended.
+ """
+ def user_suspended?(%User{} = user) do
+ user.suspended &&
+ (is_nil(user.suspended_until) ||
+ DateTime.compare(user.suspended_until, DateTime.utc_now()) == :gt)
+ end
+
+ defp revoke_banned_user_credentials({:ok, %User{} = user} = result) do
+ _ = Elektrine.Accounts.revoke_all_user_sessions(user.id, "account_banned")
+ _ = Authentication.revoke_all_app_passwords(user.id)
+ _ = Elektrine.Developer.revoke_all_api_tokens(user.id)
+ _ = ElektrineWeb.Endpoint.broadcast("user_socket:#{user.id}", "disconnect", %{})
+ _ = broadcast_mail_auth_changed(user.id)
+
+ result
+ end
+
+ defp revoke_banned_user_credentials(result), do: result
+
+ defp broadcast_mail_auth_changed(user_id) do
+ Phoenix.PubSub.broadcast(
+ Elektrine.PubSub,
+ "mail_auth:user:#{user_id}",
+ {:mail_auth_changed, user_id}
+ )
+ end
+
+ @doc """
+ Automatically unsuspends users whose suspension period has expired.
+ """
+ def unsuspend_expired_users do
+ now = DateTime.utc_now()
+
+ from(u in User,
+ where: u.suspended == true,
+ where: not is_nil(u.suspended_until),
+ where: u.suspended_until <= ^now
+ )
+ |> Repo.all()
+ |> Enum.each(&unsuspend_user/1)
+ end
+
+ ## Admin User Management
+
+ @doc """
+ Updates a user's admin status.
+
+ ## Examples
+
+ iex> update_user_admin_status(user, true)
+ {:ok, %User{}}
+
+ iex> update_user_admin_status(user, false)
+ {:ok, %User{}}
+
+ """
+ def update_user_admin_status(%User{} = user, is_admin) when is_boolean(is_admin) do
+ user
+ |> Ecto.Changeset.cast(%{is_admin: is_admin}, [:is_admin])
+ |> Repo.update()
+ end
+
+ @doc """
+ Creates a user (admin only).
+
+ ## Examples
+
+ iex> admin_create_user(%{username: "newuser", password: "password123", password_confirmation: "password123"})
+ {:ok, %User{}}
+
+ iex> admin_create_user(%{username: ""})
+ {:error, %Ecto.Changeset{}}
+
+ """
+ def admin_create_user(attrs \\ %{}) do
+ result =
+ %User{}
+ |> User.admin_registration_changeset(attrs)
+ |> Repo.insert()
+
+ case result do
+ {:ok, user} ->
+ maybe_create_user_mailbox(user)
+ {:ok, user}
+
+ error ->
+ error
+ end
+ end
+
+ @doc """
+ Updates a user (admin only).
+
+ ## Examples
+
+ iex> admin_update_user(user, %{username: "new_username"})
+ {:ok, %User{}}
+
+ iex> admin_update_user(user, %{username: ""})
+ {:error, %Ecto.Changeset{}}
+
+ """
+ def admin_update_user(%User{} = user, attrs) do
+ changeset = User.admin_changeset(user, attrs)
+
+ case Repo.update(changeset) do
+ {:ok, updated_user} ->
+ # Update mailbox email if username changed
+ case Ecto.Changeset.get_change(changeset, :username) do
+ nil ->
+ :ok
+
+ _new_username ->
+ update_mailbox_email_for_username_change(updated_user)
+ # Clear lookup caches keyed by the old username
+ Cached.invalidate_user_cache(user)
+ end
+
+ Cached.invalidate_user_cache(updated_user)
+ {:ok, updated_user}
+
+ error ->
+ error
+ end
+ end
+
+ @doc """
+ Deletes a user and all associated data (admin operation).
+
+ ## Examples
+
+ iex> admin_delete_user(user)
+ {:ok, %User{}}
+
+ iex> admin_delete_user(user)
+ {:error, %Ecto.Changeset{}}
+
+ """
+ def admin_delete_user(%User{} = user) do
+ # Delete all user's data first
+ Repo.transaction(fn ->
+ maybe_delete_email_data(user)
+
+ # Finally delete the user
+ case Repo.delete(user) do
+ {:ok, user} -> user
+ {:error, changeset} -> Repo.rollback(changeset)
+ end
+ end)
+ end
+
+ @doc """
+ Returns an `%Ecto.Changeset{}` for tracking user admin changes.
+
+ ## Examples
+
+ iex> change_user_admin(user)
+ %Ecto.Changeset{data: %User{}}
+
+ """
+ def change_user_admin(%User{} = user, attrs \\ %{}) do
+ User.admin_changeset(user, attrs)
+ end
+
+ @doc """
+ Returns an `%Ecto.Changeset{}` for creating a user (admin only).
+
+ ## Examples
+
+ iex> change_user_admin_registration(%User{})
+ %Ecto.Changeset{data: %User{}}
+
+ """
+ def change_user_admin_registration(%User{} = user, attrs \\ %{}) do
+ User.admin_registration_changeset(user, attrs)
+ end
+
+ ## Account Deletion Request Management
+
+ @doc """
+ Creates an account deletion request.
+
+ ## Examples
+
+ iex> create_deletion_request(user, %{reason: "No longer needed"})
+ {:ok, %AccountDeletionRequest{}}
+
+ iex> create_deletion_request(user, %{})
+ {:error, %Ecto.Changeset{}}
+
+ """
+ def create_deletion_request(%User{} = user, attrs \\ %{}) do
+ attrs = Map.put(attrs, :user_id, user.id)
+ attrs = Map.put(attrs, :requested_at, DateTime.utc_now() |> DateTime.truncate(:second))
+
+ %AccountDeletionRequest{}
+ |> AccountDeletionRequest.changeset(attrs)
+ |> Repo.insert()
+ end
+
+ @doc """
+ Gets a user's pending deletion request.
+
+ ## Examples
+
+ iex> get_pending_deletion_request(user)
+ %AccountDeletionRequest{}
+
+ iex> get_pending_deletion_request(user)
+ nil
+
+ """
+ def get_pending_deletion_request(%User{} = user) do
+ Repo.get_by(AccountDeletionRequest, user_id: user.id, status: "pending")
+ end
+
+ @doc """
+ Lists all account deletion requests.
+
+ ## Examples
+
+ iex> list_deletion_requests()
+ [%AccountDeletionRequest{}, ...]
+
+ """
+ def list_deletion_requests do
+ from(r in AccountDeletionRequest,
+ join: u in User,
+ on: r.user_id == u.id,
+ left_join: admin in User,
+ on: r.reviewed_by_id == admin.id,
+ select: %{r | user: u, reviewed_by: admin},
+ order_by: [desc: r.requested_at]
+ )
+ |> Repo.all()
+ end
+
+ @doc """
+ Gets a single deletion request.
+
+ ## Examples
+
+ iex> get_deletion_request!(123)
+ %AccountDeletionRequest{}
+
+ iex> get_deletion_request!(456)
+ ** (Ecto.NoResultsError)
+
+ """
+ def get_deletion_request!(id) do
+ from(r in AccountDeletionRequest,
+ join: u in User,
+ on: r.user_id == u.id,
+ left_join: admin in User,
+ on: r.reviewed_by_id == admin.id,
+ where: r.id == ^id,
+ select: %{r | user: u, reviewed_by: admin}
+ )
+ |> Repo.one!()
+ end
+
+ @doc """
+ Reviews an account deletion request (approve or deny).
+
+ ## Examples
+
+ iex> review_deletion_request(request, admin, "approved", %{admin_notes: "Approved"})
+ {:ok, %AccountDeletionRequest{}}
+
+ iex> review_deletion_request(request, admin, "denied", %{admin_notes: "Invalid reason"})
+ {:ok, %AccountDeletionRequest{}}
+
+ """
+ def review_deletion_request(
+ %AccountDeletionRequest{} = request,
+ %User{} = admin,
+ status,
+ attrs \\ %{}
+ ) do
+ review_attrs = %{
+ status: status,
+ reviewed_at: DateTime.utc_now() |> DateTime.truncate(:second),
+ reviewed_by_id: admin.id,
+ admin_notes: Map.get(attrs, :admin_notes) || Map.get(attrs, "admin_notes")
+ }
+
+ case Repo.transaction(fn ->
+ updated_request =
+ case request
+ |> AccountDeletionRequest.review_changeset(review_attrs)
+ |> Repo.update() do
+ {:ok, updated_request} ->
+ updated_request
+
+ {:error, changeset} ->
+ Repo.rollback(changeset)
+ end
+
+ if status == "approved" do
+ user = Repo.get!(User, request.user_id)
+
+ case admin_delete_user(user) do
+ {:ok, _user} -> updated_request
+ {:error, error} -> Repo.rollback(error)
+ end
+ else
+ updated_request
+ end
+ end) do
+ {:ok, updated_request} ->
+ {:ok, updated_request}
+
+ {:error, %Ecto.Changeset{} = changeset} ->
+ {:error, changeset}
+
+ {:error, _error} ->
+ {:error, "Failed to delete user account"}
+ end
+ end
+
+ defp maybe_record_suspension({:ok, %User{} = updated_user}, %User{} = original_user) do
+ if !original_user.suspended && updated_user.suspended do
+ case TrustLevel.increment_stat(updated_user.id, :suspensions_count) do
+ {:ok, _stats} -> {:ok, Repo.get!(User, updated_user.id)}
+ error -> error
+ end
+ else
+ maybe_reconcile_trust_level({:ok, updated_user})
+ end
+ end
+
+ defp maybe_record_suspension(error, _original_user), do: error
+
+ defp maybe_reconcile_trust_level({:ok, %User{} = updated_user}) do
+ TrustLevel.maybe_auto_promote_user(updated_user.id)
+ end
+
+ defp maybe_reconcile_trust_level(error), do: error
+
+ # Updates the user's mailbox email address to match their new username.
+ # This prevents duplicate mailboxes when usernames change.
+ defp update_mailbox_email_for_username_change(user) do
+ if email_module_compiled?() do
+ case Elektrine.Email.get_user_mailbox(user.id) do
+ nil ->
+ :ok
+
+ mailbox ->
+ # Calculate the new email address based on current username
+ domain = Elektrine.Domains.primary_email_domain()
+ new_email = "#{user.username}@#{domain}"
+
+ # Only process if the email is different
+ if mailbox.email != new_email do
+ # Check if the new email conflicts with an existing mailbox
+ case Elektrine.Email.get_mailbox_by_email(new_email) do
+ nil ->
+ # New email is available - update the mailbox in place.
+ case Elektrine.Email.transition_mailbox_for_username_change(
+ user,
+ mailbox,
+ new_email
+ ) do
+ {:ok, _new_mailbox} ->
+ :ok
+
+ {:error, reason} ->
+ Logger.error(
+ "Failed to transition mailbox for user #{user.id}: #{inspect(reason)}"
+ )
+
+ :ok
+ end
+
+ _existing_mailbox ->
+ Logger.error(
+ "Cannot create #{new_email} for user #{user.id} - email already taken"
+ )
+
+ :ok
+ end
+ else
+ :ok
+ end
+ end
+ else
+ :ok
+ end
+ end
+
+ defp maybe_create_user_mailbox(user) do
+ if email_module_compiled?() do
+ Elektrine.Email.create_mailbox(user)
+ else
+ :ok
+ end
+ end
+
+ defp maybe_delete_email_data(user) do
+ if email_module_compiled?() do
+ from(m in Elektrine.Email.Message,
+ join: mb in Elektrine.Email.Mailbox,
+ on: m.mailbox_id == mb.id,
+ where: mb.user_id == ^user.id
+ )
+ |> Repo.delete_all()
+
+ from(mb in Elektrine.Email.Mailbox, where: mb.user_id == ^user.id)
+ |> Repo.delete_all()
+
+ from(a in Elektrine.Email.Alias, where: a.user_id == ^user.id)
+ |> Repo.delete_all()
+ else
+ :ok
+ end
+ end
+
+ defp email_module_compiled? do
+ Modules.compiled?(:email)
+ end
+end
diff --git a/apps/elektrine/lib/elektrine/accounts/mute_expire_worker.ex b/apps/elektrine/lib/elektrine/accounts/mute_expire_worker.ex
new file mode 100644
index 0000000..ccac2cf
--- /dev/null
+++ b/apps/elektrine/lib/elektrine/accounts/mute_expire_worker.ex
@@ -0,0 +1,36 @@
+defmodule Elektrine.Accounts.MuteExpireWorker do
+ @moduledoc """
+ Removes temporary user mutes when their expiration time arrives.
+ """
+
+ use Oban.Worker,
+ queue: :default,
+ max_attempts: 3,
+ unique: [period: 3600, fields: [:args], keys: [:muter_id, :muted_id]]
+
+ alias Elektrine.Accounts
+ alias Elektrine.Accounts.UserMute
+ alias Elektrine.Repo
+
+ @impl Oban.Worker
+ def perform(%Oban.Job{args: %{"muter_id" => muter_id, "muted_id" => muted_id}}) do
+ case Repo.get_by(UserMute, muter_id: muter_id, muted_id: muted_id) do
+ %UserMute{expires_at: %DateTime{} = expires_at} ->
+ if DateTime.compare(expires_at, Elektrine.Time.utc_now()) in [:lt, :eq] do
+ _ = Accounts.unmute_user(muter_id, muted_id)
+ :ok
+ else
+ :ok
+ end
+
+ _ ->
+ :ok
+ end
+ end
+
+ def enqueue(%{muter_id: muter_id, muted_id: muted_id}, %DateTime{} = scheduled_at) do
+ %{"muter_id" => muter_id, "muted_id" => muted_id}
+ |> new(scheduled_at: scheduled_at)
+ |> Elektrine.JobQueue.insert()
+ end
+end
diff --git a/apps/elektrine/lib/elektrine/accounts/muting.ex b/apps/elektrine/lib/elektrine/accounts/muting.ex
new file mode 100644
index 0000000..9dbd914
--- /dev/null
+++ b/apps/elektrine/lib/elektrine/accounts/muting.ex
@@ -0,0 +1,159 @@
+defmodule Elektrine.Accounts.Muting do
+ @moduledoc """
+ User muting functionality.
+ Handles muting, unmuting, and checking muted relationships between users.
+ """
+
+ import Ecto.Query, warn: false
+ alias Elektrine.Accounts.{User, UserMute}
+ alias Elektrine.Repo
+
+ @doc """
+ Mutes a user. If a mute already exists, updates notification muting preference.
+
+ Pass a DateTime or a positive seconds integer as `expires_at` for temporary mutes.
+ """
+ def mute_user(muter_id, muted_id, mute_notifications \\ false, expires_at \\ nil) do
+ expires_at = normalize_expires_at(expires_at)
+
+ result =
+ case Repo.get_by(UserMute, muter_id: muter_id, muted_id: muted_id) do
+ nil ->
+ %UserMute{}
+ |> UserMute.changeset(%{
+ muter_id: muter_id,
+ muted_id: muted_id,
+ mute_notifications: mute_notifications,
+ expires_at: expires_at
+ })
+ |> Repo.insert()
+
+ mute ->
+ mute
+ |> UserMute.changeset(%{
+ muter_id: muter_id,
+ muted_id: muted_id,
+ mute_notifications: mute_notifications,
+ expires_at: expires_at
+ })
+ |> Repo.update()
+ end
+
+ maybe_schedule_mute_expiration(result)
+ notify_home_feed_policy_changed(muter_id, :muted_user)
+ result
+ end
+
+ @doc """
+ Unmutes a user.
+ """
+ def unmute_user(muter_id, muted_id) do
+ result =
+ case Repo.get_by(UserMute, muter_id: muter_id, muted_id: muted_id) do
+ nil -> {:error, :not_muted}
+ mute -> Repo.delete(mute)
+ end
+
+ notify_home_feed_policy_changed(muter_id, :unmuted_user)
+ result
+ end
+
+ @doc """
+ Checks if a user is muted by another user.
+ """
+ def user_muted?(muter_id, muted_id) do
+ expire_due_mute(muter_id, muted_id)
+
+ Repo.exists?(
+ from(um in UserMute,
+ where: um.muter_id == ^muter_id and um.muted_id == ^muted_id,
+ where: is_nil(um.expires_at) or um.expires_at > ^Elektrine.Time.utc_now()
+ )
+ )
+ end
+
+ @doc """
+ Checks if notifications are muted for a muted user.
+ """
+ def user_muting_notifications?(muter_id, muted_id) do
+ expire_due_mute(muter_id, muted_id)
+
+ Repo.exists?(
+ from(um in UserMute,
+ where:
+ um.muter_id == ^muter_id and um.muted_id == ^muted_id and
+ um.mute_notifications == true,
+ where: is_nil(um.expires_at) or um.expires_at > ^Elektrine.Time.utc_now()
+ )
+ )
+ end
+
+ @doc """
+ Gets all users muted by a user.
+ """
+ def list_muted_users(muter_id) do
+ from(u in User,
+ join: um in UserMute,
+ on: um.muted_id == u.id,
+ where: um.muter_id == ^muter_id,
+ where: is_nil(um.expires_at) or um.expires_at > ^Elektrine.Time.utc_now(),
+ order_by: [desc: um.inserted_at],
+ preload: [:profile]
+ )
+ |> Repo.all()
+ end
+
+ @doc """
+ Removes all due expired mutes.
+ """
+ def expire_due_mutes(now \\ Elektrine.Time.utc_now()) do
+ {count, _} =
+ from(um in UserMute,
+ where: not is_nil(um.expires_at) and um.expires_at <= ^now
+ )
+ |> Repo.delete_all()
+
+ count
+ end
+
+ defp expire_due_mute(muter_id, muted_id) do
+ from(um in UserMute,
+ where: um.muter_id == ^muter_id and um.muted_id == ^muted_id,
+ where: not is_nil(um.expires_at) and um.expires_at <= ^Elektrine.Time.utc_now()
+ )
+ |> Repo.delete_all()
+
+ :ok
+ end
+
+ defp maybe_schedule_mute_expiration(
+ {:ok, %UserMute{expires_at: %DateTime{} = expires_at} = mute}
+ ) do
+ _ = Elektrine.Accounts.MuteExpireWorker.enqueue(mute, expires_at)
+ :ok
+ end
+
+ defp maybe_schedule_mute_expiration(_result), do: :ok
+
+ defp normalize_expires_at(%DateTime{} = datetime), do: DateTime.truncate(datetime, :second)
+
+ defp normalize_expires_at(seconds) when is_integer(seconds) and seconds > 0 do
+ DateTime.utc_now()
+ |> DateTime.add(seconds, :second)
+ |> DateTime.truncate(:second)
+ end
+
+ defp normalize_expires_at(_), do: nil
+
+ defp notify_home_feed_policy_changed(user_id, reason) when is_integer(user_id) do
+ module = Module.concat([Elektrine, Social, HomeFeedInvalidationWorker])
+
+ if Code.ensure_loaded?(module) do
+ _ = module.clear_user(user_id, reason)
+ end
+
+ :ok
+ rescue
+ _ -> :ok
+ end
+end
diff --git a/apps/elektrine/lib/elektrine/accounts/passkey_credential.ex b/apps/elektrine/lib/elektrine/accounts/passkey_credential.ex
new file mode 100644
index 0000000..46a0983
--- /dev/null
+++ b/apps/elektrine/lib/elektrine/accounts/passkey_credential.ex
@@ -0,0 +1,82 @@
+defmodule Elektrine.Accounts.PasskeyCredential do
+ @moduledoc """
+ Schema for WebAuthn/Passkey credentials.
+
+ Users can register up to 10 passkeys across multiple devices (phone, laptop, security key).
+ Passkeys provide passwordless, phishing-resistant authentication.
+ """
+ use Ecto.Schema
+ import Ecto.Changeset
+
+ @max_passkeys_per_user 10
+
+ schema "passkey_credentials" do
+ belongs_to :user, Elektrine.Accounts.User
+
+ # WebAuthn credential ID (raw bytes from authenticator)
+ field :credential_id, :binary
+ # Public key in COSE format (stored as binary via :erlang.term_to_binary)
+ field :public_key, :binary
+ # Counter for clone detection - increments with each use
+ field :sign_count, :integer, default: 0
+ # Stable random bytes for resident/discoverable credentials
+ field :user_handle, :binary
+ # User-friendly name (e.g., "MacBook", "iPhone", "YubiKey")
+ field :name, :string, default: "Passkey"
+ # Authenticator type identifier (AAGUID from attestation)
+ field :aaguid, :binary
+ # Transport hints: ["usb", "nfc", "ble", "internal", "hybrid"]
+ field :transports, {:array, :string}, default: []
+ field :last_used_at, :utc_datetime
+ # Audit information
+ field :created_from_ip, :string
+ field :created_user_agent, :string
+
+ timestamps()
+ end
+
+ @doc "Maximum number of passkeys allowed per user"
+ def max_passkeys_per_user, do: @max_passkeys_per_user
+
+ @doc "Generate a cryptographically random user handle (32 bytes)"
+ def generate_user_handle do
+ :crypto.strong_rand_bytes(32)
+ end
+
+ @doc "Changeset for creating a new passkey credential"
+ def create_changeset(credential, attrs) do
+ credential
+ |> cast(attrs, [
+ :user_id,
+ :credential_id,
+ :public_key,
+ :sign_count,
+ :user_handle,
+ :name,
+ :aaguid,
+ :transports,
+ :created_from_ip,
+ :created_user_agent
+ ])
+ |> validate_required([:user_id, :credential_id, :public_key, :user_handle])
+ |> validate_length(:name, max: 100)
+ |> unique_constraint(:credential_id)
+ |> foreign_key_constraint(:user_id)
+ end
+
+ @doc "Changeset for updating sign count after authentication"
+ def update_sign_count_changeset(credential, sign_count) do
+ credential
+ |> change()
+ |> put_change(:sign_count, sign_count)
+ |> put_change(:last_used_at, DateTime.utc_now() |> DateTime.truncate(:second))
+ end
+
+ @doc "Changeset for renaming a passkey"
+ def rename_changeset(credential, name) do
+ credential
+ |> change()
+ |> put_change(:name, name)
+ |> validate_length(:name, max: 100)
+ end
+end
diff --git a/apps/elektrine/lib/elektrine/accounts/passkeys.ex b/apps/elektrine/lib/elektrine/accounts/passkeys.ex
new file mode 100644
index 0000000..835bf3d
--- /dev/null
+++ b/apps/elektrine/lib/elektrine/accounts/passkeys.ex
@@ -0,0 +1,409 @@
+defmodule Elektrine.Accounts.Passkeys do
+ @moduledoc ~s|Context module for WebAuthn/Passkey management.\n\nProvides functions for:\n- Registering new passkeys\n- Authenticating with passkeys\n- Managing user's passkeys (list, rename, delete)\n|
+ import Ecto.Query, warn: false
+ alias Elektrine.Accounts.{PasskeyCredential, User}
+ alias Elektrine.AppCache
+ alias Elektrine.OAuth
+ alias Elektrine.Repo
+ require Logger
+ @challenge_timeout 300_000
+ @doc ~s|Generate a registration challenge for adding a new passkey.\n\nReturns {:ok, challenge_data} or {:error, reason}\n\nThe challenge_data contains all information needed by the browser's\nWebAuthn API to create a new credential.\n\nOptions:\n - :host - The request host (e.g., \"example.com\") for multi-domain support\n|
+ def generate_registration_challenge(user, opts \\ []) do
+ passkey_count = count_user_passkeys(user)
+
+ if passkey_count >= PasskeyCredential.max_passkeys_per_user() do
+ {:error, :passkey_limit_reached}
+ else
+ user_handle = get_or_generate_user_handle(user)
+ exclude_credentials = list_credential_ids_for_user(user)
+ rp_id = get_rp_id(opts[:host])
+ origin = get_origin(opts[:host])
+
+ challenge_opts = [
+ origin: origin,
+ rp_id: rp_id,
+ attestation: "none",
+ user_verification: "required",
+ timeout: div(@challenge_timeout, 1000)
+ ]
+
+ challenge = Wax.new_registration_challenge(challenge_opts)
+
+ {:ok,
+ %{
+ challenge: challenge,
+ challenge_b64: Base.url_encode64(challenge.bytes, padding: false),
+ rp_id: rp_id,
+ rp_name: "Elektrine",
+ user_id: Base.url_encode64(user_handle, padding: false),
+ user_name: user.username,
+ user_display_name: user.display_name || user.username,
+ timeout: @challenge_timeout,
+ attestation: "none",
+ authenticator_selection: %{resident_key: "preferred", user_verification: "required"},
+ exclude_credentials:
+ Enum.map(exclude_credentials, fn cred_id ->
+ %{type: "public-key", id: Base.url_encode64(cred_id, padding: false)}
+ end),
+ pub_key_cred_params: [%{type: "public-key", alg: -7}, %{type: "public-key", alg: -257}]
+ }}
+ end
+ end
+
+ @doc ~s|Complete passkey registration by verifying the attestation.\n\nThe attestation_response is expected to be a map with the following structure:\n- \"response\" => %{\n \"clientDataJSON\" => base64url encoded JSON string\n \"attestationObject\" => base64url encoded CBOR binary\n \"transports\" => optional list of transports\n }\n\nReturns {:ok, credential} or {:error, reason}\n|
+ def complete_registration(user, challenge, attestation_response, metadata \\ %{}) do
+ response = attestation_response["response"] || %{}
+
+ with {:ok, client_data_json_raw} <- decode_base64url(response["clientDataJSON"]),
+ {:ok, attestation_object_cbor} <- decode_base64url(response["attestationObject"]),
+ {:ok, {authenticator_data, _attestation_result}} <-
+ Wax.register(attestation_object_cbor, client_data_json_raw, challenge) do
+ credential_id = authenticator_data.attested_credential_data.credential_id
+ public_key = authenticator_data.attested_credential_data.credential_public_key
+ sign_count = authenticator_data.sign_count
+ aaguid = authenticator_data.attested_credential_data.aaguid
+ user_handle = get_or_generate_user_handle(user)
+ transports = response["transports"] || []
+
+ attrs = %{
+ user_id: user.id,
+ credential_id: credential_id,
+ public_key: :erlang.term_to_binary(public_key),
+ sign_count: sign_count,
+ user_handle: user_handle,
+ aaguid: aaguid,
+ transports: transports,
+ name: metadata[:name] || generate_passkey_name(user),
+ created_from_ip: metadata[:ip],
+ created_user_agent: metadata[:user_agent]
+ }
+
+ case %PasskeyCredential{} |> PasskeyCredential.create_changeset(attrs) |> Repo.insert() do
+ {:ok, credential} ->
+ Elektrine.Accounts.invalidate_auth_sessions(user)
+ OAuth.delete_user_tokens(user)
+ Elektrine.Accounts.Authentication.revoke_all_app_passwords(user.id)
+ Elektrine.Developer.revoke_all_api_tokens(user.id)
+ ElektrineWeb.Endpoint.broadcast("user_socket:#{user.id}", "disconnect", %{})
+ {:ok, credential}
+
+ error ->
+ error
+ end
+ else
+ {:error, reason} -> {:error, reason}
+ end
+ end
+
+ @doc ~s|Generate an authentication challenge for passkey login.\n\nFor discoverable credentials (passkey login without username):\n- Pass nil for user\n- Browser will prompt user to select a passkey\n\nFor non-discoverable credentials (after username entry):\n- Pass the user struct\n- Challenge will include allowed credentials for that user\n\nOptions:\n - :host - The request host (e.g., \"example.com\") for multi-domain support\n|
+ def generate_authentication_challenge(user \\ nil, opts \\ []) do
+ rp_id = get_rp_id(opts[:host])
+ origin = get_origin(opts[:host])
+
+ allowed_credentials =
+ case user do
+ nil ->
+ []
+
+ %User{} = user ->
+ user
+ |> list_user_passkeys()
+ |> Enum.reduce([], fn cred, acc ->
+ case decode_public_key(cred.public_key) do
+ {:ok, public_key} ->
+ [{cred.credential_id, public_key} | acc]
+
+ {:error, reason} ->
+ Logger.warning(
+ "Skipping invalid passkey key for credential #{inspect(cred.id)}: #{inspect(reason)}"
+ )
+
+ acc
+ end
+ end)
+ |> Enum.reverse()
+ end
+
+ challenge_opts = [
+ origin: origin,
+ rp_id: rp_id,
+ user_verification: "required",
+ timeout: div(@challenge_timeout, 1000),
+ allow_credentials: allowed_credentials
+ ]
+
+ challenge = Wax.new_authentication_challenge(challenge_opts)
+
+ allow_credentials_for_client =
+ case user do
+ nil ->
+ []
+
+ %User{} = u ->
+ u
+ |> list_user_passkeys()
+ |> Enum.map(fn cred ->
+ %{
+ type: "public-key",
+ id: Base.url_encode64(cred.credential_id, padding: false),
+ transports: cred.transports
+ }
+ end)
+ end
+
+ AppCache.put_passkey_challenge(challenge.bytes, challenge)
+
+ {:ok,
+ %{
+ challenge: challenge,
+ challenge_b64: Base.url_encode64(challenge.bytes, padding: false),
+ rp_id: rp_id,
+ timeout: @challenge_timeout,
+ user_verification: "required",
+ allow_credentials: allow_credentials_for_client
+ }}
+ end
+
+ @doc ~s|Retrieve a stored challenge from cache.\nUsed by the controller to get the full challenge struct for verification.\n|
+ def get_challenge(challenge_bytes) when is_binary(challenge_bytes) do
+ AppCache.get_passkey_challenge(challenge_bytes)
+ end
+
+ @doc ~s|Verify passkey authentication assertion.\n\nThe assertion_response is expected to be a map with:\n- \"id\" or \"rawId\" => base64url encoded credential ID\n- \"response\" => %{\n \"clientDataJSON\" => base64url encoded JSON string\n \"authenticatorData\" => base64url encoded binary\n \"signature\" => base64url encoded binary\n }\n\nReturns {:ok, user} or {:error, reason}\n|
+ def verify_authentication(challenge, assertion_response) do
+ case verify_authentication_with_credential(challenge, assertion_response) do
+ {:ok, user, _credential} -> {:ok, user}
+ {:error, reason} -> {:error, reason}
+ end
+ end
+
+ @doc ~s|Verify passkey authentication assertion and return both user and credential.\n\nReturns {:ok, user, credential} or {:error, reason}\n|
+ def verify_authentication_with_credential(challenge, assertion_response) do
+ credential_id_b64 = assertion_response["id"] || assertion_response["rawId"]
+ response = assertion_response["response"] || %{}
+
+ with {:ok, credential_id} <- decode_base64url(credential_id_b64),
+ {:ok, client_data_json_raw} <- decode_base64url(response["clientDataJSON"]),
+ {:ok, authenticator_data_bin} <- decode_base64url(response["authenticatorData"]),
+ {:ok, signature} <- decode_base64url(response["signature"]) do
+ case get_credential_by_id(credential_id) do
+ nil ->
+ {:error, :credential_not_found}
+
+ credential ->
+ case decode_public_key(credential.public_key) do
+ {:ok, public_key} ->
+ credentials = [{credential_id, public_key}]
+
+ case Wax.authenticate(
+ credential_id,
+ authenticator_data_bin,
+ signature,
+ client_data_json_raw,
+ challenge,
+ credentials
+ ) do
+ {:ok, authenticator_data} ->
+ new_sign_count = authenticator_data.sign_count
+
+ if new_sign_count > 0 and new_sign_count <= credential.sign_count do
+ Logger.warning(
+ "Passkey clone detection: credential #{inspect(credential.id)} " <>
+ "sign_count did not increase (stored: #{credential.sign_count}, received: #{new_sign_count})"
+ )
+
+ {:error, :cloned_authenticator}
+ else
+ credential
+ |> PasskeyCredential.update_sign_count_changeset(new_sign_count)
+ |> Repo.update()
+
+ user = Repo.get!(User, credential.user_id)
+
+ case Elektrine.Accounts.Authentication.ensure_user_active(user) do
+ :ok -> {:ok, user, credential}
+ {:error, reason} -> {:error, reason}
+ end
+ end
+
+ {:error, reason} ->
+ {:error, reason}
+ end
+
+ {:error, reason} ->
+ Logger.warning(
+ "Invalid passkey key for credential #{inspect(credential.id)}: #{inspect(reason)}"
+ )
+
+ {:error, :invalid_credential_key}
+ end
+ end
+ else
+ {:error, reason} -> {:error, reason}
+ end
+ end
+
+ @doc ~s|List all passkeys for a user|
+ def list_user_passkeys(%User{id: user_id}) do
+ PasskeyCredential
+ |> where([p], p.user_id == ^user_id)
+ |> order_by([p], desc: p.inserted_at)
+ |> Repo.all()
+ end
+
+ @doc ~s|Count passkeys for a user|
+ def count_user_passkeys(%User{id: user_id}) do
+ PasskeyCredential |> where([p], p.user_id == ^user_id) |> Repo.aggregate(:count, :id)
+ end
+
+ @doc ~s|Check if user has any passkeys registered|
+ def has_passkeys?(%User{} = user) do
+ count_user_passkeys(user) > 0
+ end
+
+ @doc ~s|Get a specific passkey by ID, scoped to user|
+ def get_user_passkey(%User{id: user_id}, passkey_id) do
+ PasskeyCredential |> where([p], p.id == ^passkey_id and p.user_id == ^user_id) |> Repo.one()
+ end
+
+ @doc ~s|Delete a passkey|
+ def delete_passkey(%User{} = user, passkey_id) do
+ case get_user_passkey(user, passkey_id) do
+ nil ->
+ {:error, :not_found}
+
+ credential ->
+ case Repo.delete(credential) do
+ {:ok, deleted} ->
+ Elektrine.Accounts.invalidate_auth_sessions(user)
+ OAuth.delete_user_tokens(user)
+ Elektrine.Accounts.Authentication.revoke_all_app_passwords(user.id)
+ Elektrine.Developer.revoke_all_api_tokens(user.id)
+ ElektrineWeb.Endpoint.broadcast("user_socket:#{user.id}", "disconnect", %{})
+ {:ok, deleted}
+
+ error ->
+ error
+ end
+ end
+ end
+
+ @doc ~s|Rename a passkey|
+ def rename_passkey(%User{} = user, passkey_id, new_name) do
+ case get_user_passkey(user, passkey_id) do
+ nil -> {:error, :not_found}
+ credential -> credential |> PasskeyCredential.rename_changeset(new_name) |> Repo.update()
+ end
+ end
+
+ defp get_credential_by_id(credential_id) do
+ PasskeyCredential |> where([p], p.credential_id == ^credential_id) |> Repo.one()
+ end
+
+ defp list_credential_ids_for_user(%User{id: user_id}) do
+ PasskeyCredential
+ |> where([p], p.user_id == ^user_id)
+ |> select([p], p.credential_id)
+ |> Repo.all()
+ end
+
+ defp get_or_generate_user_handle(%User{id: user_id}) do
+ existing =
+ PasskeyCredential
+ |> where([p], p.user_id == ^user_id)
+ |> select([p], p.user_handle)
+ |> limit(1)
+ |> Repo.one()
+
+ case existing do
+ nil -> PasskeyCredential.generate_user_handle()
+ handle -> handle
+ end
+ end
+
+ defp generate_passkey_name(%User{} = user) do
+ count = count_user_passkeys(user) + 1
+ "Passkey #{count}"
+ end
+
+ defp get_rp_id(nil) do
+ Application.get_env(:elektrine, :passkey_rp_id, "localhost")
+ end
+
+ defp get_rp_id(host) when is_binary(host) do
+ rp_id = extract_registrable_domain(host)
+
+ if rp_id in Elektrine.Domains.local_passkey_domains() do
+ rp_id
+ else
+ Application.get_env(:elektrine, :passkey_rp_id, "localhost")
+ end
+ end
+
+ defp get_origin(nil) do
+ Application.get_env(:elektrine, :passkey_origin, "http://localhost:4000")
+ end
+
+ defp get_origin(host) when is_binary(host) do
+ rp_id = get_rp_id(host)
+ origin_host = normalize_host(host)
+
+ cond do
+ rp_id == "localhost" ->
+ "http://localhost:4000"
+
+ valid_origin_host?(origin_host, rp_id) ->
+ "https://#{origin_host}"
+
+ true ->
+ Application.get_env(:elektrine, :passkey_origin, "http://localhost:4000")
+ end
+ end
+
+ defp valid_origin_host?(host, rp_id) do
+ host == rp_id or String.ends_with?(host, ".#{rp_id}")
+ end
+
+ defp normalize_host(host) when is_binary(host) do
+ host
+ |> String.downcase()
+ |> String.split(":", parts: 2)
+ |> List.first()
+ end
+
+ defp extract_registrable_domain(host) do
+ host
+ |> String.downcase()
+ |> String.split(".")
+ |> case do
+ [single] -> single
+ [_, _] = parts -> Enum.join(parts, ".")
+ parts when length(parts) >= 3 -> parts |> Enum.take(-2) |> Enum.join(".")
+ end
+ end
+
+ defp decode_public_key(data) when is_binary(data) do
+ {:ok, :erlang.binary_to_term(data, [:safe])}
+ rescue
+ ArgumentError -> {:error, :invalid_binary}
+ end
+
+ defp decode_public_key(_) do
+ {:error, :invalid_key}
+ end
+
+ defp decode_base64url(nil) do
+ {:error, :missing_data}
+ end
+
+ defp decode_base64url(data) when is_binary(data) do
+ case Base.url_decode64(data, padding: false) do
+ {:ok, decoded} -> {:ok, decoded}
+ :error -> {:error, :invalid_base64}
+ end
+ end
+
+ defp decode_base64url(_) do
+ {:error, :invalid_data}
+ end
+end
diff --git a/apps/elektrine/lib/elektrine/accounts/recovery_email_verification.ex b/apps/elektrine/lib/elektrine/accounts/recovery_email_verification.ex
new file mode 100644
index 0000000..39bb017
--- /dev/null
+++ b/apps/elektrine/lib/elektrine/accounts/recovery_email_verification.ex
@@ -0,0 +1,511 @@
+defmodule Elektrine.Accounts.RecoveryEmailVerification do
+ @moduledoc """
+ Handles recovery email verification.
+
+ Recovery emails must be verified before they can be used for password resets.
+ This also handles lifting email restrictions for users who hit rate limits.
+ """
+
+ import Ecto.Query
+ import Swoosh.Email, except: [from: 2]
+ alias Elektrine.Accounts.User
+ alias Elektrine.EmailAddresses
+ alias Elektrine.Repo
+ alias Elektrine.Theme
+ require Logger
+
+ @token_validity_hours 24
+
+ @doc """
+ Sends a verification email to the user's recovery email address.
+ Works for both new recovery emails and restricted accounts.
+ Returns {:ok, user} or {:error, reason}.
+ """
+ def send_verification_email(user_id, opts \\ []) do
+ # Option to force resend even if already verified
+ force_resend = Keyword.get(opts, :force, false)
+
+ case Repo.get(User, user_id) do
+ nil ->
+ {:error, :user_not_found}
+
+ user ->
+ cond do
+ is_nil(user.recovery_email) || user.recovery_email == "" ->
+ {:error, :no_recovery_email}
+
+ user.recovery_email_verified && !force_resend && !user.email_sending_restricted ->
+ {:error, :already_verified}
+
+ true ->
+ do_send_verification_email(user)
+ end
+ end
+ end
+
+ defp do_send_verification_email(user) do
+ token = generate_token()
+ previous_verification_state = verification_state(user)
+
+ case update_verification_token(user, token) do
+ {:ok, updated_user} ->
+ case send_email(updated_user, token, user.email_sending_restricted) do
+ {:ok, _result} ->
+ {:ok, updated_user}
+
+ {:error, reason} ->
+ restore_verification_state(updated_user, previous_verification_state)
+ {:error, reason}
+ end
+
+ {:error, _} = error ->
+ error
+ end
+ end
+
+ defp generate_token do
+ :crypto.strong_rand_bytes(32) |> Base.url_encode64(padding: false)
+ end
+
+ defp update_verification_token(user, token) do
+ user
+ |> Ecto.Changeset.change(%{
+ recovery_email_verification_token: User.hash_sensitive_token(token),
+ recovery_email_verification_sent_at: DateTime.utc_now() |> DateTime.truncate(:second)
+ })
+ |> Repo.update()
+ end
+
+ defp send_email(user, token, is_restricted) do
+ verify_url = base_url() <> "/verify-recovery-email?token=#{token}"
+
+ app_name = Application.get_env(:elektrine, :app_name, "Elektrine")
+
+ {subject, text_body, html_body} =
+ if is_restricted do
+ build_restriction_email(user, verify_url, app_name)
+ else
+ build_verification_email(user, verify_url, app_name)
+ end
+
+ case new()
+ |> to(user.recovery_email)
+ |> Swoosh.Email.from({"Elektrine", EmailAddresses.local("noreply")})
+ |> subject(subject)
+ |> html_body(html_body)
+ |> text_body(text_body)
+ |> header("List-Id", EmailAddresses.list_id("elektrine-account"))
+ |> Elektrine.Mailer.deliver_later() do
+ {:ok, result} ->
+ Logger.info(
+ "Queued recovery email verification to #{user.recovery_email} for user #{user.id}"
+ )
+
+ {:ok, result}
+
+ {:error, reason} ->
+ Logger.error(
+ "Failed to send recovery email verification to #{user.recovery_email}: #{inspect(reason)}"
+ )
+
+ {:error, :email_failed}
+ end
+ end
+
+ defp verification_state(%User{} = user) do
+ %{
+ recovery_email_verification_token: user.recovery_email_verification_token,
+ recovery_email_verification_sent_at: user.recovery_email_verification_sent_at
+ }
+ end
+
+ defp restore_verification_state(%User{} = user, previous_state) do
+ case user |> Ecto.Changeset.change(previous_state) |> Repo.update() do
+ {:ok, _restored_user} ->
+ :ok
+
+ {:error, restore_reason} ->
+ Logger.error("Failed to restore recovery verification state: #{inspect(restore_reason)}")
+ :error
+ end
+ end
+
+ defp base_url do
+ if Code.ensure_loaded?(ElektrineWeb.Endpoint) do
+ ElektrineWeb.Endpoint.url()
+ else
+ "https://#{Elektrine.Domains.instance_domain()}"
+ end
+ end
+
+ defp build_verification_email(user, verify_url, app_name) do
+ subject = "Verify your recovery email address"
+ palette = Theme.email_palette(user)
+
+ text_body = """
+ Hi #{user.username},
+
+ Please verify your recovery email address by clicking the link below:
+
+ #{verify_url}
+
+ This link will expire in #{@token_validity_hours} hours.
+
+ Once verified, this email can be used for password resets and account recovery.
+
+ If you did not add this recovery email, you can safely ignore this message.
+
+ - The #{app_name} Team
+ """
+
+ html_body = """
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Verify Your Recovery Email
+
+
+
+
+
+
+
+
+
+ Hi #{user.username},
+
+
+ Please verify your recovery email address by clicking the button below:
+
+
+
+
+
+
+ This link will expire in #{@token_validity_hours} hours.
+
+
+ Once verified, this email can be used for password resets and account recovery.
+
+
+ If you did not add this recovery email, you can safely ignore this message.
+
+
+
+
+
+
+
+
+
+
+ - The #{app_name} Team
+
+
+
+
+
+
+
+
+
+
+
+
+ """
+
+ {subject, text_body, html_body}
+ end
+
+ defp build_restriction_email(user, verify_url, app_name) do
+ subject = "Verify your recovery email to restore email sending"
+ palette = Theme.email_palette(user, :warning)
+
+ text_body = """
+ Hi #{user.username},
+
+ Your email sending privileges have been temporarily restricted due to rate limit violations.
+
+ To restore your ability to send emails, please verify your recovery email by clicking the link below:
+
+ #{verify_url}
+
+ This link will expire in #{@token_validity_hours} hours.
+
+ If you did not request this or believe this is an error, please contact support.
+
+ - The #{app_name} Team
+ """
+
+ html_body = """
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Restore Email Sending
+
+
+
+
+
+
+
+
+
+
+
+
+ Your email sending privileges have been temporarily restricted due to rate limit violations.
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Hi #{user.username},
+
+
+ To restore your ability to send emails, please verify your recovery email by clicking the button below:
+
+
+
+
+
+
+ This link will expire in #{@token_validity_hours} hours.
+
+
+ If you did not request this or believe this is an error, please contact support.
+
+
+
+
+
+
+
+
+
+
+ - The #{app_name} Team
+
+
+
+
+
+
+
+
+
+
+
+
+ """
+
+ {subject, text_body, html_body}
+ end
+
+ @doc """
+ Verifies the recovery email token.
+ If the user was restricted, also lifts the restriction.
+ Returns {:ok, user} or {:error, reason}.
+ """
+ def verify_token(token) when is_binary(token) and byte_size(token) > 0 do
+ case find_user_by_token(token) do
+ nil ->
+ {:error, :invalid_token}
+
+ user ->
+ if token_expired?(user) do
+ {:error, :token_expired}
+ else
+ do_verify(user)
+ end
+ end
+ end
+
+ def verify_token(_), do: {:error, :invalid_token}
+
+ @doc """
+ Finds a user by their recovery email verification token.
+ Returns the user or nil if not found.
+ """
+ def get_user_by_token(token) when is_binary(token) and byte_size(token) > 0 do
+ find_user_by_token(token)
+ end
+
+ def get_user_by_token(_), do: nil
+
+ defp find_user_by_token(token) do
+ hashed_token = User.hash_sensitive_token(token)
+
+ from(u in User, where: u.recovery_email_verification_token == ^hashed_token)
+ |> Repo.one()
+ end
+
+ defp token_expired?(user) do
+ case user.recovery_email_verification_sent_at do
+ nil ->
+ true
+
+ sent_at ->
+ expiry = DateTime.add(sent_at, @token_validity_hours * 3600, :second)
+ DateTime.compare(DateTime.utc_now(), expiry) == :gt
+ end
+ end
+
+ defp do_verify(user) do
+ # Always set recovery_email_verified to true
+ # If user was restricted, also lift the restriction
+ changes = %{
+ recovery_email_verified: true,
+ recovery_email_verification_token: nil,
+ recovery_email_verification_sent_at: nil
+ }
+
+ # Add restriction lifting if user was restricted
+ changes =
+ if user.email_sending_restricted do
+ Map.merge(changes, %{
+ email_sending_restricted: false,
+ email_rate_limit_violations: 0,
+ email_restriction_reason: nil,
+ email_restricted_at: nil
+ })
+ else
+ changes
+ end
+
+ user
+ |> Ecto.Changeset.change(changes)
+ |> Repo.update()
+ |> case do
+ {:ok, updated_user} ->
+ if user.email_sending_restricted do
+ Logger.info("User #{user.id} verified recovery email and restriction lifted")
+ else
+ Logger.info("User #{user.id} verified recovery email")
+ end
+
+ {:ok, updated_user}
+
+ error ->
+ error
+ end
+ end
+
+ @doc """
+ Checks if a user's recovery email is verified.
+ """
+ def verified?(user_id) do
+ case Repo.get(User, user_id) do
+ nil -> false
+ user -> user.recovery_email_verified == true
+ end
+ end
+
+ @doc """
+ Checks if a user needs to verify their recovery email.
+ Returns true if they have a recovery email but it's not verified.
+ """
+ def needs_verification?(user_id) do
+ case Repo.get(User, user_id) do
+ nil ->
+ false
+
+ user ->
+ has_recovery_email = !is_nil(user.recovery_email) && user.recovery_email != ""
+ has_recovery_email && !user.recovery_email_verified
+ end
+ end
+
+ @doc """
+ Sets the recovery email for a user.
+ Automatically marks it as unverified and sends verification email.
+ """
+ def set_recovery_email(user_id, email) do
+ case Repo.get(User, user_id) do
+ nil ->
+ {:error, :user_not_found}
+
+ user ->
+ # Check if email is actually changing
+ if user.recovery_email == email && user.recovery_email_verified do
+ {:ok, user}
+ else
+ result =
+ user
+ |> User.recovery_email_changeset(%{recovery_email: email})
+ |> Ecto.Changeset.change(%{
+ recovery_email_verified: false,
+ recovery_email_verification_token: nil,
+ recovery_email_verification_sent_at: nil
+ })
+ |> Repo.update()
+
+ # If successful and email is not empty, send verification
+ case result do
+ {:ok, updated_user} when email != nil and email != "" ->
+ send_verification_email(updated_user.id)
+ {:ok, updated_user}
+
+ other ->
+ other
+ end
+ end
+ end
+ end
+end
diff --git a/apps/elektrine/lib/elektrine/accounts/storage.ex b/apps/elektrine/lib/elektrine/accounts/storage.ex
new file mode 100644
index 0000000..2f34c29
--- /dev/null
+++ b/apps/elektrine/lib/elektrine/accounts/storage.ex
@@ -0,0 +1,394 @@
+defmodule Elektrine.Accounts.Storage do
+ @moduledoc """
+ Centralized storage tracking for user uploads across the entire application.
+ Tracks storage usage from emails, chat attachments, profile images, etc.
+ """
+
+ import Ecto.Query
+ alias Elektrine.Accounts.User
+ alias Elektrine.Email.Message
+ alias Elektrine.Platform.Modules
+ alias Elektrine.Repo
+
+ @doc """
+ Calculates total storage used by a user across all sources.
+ Includes: email messages, chat attachments, profile images, Kairo sources, etc.
+ """
+ def calculate_user_storage(user_id) do
+ # Email storage (mailbox messages)
+ email_storage = calculate_email_storage(user_id)
+
+ # Chat message media storage
+ chat_storage = calculate_chat_storage(user_id)
+
+ # Profile images and backgrounds
+ profile_storage = calculate_profile_storage(user_id)
+
+ # Static site files
+ static_site_storage = calculate_static_site_storage(user_id)
+
+ # Personal file library
+ files_storage = calculate_files_storage(user_id)
+
+ # Kairo ingested sources and project metadata
+ kairo_storage = calculate_kairo_storage(user_id)
+
+ total =
+ email_storage + chat_storage + profile_storage + static_site_storage + files_storage +
+ kairo_storage
+
+ total
+ end
+
+ @doc """
+ Updates the storage_used_bytes for a user.
+ """
+ def update_user_storage(user_id) do
+ total_bytes = calculate_user_storage(user_id)
+
+ from(u in User, where: u.id == ^user_id)
+ |> Repo.update_all(
+ set: [
+ storage_used_bytes: total_bytes,
+ storage_last_calculated_at: DateTime.utc_now() |> DateTime.truncate(:second)
+ ]
+ )
+
+ # Invalidate storage cache
+ Elektrine.AppCache.invalidate_storage_cache(user_id)
+
+ # Broadcast storage update to LiveViews
+ Phoenix.PubSub.broadcast!(
+ Elektrine.PubSub,
+ "user:#{user_id}",
+ {:storage_updated,
+ %{
+ storage_used_bytes: total_bytes,
+ user_id: user_id
+ }}
+ )
+
+ {:ok, total_bytes}
+ end
+
+ @doc """
+ Checks if a user would exceed their storage limit with additional bytes.
+ """
+ def would_exceed_limit?(user_id, additional_bytes) do
+ user = Repo.get(User, user_id)
+
+ if user do
+ current_storage = user.storage_used_bytes || 0
+ # 500MB default
+ limit = user.storage_limit_bytes || 524_288_000
+
+ current_storage + additional_bytes > limit
+ else
+ false
+ end
+ end
+
+ @doc """
+ Gets storage info for a user including usage percentage and formatted sizes.
+ """
+ def get_storage_info(user_id) do
+ user = Repo.get(User, user_id)
+
+ if user do
+ used = user.storage_used_bytes || 0
+ # 500MB default (matches schema + enforcement paths)
+ limit = user.storage_limit_bytes || 524_288_000
+
+ %{
+ used_bytes: used,
+ limit_bytes: limit,
+ used_formatted: format_bytes(used),
+ limit_formatted: format_bytes(limit),
+ percentage: if(limit > 0, do: used / limit, else: 0),
+ over_limit: used > limit,
+ available_bytes: max(0, limit - used)
+ }
+ else
+ nil
+ end
+ end
+
+ # Public calculation functions for storage management page
+
+ def calculate_email_storage(user_id) do
+ calculate_email_message_storage(user_id) + calculate_email_attachment_storage(user_id)
+ end
+
+ def calculate_email_message_storage(user_id) do
+ if Modules.compiled?(:email) do
+ # Get user's mailbox
+ case Elektrine.Email.get_user_mailbox(user_id) do
+ nil ->
+ 0
+
+ mailbox ->
+ # Sum stored mailbox content separately from attachment blobs so the UI can break them out.
+ Message
+ |> where([m], m.mailbox_id == ^mailbox.id)
+ |> select([m], %{
+ text_size: coalesce(fragment("length(?)", m.text_body), 0),
+ html_size: coalesce(fragment("length(?)", m.html_body), 0),
+ subject_size: coalesce(fragment("length(?)", m.subject), 0),
+ from_size: coalesce(fragment("length(?)", m.from), 0),
+ to_size: coalesce(fragment("length(?)", m.to), 0),
+ cc_size: coalesce(fragment("length(?)", m.cc), 0),
+ bcc_size: coalesce(fragment("length(?)", m.bcc), 0)
+ })
+ |> Repo.all()
+ |> Enum.reduce(0, fn row, acc ->
+ acc + row.text_size + row.html_size + row.subject_size +
+ row.from_size + row.to_size + row.cc_size + row.bcc_size
+ end)
+ end
+ else
+ 0
+ end
+ end
+
+ def calculate_email_attachment_storage(user_id) do
+ if Modules.compiled?(:email) do
+ case Elektrine.Email.get_user_mailbox(user_id) do
+ nil ->
+ 0
+
+ mailbox ->
+ Message
+ |> where([m], m.mailbox_id == ^mailbox.id)
+ |> select([m], %{attachments: m.attachments})
+ |> Repo.all()
+ |> Enum.reduce(0, fn row, acc ->
+ acc + calculate_attachments_size(row.attachments)
+ end)
+ end
+ else
+ 0
+ end
+ end
+
+ # Calculate total size of attachments from the attachments JSON
+ defp calculate_attachments_size(nil), do: 0
+
+ defp calculate_attachments_size(attachments) when is_map(attachments) do
+ attachments
+ |> Map.values()
+ |> Enum.reduce(0, fn attachment, acc ->
+ # Get size from attachment metadata (works for both S3 and DB storage)
+ size = Map.get(attachment, "size", 0)
+ acc + size_to_integer(size)
+ end)
+ end
+
+ defp calculate_attachments_size(_), do: 0
+
+ defp size_to_integer(size) when is_integer(size) and size > 0, do: size
+
+ defp size_to_integer(%Decimal{} = size) do
+ size
+ |> Decimal.round(0, :down)
+ |> Decimal.to_integer()
+ |> size_to_integer()
+ end
+
+ defp size_to_integer(size) when is_binary(size) do
+ case Integer.parse(size) do
+ {size, ""} when size > 0 -> size
+ _ -> 0
+ end
+ end
+
+ defp size_to_integer(_), do: 0
+
+ def calculate_chat_storage(user_id) do
+ # Get all messages with media from the user (exclude deleted)
+ messages =
+ Repo.all(
+ from(m in Elektrine.Social.Message,
+ where:
+ m.sender_id == ^user_id and fragment("cardinality(?) > 0", m.media_urls) and
+ is_nil(m.deleted_at),
+ select: %{media_urls: m.media_urls, media_metadata: m.media_metadata}
+ )
+ )
+
+ Enum.reduce(messages, 0, fn message, acc ->
+ metadata = message.media_metadata || %{}
+
+ message_size =
+ message.media_urls
+ |> Enum.reduce(0, fn url, url_acc ->
+ case Map.get(metadata, url) do
+ %{"size" => size} when is_integer(size) -> url_acc + size
+ %{size: size} when is_integer(size) -> url_acc + size
+ _ -> url_acc
+ end
+ end)
+
+ acc + message_size
+ end)
+ end
+
+ def calculate_profile_storage(user_id) do
+ # Get user avatar size from users table
+ user_avatar_size =
+ case Repo.one(
+ from(u in User,
+ where: u.id == ^user_id,
+ select: u.avatar_size
+ )
+ ) do
+ nil -> 0
+ size when is_integer(size) -> size
+ _ -> 0
+ end
+
+ # Get profile image sizes from user_profiles table
+ profile_sizes =
+ case Repo.one(
+ from(p in Elektrine.Profiles.UserProfile,
+ where: p.user_id == ^user_id,
+ select: %{
+ avatar: p.avatar_size,
+ banner: p.banner_size,
+ background: p.background_size
+ }
+ )
+ ) do
+ nil ->
+ 0
+
+ data ->
+ avatar_size = if is_integer(data.avatar), do: data.avatar, else: 0
+ banner_size = if is_integer(data.banner), do: data.banner, else: 0
+ background_size = if is_integer(data.background), do: data.background, else: 0
+ avatar_size + banner_size + background_size
+ end
+
+ user_avatar_size + profile_sizes
+ end
+
+ def calculate_static_site_storage(user_id) do
+ Elektrine.StaticSites.total_storage_used(user_id)
+ end
+
+ def calculate_files_storage(user_id) do
+ Elektrine.Drive.storage_used(user_id)
+ end
+
+ def calculate_kairo_storage(user_id) do
+ if Modules.compiled?(:kairo) do
+ calculate_kairo_projects_storage(user_id) + calculate_kairo_sources_storage(user_id)
+ else
+ 0
+ end
+ end
+
+ defp calculate_kairo_projects_storage(user_id) do
+ from(project in "kairo_projects",
+ where: field(project, :user_id) == ^user_id,
+ select:
+ fragment(
+ """
+ COALESCE(SUM(
+ COALESCE(octet_length(?), 0) +
+ COALESCE(octet_length(?), 0) +
+ COALESCE(octet_length(?), 0) +
+ COALESCE(octet_length(?), 0)
+ ), 0)
+ """,
+ field(project, :name),
+ field(project, :slug),
+ field(project, :description),
+ field(project, :status)
+ )
+ )
+ |> Repo.one()
+ |> size_to_integer()
+ end
+
+ defp calculate_kairo_sources_storage(user_id) do
+ calculate_kairo_source_rows_storage(user_id) + calculate_kairo_upload_blobs_storage(user_id)
+ end
+
+ defp calculate_kairo_source_rows_storage(user_id) do
+ from(source in "kairo_sources",
+ where: field(source, :user_id) == ^user_id,
+ select:
+ fragment(
+ """
+ COALESCE(SUM(
+ COALESCE(octet_length(?), 0) +
+ COALESCE(octet_length(?), 0) +
+ COALESCE(octet_length(?), 0) +
+ COALESCE(octet_length(?), 0) +
+ COALESCE(octet_length(?), 0) +
+ COALESCE(octet_length(?), 0) +
+ COALESCE(octet_length(?::text), 0) +
+ COALESCE(octet_length(?::text), 0) +
+ COALESCE(octet_length(?::text), 0) +
+ COALESCE(octet_length(?::text), 0)
+ ), 0)
+ """,
+ field(source, :source_type),
+ field(source, :title),
+ field(source, :url),
+ field(source, :content),
+ field(source, :content_format),
+ field(source, :raw_hash),
+ field(source, :tags),
+ field(source, :metadata),
+ field(source, :content_encrypted),
+ field(source, :encrypted_content)
+ )
+ )
+ |> Repo.one()
+ |> size_to_integer()
+ end
+
+ defp calculate_kairo_upload_blobs_storage(user_id) do
+ upload_key_prefix = "kairo-sources/#{user_id}/%"
+
+ from(source in "kairo_sources",
+ where: field(source, :user_id) == ^user_id,
+ where:
+ fragment(
+ "COALESCE(?->>'storage_key', ?->>'key') LIKE ?",
+ field(source, :metadata),
+ field(source, :metadata),
+ ^upload_key_prefix
+ ),
+ where:
+ fragment(
+ "COALESCE(?->>'size', '') ~ '^[0-9]{1,12}$'",
+ field(source, :metadata)
+ ),
+ group_by:
+ fragment(
+ "COALESCE(?->>'storage_key', ?->>'key')",
+ field(source, :metadata),
+ field(source, :metadata)
+ ),
+ select: fragment("MAX((?->>'size')::bigint)", field(source, :metadata))
+ )
+ |> Repo.all()
+ |> Enum.reduce(0, &(size_to_integer(&1) + &2))
+ end
+
+ @doc """
+ Formats bytes in human readable format (public for mix tasks).
+ """
+ def format_bytes(bytes) when is_integer(bytes) do
+ cond do
+ bytes >= 1024 * 1024 * 1024 -> "#{Float.round(bytes / (1024 * 1024 * 1024), 2)} GB"
+ bytes >= 1024 * 1024 -> "#{Float.round(bytes / (1024 * 1024), 2)} MB"
+ bytes >= 1024 -> "#{Float.round(bytes / 1024, 2)} KB"
+ true -> "#{bytes} B"
+ end
+ end
+
+ def format_bytes(_), do: "0 B"
+end
diff --git a/apps/elektrine/lib/elektrine/accounts/subscriptions.ex b/apps/elektrine/lib/elektrine/accounts/subscriptions.ex
new file mode 100644
index 0000000..b003474
--- /dev/null
+++ b/apps/elektrine/lib/elektrine/accounts/subscriptions.ex
@@ -0,0 +1,182 @@
+defmodule Elektrine.Accounts.Subscriptions do
+ @moduledoc """
+ Per-account notification subscriptions.
+ """
+
+ import Ecto.Query
+
+ alias Elektrine.Accounts.{AccountSubscription, User}
+ alias Elektrine.ActivityPub.Actor
+ alias Elektrine.Notifications
+ alias Elektrine.Repo
+ alias Elektrine.Social.Message
+
+ def subscribe_to_account(user_id, %User{id: user_id}), do: {:error, :self_subscribe}
+
+ def subscribe_to_account(user_id, %User{} = account) when is_integer(user_id) do
+ attrs = %{user_id: user_id, subscribed_user_id: account.id}
+
+ case Repo.get_by(AccountSubscription, attrs) do
+ %AccountSubscription{} = subscription -> {:ok, subscription}
+ nil -> insert_subscription(attrs)
+ end
+ end
+
+ def subscribe_to_account(user_id, %Actor{} = actor) when is_integer(user_id) do
+ attrs = %{user_id: user_id, remote_actor_id: actor.id}
+
+ case Repo.get_by(AccountSubscription, attrs) do
+ %AccountSubscription{} = subscription -> {:ok, subscription}
+ nil -> insert_subscription(attrs)
+ end
+ end
+
+ def subscribe_to_account(_user_id, _account), do: {:error, :invalid_account}
+
+ def unsubscribe_from_account(user_id, %User{} = account) when is_integer(user_id) do
+ delete_subscription(user_id, subscribed_user_id: account.id)
+ end
+
+ def unsubscribe_from_account(user_id, %Actor{} = actor) when is_integer(user_id) do
+ delete_subscription(user_id, remote_actor_id: actor.id)
+ end
+
+ def unsubscribe_from_account(_user_id, _account), do: {:error, :invalid_account}
+
+ def account_subscribed?(user_id, %User{} = account) when is_integer(user_id) do
+ subscription_exists?(user_id, subscribed_user_id: account.id)
+ end
+
+ def account_subscribed?(user_id, %Actor{} = actor) when is_integer(user_id) do
+ subscription_exists?(user_id, remote_actor_id: actor.id)
+ end
+
+ def account_subscribed?(_user_id, _account), do: false
+
+ def notify_subscribers_for_message(%Message{} = message) do
+ message = Repo.preload(message, [:sender, :remote_actor, :conversation])
+
+ if notifiable_message?(message) do
+ message
+ |> subscribed_user_ids()
+ |> Enum.each(&create_status_notification(&1, message))
+ end
+
+ :ok
+ end
+
+ def notify_subscribers_for_message(_message), do: :ok
+
+ defp insert_subscription(attrs) do
+ %AccountSubscription{}
+ |> AccountSubscription.changeset(attrs)
+ |> Repo.insert()
+ end
+
+ defp delete_subscription(user_id, clauses) do
+ case get_subscription(user_id, clauses) do
+ %AccountSubscription{} = subscription -> Repo.delete(subscription)
+ nil -> {:ok, :not_subscribed}
+ end
+ end
+
+ defp subscription_exists?(user_id, clauses) do
+ not is_nil(get_subscription(user_id, clauses))
+ end
+
+ defp get_subscription(user_id, clauses) do
+ clauses
+ |> Keyword.put(:user_id, user_id)
+ |> then(&Repo.get_by(AccountSubscription, &1))
+ end
+
+ defp notifiable_message?(
+ %Message{deleted_at: nil, is_draft: draft, conversation: conversation} =
+ message
+ ) do
+ draft != true and social_message?(message, conversation)
+ end
+
+ defp notifiable_message?(_message), do: false
+
+ defp social_message?(%Message{federated: true}, _conversation), do: true
+ defp social_message?(_message, %{type: type}) when type in ["timeline", "community"], do: true
+ defp social_message?(_message, _conversation), do: false
+
+ defp subscribed_user_ids(%Message{sender_id: sender_id}) when is_integer(sender_id) do
+ AccountSubscription
+ |> where([s], s.subscribed_user_id == ^sender_id)
+ |> where([s], s.user_id != ^sender_id)
+ |> select([s], s.user_id)
+ |> Repo.all()
+ end
+
+ defp subscribed_user_ids(%Message{remote_actor_id: remote_actor_id})
+ when is_integer(remote_actor_id) do
+ AccountSubscription
+ |> where([s], s.remote_actor_id == ^remote_actor_id)
+ |> select([s], s.user_id)
+ |> Repo.all()
+ end
+
+ defp subscribed_user_ids(_message), do: []
+
+ defp create_status_notification(user_id, %Message{} = message) when is_integer(user_id) do
+ actor = message.sender || message.remote_actor
+
+ attrs =
+ %{
+ user_id: user_id,
+ type: "status",
+ title: status_notification_title(actor),
+ body: status_notification_body(message),
+ url: Elektrine.Paths.post_path(message),
+ source_type: "message",
+ source_id: message.id,
+ priority: "normal"
+ }
+ |> maybe_put_actor_id(message)
+ |> maybe_put_remote_actor_id(message)
+
+ Notifications.create_notification(attrs)
+ end
+
+ defp maybe_put_actor_id(attrs, %Message{sender_id: sender_id}) when is_integer(sender_id) do
+ Map.put(attrs, :actor_id, sender_id)
+ end
+
+ defp maybe_put_actor_id(attrs, _message), do: attrs
+
+ defp maybe_put_remote_actor_id(attrs, %Message{remote_actor_id: remote_actor_id})
+ when is_integer(remote_actor_id) do
+ Map.put(attrs, :metadata, %{remote_actor_id: remote_actor_id})
+ end
+
+ defp maybe_put_remote_actor_id(attrs, _message), do: attrs
+
+ defp status_notification_title(%User{} = user), do: "#{display_name(user)} posted"
+ defp status_notification_title(%Actor{} = actor), do: "#{display_name(actor)} posted"
+ defp status_notification_title(_actor), do: "New post"
+
+ defp display_name(%User{} = user), do: user.display_name || user.handle || user.username
+
+ defp display_name(%Actor{} = actor),
+ do: actor.display_name || actor.username || actor.domain || "Remote user"
+
+ defp status_notification_body(%Message{content_warning: warning})
+ when is_binary(warning) and warning != "" do
+ warning
+ end
+
+ defp status_notification_body(%Message{title: title}) when is_binary(title) and title != "" do
+ title
+ end
+
+ defp status_notification_body(%Message{content: content}) when is_binary(content) do
+ content
+ |> String.replace(~r/<[^>]*>/, "")
+ |> String.slice(0, 140)
+ end
+
+ defp status_notification_body(_message), do: "Open Elektrine to view the post."
+end
diff --git a/apps/elektrine/lib/elektrine/accounts/tracking.ex b/apps/elektrine/lib/elektrine/accounts/tracking.ex
new file mode 100644
index 0000000..d310a32
--- /dev/null
+++ b/apps/elektrine/lib/elektrine/accounts/tracking.ex
@@ -0,0 +1,192 @@
+defmodule Elektrine.Accounts.Tracking do
+ @moduledoc """
+ User activity tracking functionality.
+ Handles tracking of user logins, last seen timestamps, and email protocol access (IMAP/POP3).
+ """
+
+ use GenServer
+
+ import Ecto.Query, warn: false
+ alias Elektrine.Accounts.User
+ alias Elektrine.DB.WriteGuard
+ alias Elektrine.Repo
+
+ @last_seen_db_interval_seconds 5 * 60
+ @last_seen_local_throttle_ms @last_seen_db_interval_seconds * 1000
+ @last_seen_table :last_seen_update_throttle
+
+ def start_link(opts \\ []) do
+ GenServer.start_link(__MODULE__, opts, name: __MODULE__)
+ end
+
+ @impl true
+ def init(_opts) do
+ ensure_last_seen_table()
+ {:ok, %{}}
+ end
+
+ @doc """
+ Update user login information (IP address, login time, login count).
+ """
+ def update_user_login_info(user, ip_address) do
+ login_count = (user.login_count || 0) + 1
+
+ user
+ |> User.login_changeset(%{
+ last_login_ip: ip_address,
+ last_login_at: DateTime.utc_now() |> DateTime.truncate(:second),
+ login_count: login_count
+ })
+ |> Repo.update()
+ |> case do
+ {:ok, updated_user} ->
+ Elektrine.Accounts.TrustLevel.maybe_auto_promote_user(updated_user.id)
+ {:ok, updated_user}
+
+ error ->
+ error
+ end
+ end
+
+ @doc """
+ Record IMAP access for a user.
+ """
+ def record_imap_access(user_id) do
+ record_protocol_access(user_id, :last_imap_access, :imap)
+ end
+
+ @doc """
+ Record POP3 access for a user.
+ """
+ def record_pop3_access(user_id) do
+ record_protocol_access(user_id, :last_pop3_access, :pop3)
+ end
+
+ @doc """
+ Update user's last_seen_at timestamp.
+ Only updates if the last write attempt was more than 5 minutes ago to reduce database load.
+ """
+ def update_last_seen(user_id) do
+ now_ms = System.monotonic_time(:millisecond)
+
+ if allow_last_seen_update?(user_id, now_ms) do
+ now = DateTime.utc_now() |> DateTime.truncate(:second)
+ cutoff = DateTime.add(now, -@last_seen_db_interval_seconds, :second)
+
+ from(u in User,
+ where: u.id == ^user_id and (is_nil(u.last_seen_at) or u.last_seen_at < ^cutoff)
+ )
+ |> Repo.update_all(set: [last_seen_at: now])
+ else
+ {0, nil}
+ end
+ end
+
+ @doc """
+ Update user's last_seen_at timestamp asynchronously.
+ Delegates to update_last_seen/1 in a background task.
+ """
+ def update_last_seen_async(user_id) do
+ update_last_seen(user_id)
+ :ok
+ end
+
+ @doc """
+ Updates user status (online, away, dnd, offline).
+ """
+ def update_user_status(%User{} = user, status, message \\ nil)
+ when status in ["online", "away", "dnd", "offline"] do
+ # Sanitize status message - trim and limit length
+ sanitized_message =
+ if message && is_binary(message) do
+ message
+ |> String.trim()
+ # Max 100 characters
+ |> String.slice(0, 100)
+ |> case do
+ "" -> nil
+ msg -> msg
+ end
+ else
+ nil
+ end
+
+ attrs = %{
+ status: status,
+ status_message: sanitized_message,
+ status_updated_at: DateTime.utc_now()
+ }
+
+ user
+ |> Ecto.Changeset.cast(attrs, [:status, :status_message, :status_updated_at])
+ |> Ecto.Changeset.validate_required([:status])
+ |> Ecto.Changeset.validate_inclusion(:status, ["online", "away", "dnd", "offline"])
+ |> Ecto.Changeset.validate_length(:status_message, max: 100)
+ |> Repo.update()
+ end
+
+ @doc """
+ Gets user status information.
+ """
+ def get_user_status(user_id) do
+ case Repo.get(User, user_id) do
+ nil ->
+ {:error, :not_found}
+
+ user ->
+ {:ok,
+ %{status: user.status, message: user.status_message, updated_at: user.status_updated_at}}
+ end
+ end
+
+ defp record_protocol_access(user_id, field, protocol) do
+ now = DateTime.utc_now() |> DateTime.truncate(:second)
+
+ WriteGuard.run("#{protocol} access timestamp update for user_id=#{user_id}", fn ->
+ from(u in User, where: u.id == ^user_id)
+ |> Repo.update_all(set: [{field, now}])
+ end)
+ end
+
+ defp allow_last_seen_update?(user_id, now_ms) when is_integer(user_id) do
+ ensure_last_seen_table()
+
+ try do
+ case :ets.lookup(@last_seen_table, user_id) do
+ [{^user_id, last_ms}] when now_ms - last_ms < @last_seen_local_throttle_ms ->
+ false
+
+ _ ->
+ :ets.insert(@last_seen_table, {user_id, now_ms})
+ true
+ end
+ rescue
+ ArgumentError ->
+ # If the supervised owner is still starting, recreate and retry once.
+ ensure_last_seen_table()
+
+ case :ets.lookup(@last_seen_table, user_id) do
+ [{^user_id, last_ms}] when now_ms - last_ms < @last_seen_local_throttle_ms ->
+ false
+
+ _ ->
+ :ets.insert(@last_seen_table, {user_id, now_ms})
+ true
+ end
+ end
+ end
+
+ defp ensure_last_seen_table do
+ case :ets.whereis(@last_seen_table) do
+ :undefined ->
+ try do
+ :ets.new(@last_seen_table, [:named_table, :public, :set, {:write_concurrency, true}])
+ rescue
+ ArgumentError -> @last_seen_table
+ end
+
+ _ ->
+ @last_seen_table
+ end
+ end
+end
diff --git a/apps/elektrine/lib/elektrine/accounts/trust_level.ex b/apps/elektrine/lib/elektrine/accounts/trust_level.ex
new file mode 100644
index 0000000..a9ebcbd
--- /dev/null
+++ b/apps/elektrine/lib/elektrine/accounts/trust_level.ex
@@ -0,0 +1,405 @@
+defmodule Elektrine.Accounts.TrustLevel do
+ @moduledoc """
+ Trust level system similar to Discourse.
+
+ Trust Levels:
+ - TL0 (New): Default for all new users
+ - TL1 (Basic): Established account with repeat logins and visits
+ - TL2 (Member): Active participant
+ - TL3 (Regular): Trusted community member with sustained engagement
+ - TL4 (Leader): Manually granted, full moderation access
+ """
+
+ import Ecto.Query
+ alias Elektrine.Accounts.{TrustLevelLog, User, UserActivityStats}
+ alias Elektrine.Repo
+ require Logger
+
+ @levels %{
+ 0 => %{name: "New", color: "gray", description: "Welcome! Start exploring."},
+ 1 => %{name: "Basic", color: "blue", description: "You've learned the basics."},
+ 2 => %{name: "Member", color: "green", description: "Active community member."},
+ 3 => %{
+ name: "Regular",
+ color: "purple",
+ description: "Trusted regular with moderation powers."
+ },
+ 4 => %{name: "Leader", color: "red", description: "Community leader."}
+ }
+
+ # Trust levels only depend on signals that are currently tracked in production.
+ @requirements %{
+ 1 => %{
+ minimums: %{
+ account_age_days: 1,
+ login_count: 2,
+ days_visited: 2,
+ topics_entered: 5,
+ posts_read: 30,
+ time_read_seconds: 600
+ },
+ maximums: %{
+ banned: 0,
+ suspended: 0
+ }
+ },
+ 2 => %{
+ minimums: %{
+ account_age_days: 14,
+ login_count: 5,
+ days_visited: 15,
+ topics_entered: 15,
+ posts_read: 100,
+ topics_created: 1,
+ posts_created: 3,
+ replies_created: 3,
+ time_read_seconds: 3600,
+ likes_given: 3,
+ likes_received: 1,
+ replies_received: 1
+ },
+ maximums: %{
+ banned: 0,
+ suspended: 0,
+ posts_deleted: 0,
+ suspensions_count: 0
+ }
+ },
+ 3 => %{
+ minimums: %{
+ account_age_days: 50,
+ login_count: 10,
+ days_visited: 50,
+ topics_entered: 25,
+ posts_read: 250,
+ posts_created: 10,
+ topics_created: 2,
+ replies_created: 10,
+ likes_given: 15,
+ likes_received: 10,
+ replies_received: 5
+ },
+ maximums: %{
+ banned: 0,
+ suspended: 0,
+ flags_received: 3,
+ posts_deleted: 1,
+ suspensions_count: 0
+ }
+ }
+ # TL4 is manual only
+ }
+
+ @incrementable_stats [
+ :posts_created,
+ :topics_created,
+ :replies_created,
+ :likes_given,
+ :likes_received,
+ :replies_received,
+ :posts_read,
+ :topics_entered,
+ :time_read_seconds,
+ :flags_given,
+ :flags_received,
+ :flags_agreed,
+ :posts_deleted,
+ :suspensions_count
+ ]
+
+ def levels, do: @levels
+ def requirements, do: @requirements
+
+ @doc """
+ Get trust level name and info.
+ """
+ def get_level_info(level) when level in 0..4 do
+ @levels[level]
+ end
+
+ def get_level_info(_), do: @levels[0]
+
+ @doc """
+ Check if user qualifies for a specific trust level.
+ """
+ def qualifies_for_level?(user, stats, target_level) when target_level in 1..3 do
+ %{minimums: minimums, maximums: maximums} = @requirements[target_level]
+
+ Enum.all?(minimums, fn {metric, required_value} ->
+ metric_value(user, stats, metric) >= required_value
+ end) and
+ Enum.all?(maximums, fn {metric, allowed_value} ->
+ metric_value(user, stats, metric) <= allowed_value
+ end)
+ end
+
+ # TL0 is default
+ def qualifies_for_level?(_, _, 0), do: true
+
+ # TL4 is manual only
+ def qualifies_for_level?(_, _, 4), do: false
+
+ @doc """
+ Calculate the maximum trust level a user qualifies for.
+ """
+ def calculate_trust_level(user, stats) do
+ if user.trust_level_locked do
+ user.trust_level
+ else
+ cond do
+ qualifies_for_level?(user, stats, 3) -> 3
+ qualifies_for_level?(user, stats, 2) -> 2
+ qualifies_for_level?(user, stats, 1) -> 1
+ true -> 0
+ end
+ end
+ end
+
+ @doc """
+ Change a user's trust level and persist an audit log.
+ """
+ def change_user_level(user, new_level, opts \\ []) when new_level in 0..4 do
+ persist_level_change(user, user.trust_level, new_level, opts, persist_user?: true)
+ end
+
+ @doc """
+ Record a trust-level change after the caller has already updated `user.trust_level`.
+ """
+ def record_level_change(user, old_level, opts \\ []) when old_level in 0..4 do
+ persist_level_change(user, old_level, user.trust_level, opts, persist_user?: false)
+ end
+
+ @doc """
+ Automatically reconcile user trust levels based on their activity stats.
+ Returns {:ok, changed_count} or {:error, reason}.
+ """
+ def auto_promote_eligible_users do
+ users_with_stats =
+ from(u in User,
+ where: u.trust_level_locked == false,
+ left_join: s in UserActivityStats,
+ on: s.user_id == u.id,
+ preload: [activity_stats: s]
+ )
+ |> Repo.all()
+
+ changed_count =
+ users_with_stats
+ |> Enum.reduce(0, fn user, count ->
+ stats = user.activity_stats || %UserActivityStats{}
+ new_level = calculate_trust_level(user, stats)
+
+ if new_level != user.trust_level do
+ case change_user_level(user, new_level, reason: "automatic") do
+ {:ok, _} -> count + 1
+ {:error, _} -> count
+ end
+ else
+ count
+ end
+ end)
+
+ Logger.info("Auto-reconciled trust levels for #{changed_count} users")
+ {:ok, changed_count}
+ end
+
+ @doc """
+ Recalculate a single user's trust level and apply any automatic change immediately.
+ """
+ def maybe_auto_promote_user(user_id) do
+ case get_user_with_stats(user_id) do
+ nil ->
+ {:error, :not_found}
+
+ user ->
+ stats = user.activity_stats || %UserActivityStats{}
+ new_level = calculate_trust_level(user, stats)
+
+ if new_level != user.trust_level do
+ change_user_level(user, new_level, reason: "automatic")
+ else
+ {:ok, user}
+ end
+ end
+ end
+
+ @doc """
+ Initialize activity stats for a user if they don't exist.
+ """
+ def ensure_activity_stats(user_id) do
+ case Repo.get_by(UserActivityStats, user_id: user_id) do
+ nil ->
+ %UserActivityStats{user_id: user_id}
+ |> UserActivityStats.changeset(%{})
+ |> Repo.insert(
+ on_conflict: :nothing,
+ conflict_target: :user_id
+ )
+ |> case do
+ {:ok, %{id: nil}} ->
+ {:ok, Repo.get_by!(UserActivityStats, user_id: user_id)}
+
+ result ->
+ result
+ end
+
+ stats ->
+ {:ok, stats}
+ end
+ end
+
+ @doc """
+ Track a visit (daily).
+ """
+ def track_visit(user_id) do
+ {:ok, _stats} = ensure_activity_stats(user_id)
+ today = Date.utc_today()
+ now = DateTime.utc_now() |> DateTime.truncate(:second)
+
+ {updated_count, _} =
+ from(s in UserActivityStats,
+ where:
+ s.user_id == ^user_id and (is_nil(s.last_visit_date) or s.last_visit_date != ^today)
+ )
+ |> Repo.update_all(
+ inc: [days_visited: 1],
+ set: [last_visit_date: today, updated_at: now]
+ )
+
+ stats = Repo.get_by!(UserActivityStats, user_id: user_id)
+
+ if updated_count > 0 do
+ maybe_auto_promote_user(user_id)
+ end
+
+ {:ok, stats}
+ end
+
+ @doc """
+ Increment a stat for a user.
+ """
+ def increment_stat(user_id, stat_name, amount \\ 1)
+
+ def increment_stat(user_id, stat_name, amount)
+ when stat_name in @incrementable_stats and is_integer(amount) do
+ {:ok, _stats} = ensure_activity_stats(user_id)
+ now = DateTime.utc_now() |> DateTime.truncate(:second)
+
+ from(s in UserActivityStats, where: s.user_id == ^user_id)
+ |> Repo.update_all(inc: [{stat_name, amount}], set: [updated_at: now])
+
+ stats = Repo.get_by!(UserActivityStats, user_id: user_id)
+ maybe_auto_promote_user(user_id)
+ {:ok, stats}
+ end
+
+ def increment_stat(_user_id, stat_name, _amount), do: {:error, {:unknown_stat, stat_name}}
+
+ defp persist_level_change(user, old_level, new_level, opts, persist_opts) do
+ if new_level != old_level do
+ now = DateTime.utc_now() |> DateTime.truncate(:second)
+ reason = Keyword.get(opts, :reason, "automatic")
+ changed_by_user_id = Keyword.get(opts, :changed_by_user_id)
+ notes = Keyword.get(opts, :notes)
+ persist_user? = Keyword.get(persist_opts, :persist_user?, true)
+
+ user_changes =
+ if persist_user? do
+ %{trust_level: new_level, promoted_at: now}
+ else
+ %{promoted_at: now}
+ end
+
+ user_changeset = User.trust_level_changeset(user, user_changes)
+
+ user_changeset =
+ if new_level > old_level do
+ Ecto.Changeset.change(user_changeset, %{
+ email_sending_restricted: false,
+ email_rate_limit_violations: 0,
+ email_restriction_reason: nil,
+ email_restricted_at: nil
+ })
+ else
+ user_changeset
+ end
+
+ log_changeset =
+ TrustLevelLog.changeset(%TrustLevelLog{}, %{
+ user_id: user.id,
+ old_level: old_level,
+ new_level: new_level,
+ reason: reason,
+ changed_by_user_id: changed_by_user_id,
+ notes: notes
+ })
+
+ Ecto.Multi.new()
+ |> Ecto.Multi.update(:user, user_changeset)
+ |> Ecto.Multi.insert(:log, log_changeset)
+ |> Repo.transaction()
+ |> case do
+ {:ok, %{user: updated_user}} ->
+ Logger.info(
+ "User #{user.id} trust level changed from TL#{old_level} to TL#{new_level} (#{reason})"
+ )
+
+ if new_level > old_level do
+ notify_promotion(user.id, old_level, new_level, reason)
+ end
+
+ {:ok, updated_user}
+
+ {:error, _failed_operation, changeset, _changes} ->
+ {:error, changeset}
+ end
+ else
+ {:ok, user}
+ end
+ end
+
+ defp notify_promotion(user_id, old_level, new_level, reason) do
+ Elektrine.Async.start(fn ->
+ level_info = get_level_info(new_level)
+
+ Elektrine.Notifications.create_notification(%{
+ user_id: user_id,
+ type: "trust_level_promoted",
+ title: "Promoted to #{level_info.name}!",
+ body:
+ "Congratulations! You've been promoted to Trust Level #{new_level}: #{level_info.description}",
+ metadata: %{
+ "old_level" => old_level,
+ "new_level" => new_level,
+ "reason" => reason
+ },
+ priority: "high"
+ })
+ end)
+ end
+
+ defp get_user_with_stats(user_id) do
+ from(u in User,
+ where: u.id == ^user_id,
+ left_join: s in UserActivityStats,
+ on: s.user_id == u.id,
+ preload: [activity_stats: s]
+ )
+ |> Repo.one()
+ end
+
+ defp metric_value(user, _stats, :account_age_days) do
+ case user.inserted_at do
+ %DateTime{} = inserted_at -> Date.diff(Date.utc_today(), DateTime.to_date(inserted_at))
+ _ -> 0
+ end
+ end
+
+ defp metric_value(user, _stats, :login_count), do: user.login_count || 0
+ defp metric_value(user, _stats, :banned), do: if(user.banned, do: 1, else: 0)
+ defp metric_value(user, _stats, :suspended), do: if(user.suspended, do: 1, else: 0)
+
+ defp metric_value(_user, stats, metric) do
+ Map.get(stats, metric, 0) || 0
+ end
+end
diff --git a/apps/elektrine/lib/elektrine/accounts/trust_level_log.ex b/apps/elektrine/lib/elektrine/accounts/trust_level_log.ex
new file mode 100644
index 0000000..f0c449d
--- /dev/null
+++ b/apps/elektrine/lib/elektrine/accounts/trust_level_log.ex
@@ -0,0 +1,28 @@
+defmodule Elektrine.Accounts.TrustLevelLog do
+ @moduledoc """
+ Logs trust level changes for audit trail.
+ """
+ use Ecto.Schema
+ import Ecto.Changeset
+
+ schema "trust_level_logs" do
+ belongs_to :user, Elektrine.Accounts.User
+ field :old_level, :integer
+ field :new_level, :integer
+ # "automatic", "manual", "penalty"
+ field :reason, :string
+ belongs_to :changed_by_user, Elektrine.Accounts.User
+ field :notes, :string
+
+ timestamps(type: :utc_datetime)
+ end
+
+ def changeset(log, attrs) do
+ log
+ |> cast(attrs, [:user_id, :old_level, :new_level, :reason, :changed_by_user_id, :notes])
+ |> validate_required([:user_id, :old_level, :new_level, :reason])
+ |> validate_inclusion(:reason, ["automatic", "manual", "penalty"])
+ |> validate_number(:old_level, greater_than_or_equal_to: 0, less_than_or_equal_to: 4)
+ |> validate_number(:new_level, greater_than_or_equal_to: 0, less_than_or_equal_to: 4)
+ end
+end
diff --git a/apps/elektrine/lib/elektrine/accounts/trusted_device.ex b/apps/elektrine/lib/elektrine/accounts/trusted_device.ex
new file mode 100644
index 0000000..3f9e53f
--- /dev/null
+++ b/apps/elektrine/lib/elektrine/accounts/trusted_device.ex
@@ -0,0 +1,82 @@
+defmodule Elektrine.Accounts.TrustedDevice do
+ @moduledoc """
+ Schema for trusted devices that can skip 2FA verification.
+ Devices are trusted for 30 days by default.
+ """
+ use Ecto.Schema
+ import Ecto.Changeset
+
+ schema "trusted_devices" do
+ field :device_token, :string
+ field :device_name, :string
+ field :user_agent, :string
+ field :ip_address, :string
+ field :last_used_at, :utc_datetime
+ field :expires_at, :utc_datetime
+
+ belongs_to :user, Elektrine.Accounts.User
+
+ timestamps(type: :utc_datetime)
+ end
+
+ @doc false
+ def changeset(trusted_device, attrs) do
+ trusted_device
+ |> cast(attrs, [
+ :user_id,
+ :device_token,
+ :device_name,
+ :user_agent,
+ :ip_address,
+ :last_used_at,
+ :expires_at
+ ])
+ |> validate_required([:user_id, :device_token])
+ |> unique_constraint(:device_token)
+ |> foreign_key_constraint(:user_id)
+ end
+
+ @doc """
+ Generates a new trusted device with a secure random token.
+ Devices are trusted for 30 days by default.
+ """
+ def new_trusted_device(user_id, attrs \\ %{}) do
+ raw_device_token = generate_device_token()
+ expires_at = DateTime.utc_now() |> DateTime.add(30, :day)
+
+ default_attrs = %{
+ user_id: user_id,
+ device_token: hash_device_token(raw_device_token),
+ last_used_at: DateTime.utc_now(),
+ expires_at: expires_at
+ }
+
+ attrs = Map.merge(default_attrs, attrs)
+
+ changeset =
+ %__MODULE__{}
+ |> changeset(attrs)
+
+ {changeset, raw_device_token}
+ end
+
+ @doc """
+ Generates a secure random device token.
+ """
+ def generate_device_token do
+ :crypto.strong_rand_bytes(32)
+ |> Base.url_encode64(padding: false)
+ end
+
+ def hash_device_token(token) when is_binary(token) do
+ :crypto.hash(:sha256, token)
+ |> Base.encode16(case: :lower)
+ end
+
+ @doc """
+ Checks if a trusted device is still valid (not expired).
+ """
+ def valid?(%__MODULE__{expires_at: expires_at}) do
+ DateTime.compare(DateTime.utc_now(), expires_at) == :lt
+ end
+end
diff --git a/apps/elektrine/lib/elektrine/accounts/two_factor.ex b/apps/elektrine/lib/elektrine/accounts/two_factor.ex
new file mode 100644
index 0000000..5168b46
--- /dev/null
+++ b/apps/elektrine/lib/elektrine/accounts/two_factor.ex
@@ -0,0 +1,121 @@
+defmodule Elektrine.Accounts.TwoFactor do
+ @moduledoc "Provides functionality for Two-Factor Authentication using TOTP.\n"
+ alias NimbleTOTP
+ @doc "Generates a new TOTP secret for a user.\n"
+ def generate_secret do
+ NimbleTOTP.secret()
+ end
+
+ @doc "Generates a TOTP provisioning URI for QR code generation.\n"
+ def generate_provisioning_uri(secret, username, issuer \\ "Elektrine") do
+ NimbleTOTP.otpauth_uri("#{issuer}:#{username}", secret, issuer: issuer)
+ end
+
+ @doc "Converts a binary secret to Base32 encoding for manual entry in authenticator apps.\n"
+ def secret_to_base32(secret) when is_binary(secret) do
+ Base.encode32(secret, padding: false)
+ end
+
+ @doc "Generates a QR code PNG image as binary data for the given provisioning URI.\n"
+ def generate_qr_code_png(provisioning_uri) when is_binary(provisioning_uri) do
+ qr_code = EQRCode.encode(provisioning_uri)
+ png_binary = EQRCode.png(qr_code, width: 200)
+ {:ok, png_binary}
+ rescue
+ error -> {:error, error}
+ end
+
+ @doc "Generates a QR code as a base64 data URI for inline embedding in HTML.\nThis avoids timing issues with session cookies when using separate image requests.\n"
+ def generate_qr_code_data_uri(provisioning_uri) when is_binary(provisioning_uri) do
+ qr_code = EQRCode.encode(provisioning_uri)
+ png_binary = EQRCode.png(qr_code, width: 200)
+ base64_png = Base.encode64(png_binary)
+ {:ok, "data:image/png;base64,#{base64_png}"}
+ rescue
+ error -> {:error, error}
+ end
+
+ @doc "Verifies a TOTP code against the user's secret.\n\nTOTP Configuration:\n- Algorithm: SHA1 (TOTP standard)\n- Digits: 6\n- Period: 30 seconds\n- Custom window: checks ±10 time periods manually\n\nUses ±1 period (±30 seconds) for a total 90-second validation window.\nThis handles minor clock drift while maintaining security.\n"
+ def verify_totp(secret, code) when is_binary(secret) and is_binary(code) do
+ require Logger
+
+ case Integer.parse(code) do
+ {numeric_code, ""} when numeric_code >= 0 and numeric_code <= 999_999 ->
+ formatted_code = String.pad_leading(code, 6, "0")
+ current_time = System.system_time(:second)
+
+ valid =
+ Enum.any?(-1..1, fn offset ->
+ time_for_offset = current_time + offset * 30
+ expected_code = NimbleTOTP.verification_code(secret, time: time_for_offset)
+ expected_code == formatted_code
+ end)
+
+ if valid do
+ Logger.debug("2FA code verified successfully")
+ else
+ Logger.warning("2FA code verification failed for user")
+ end
+
+ valid
+
+ _ ->
+ Logger.warning("2FA code format invalid")
+ false
+ end
+ end
+
+ def verify_totp(_, _) do
+ false
+ end
+
+ @doc "Generates backup codes for account recovery.\nReturns {plain_codes, hashed_codes} tuple.\nPlain codes should be shown to user once, hashed codes stored in database.\n"
+ def generate_backup_codes(count \\ 8) do
+ plain_codes = 1..count |> Enum.map(fn _ -> generate_backup_code() end)
+ hashed_codes = Enum.map(plain_codes, &hash_backup_code/1)
+ {plain_codes, hashed_codes}
+ end
+
+ @doc "Verifies a backup code against the user's stored hashed backup codes.\nReturns {:ok, remaining_codes} if valid, {:error, :invalid} if not.\n"
+ def verify_backup_code(backup_codes, code) when is_list(backup_codes) and is_binary(code) do
+ formatted_code = String.upcase(String.trim(code))
+
+ case Enum.find_index(backup_codes, fn stored_code ->
+ verify_backup_code_match(formatted_code, stored_code)
+ end) do
+ nil ->
+ {:error, :invalid}
+
+ index ->
+ remaining_codes = List.delete_at(backup_codes, index)
+ {:ok, remaining_codes}
+ end
+ end
+
+ def verify_backup_code(_, _) do
+ {:error, :invalid}
+ end
+
+ defp hash_backup_code(code) when is_binary(code) do
+ Argon2.hash_pwd_salt(code)
+ end
+
+ defp verify_backup_code_match(code, stored_code)
+ when is_binary(code) and is_binary(stored_code) do
+ String.starts_with?(stored_code, "$argon2") and Argon2.verify_pass(code, stored_code)
+ end
+
+ defp verify_backup_code_match(_, _) do
+ false
+ end
+
+ defp generate_backup_code do
+ chars = "ABCDEFGHIJKLMNPQRSTUVWXYZ123456789"
+ chars_list = String.codepoints(chars)
+ chars_count = length(chars_list)
+
+ :crypto.strong_rand_bytes(8)
+ |> :binary.bin_to_list()
+ |> Enum.map_join("", fn byte -> Enum.at(chars_list, rem(byte, chars_count)) end)
+ end
+end
diff --git a/apps/elektrine/lib/elektrine/accounts/user.ex b/apps/elektrine/lib/elektrine/accounts/user.ex
new file mode 100644
index 0000000..a797e07
--- /dev/null
+++ b/apps/elektrine/lib/elektrine/accounts/user.ex
@@ -0,0 +1,1489 @@
+defmodule Elektrine.Accounts.User do
+ @moduledoc false
+ use Ecto.Schema
+ import Ecto.Changeset
+ import Ecto.Query, warn: false
+
+ alias Elektrine.Accounts.BuiltInSubdomain
+ alias Elektrine.Security.URLValidator
+
+ @built_in_subdomain_modes BuiltInSubdomain.modes()
+
+ schema "users" do
+ # Authentication
+ field :username, :string
+ field :password_hash, :string
+ field :password, :string, virtual: true
+ field :password_confirmation, :string, virtual: true
+ field :current_password, :string, virtual: true
+ field :invite_code, :string, virtual: true
+ field :registration_access_token, :string, virtual: true
+ field :avatar, :string
+ field :avatar_size, :integer, default: 0
+ field :is_admin, :boolean, default: false
+ field :is_bot, :boolean, default: false
+ field :verified, :boolean, default: false
+ field :banned, :boolean, default: false
+ field :banned_at, :utc_datetime
+ field :banned_reason, :string
+ field :suspended, :boolean, default: false
+ field :suspended_until, :utc_datetime
+ field :suspension_reason, :string
+
+ # Trust Level System
+ field :trust_level, :integer, default: 0
+ field :trust_level_locked, :boolean, default: false
+ field :promoted_at, :utc_datetime
+ field :two_factor_enabled, :boolean, default: false
+ field :two_factor_secret, Elektrine.Secrets.EncryptedString
+ field :two_factor_backup_codes, {:array, :string}
+ field :two_factor_enabled_at, :utc_datetime
+ field :registration_ip, :string
+ field :registered_via_onion, :boolean, default: false
+ field :last_login_ip, :string
+ field :last_login_at, :utc_datetime
+ field :login_count, :integer, default: 0
+ field :recovery_email, :string
+ field :password_reset_token, :string
+ field :password_reset_token_expires_at, :utc_datetime
+ field :last_password_change, :utc_datetime
+ field :auth_valid_after, :utc_datetime
+ field :locale, :string, default: "en"
+ field :timezone, :string
+ field :time_format, :string, default: "12"
+ field :theme_mode, :string, default: "system"
+ field :theme_overrides, :map, default: %{}
+
+ # Social Identity
+ field :handle, :string
+ field :display_name, :string
+ field :unique_id, :string
+ field :handle_changed_at, :utc_datetime
+ field :built_in_subdomain_mode, :string, default: "path"
+ field :birthday, :date
+ field :show_birthday, :boolean, default: false
+ field :hide_followers, :boolean, default: false
+ field :hide_follows, :boolean, default: false
+ field :hide_favorites, :boolean, default: false
+ field :also_known_as, {:array, :string}, default: []
+ field :moved_to, :string
+
+ # Privacy Settings
+ field :allow_group_adds_from, :string, default: "everyone"
+ field :allow_direct_messages_from, :string, default: "everyone"
+ field :allow_mentions_from, :string, default: "everyone"
+ field :allow_calls_from, :string, default: "friends"
+ field :allow_friend_requests_from, :string, default: "everyone"
+ field :profile_visibility, :string, default: "public"
+ field :default_post_visibility, :string, default: "followers"
+
+ # Notification Settings
+ field :notify_on_new_follower, :boolean, default: true
+ field :notify_on_direct_message, :boolean, default: true
+ field :notify_on_mention, :boolean, default: true
+ field :notify_on_reply, :boolean, default: true
+ field :notify_on_like, :boolean, default: true
+ field :notify_on_email_received, :boolean, default: true
+ field :notify_on_discussion_reply, :boolean, default: true
+ field :notify_on_comment, :boolean, default: true
+ field :block_notifications_from_strangers, :boolean, default: false
+ field :hide_notification_contents, :boolean, default: false
+
+ # Storage Tracking
+ field :storage_used_bytes, :integer, default: 0
+ # 500MB
+ field :storage_limit_bytes, :integer, default: 524_288_000
+ field :storage_last_calculated_at, :utc_datetime
+
+ # CardDAV/CalDAV sync
+ field :addressbook_ctag, :string
+
+ # Email Protocol Usage Tracking
+ field :last_imap_access, :utc_datetime
+ field :last_pop3_access, :utc_datetime
+
+ # User Status
+ field :status, :string, default: "online"
+ field :status_message, :string
+ field :status_updated_at, :utc_datetime
+ field :last_seen_at, :utc_datetime
+
+ # Email Settings
+ field :email_signature, :string
+ field :preferred_email_domain, :string
+ field :stripe_customer_id, :string
+
+ # Email Sending Restrictions (anti-spam)
+ field :email_sending_restricted, :boolean, default: false
+ field :email_rate_limit_violations, :integer, default: 0
+ field :email_restriction_reason, :string
+ field :email_restricted_at, :utc_datetime
+ field :recovery_email_verified, :boolean, default: false
+ field :recovery_email_verification_token, :string
+ field :recovery_email_verification_sent_at, :utc_datetime
+
+ # Onboarding
+ field :onboarding_completed, :boolean, default: false
+ field :onboarding_completed_at, :utc_datetime
+ field :onboarding_step, :integer, default: 1
+
+ # ActivityPub Federation
+ field :activitypub_enabled, :boolean, default: true
+ field :activitypub_private_key, Elektrine.Secrets.EncryptedString
+ field :activitypub_public_key, :string
+ field :activitypub_manually_approve_followers, :boolean, default: true
+
+ # Bluesky Cross-posting
+ field :bluesky_enabled, :boolean, default: false
+ field :bluesky_identifier, :string
+ field :bluesky_app_password, Elektrine.Secrets.EncryptedString
+ field :bluesky_did, :string
+ field :bluesky_pds_url, :string
+ field :bluesky_inbound_cursor, :string
+ field :bluesky_inbound_last_polled_at, :utc_datetime
+
+ # PGP/OpenPGP Encryption
+ field :pgp_public_key, :string
+ field :pgp_key_id, :string
+ field :pgp_fingerprint, :string
+ field :pgp_key_uploaded_at, :utc_datetime
+ field :pgp_wkd_hash, :string
+
+ has_one :profile, Elektrine.Profiles.UserProfile
+ has_many :sessions, Elektrine.Accounts.UserSession
+ has_many :badges, Elektrine.Profiles.UserBadge, foreign_key: :user_id
+ has_one :activity_stats, Elektrine.Accounts.UserActivityStats
+ has_many :trust_level_logs, Elektrine.Accounts.TrustLevelLog
+
+ timestamps(type: :utc_datetime)
+ end
+
+ @doc """
+ Changeset for user registration.
+ """
+ def registration_changeset(user, attrs) do
+ user
+ |> cast(attrs, [
+ :username,
+ :password,
+ :password_confirmation,
+ :invite_code,
+ :registration_access_token,
+ :registration_ip,
+ :registered_via_onion
+ ])
+ |> validate_tos_agreement(attrs)
+ |> normalize_username()
+ |> validate_required([:username, :password, :password_confirmation])
+ |> validate_length(:username, min: 2, max: 30)
+ |> validate_format(:username, ~r/^[a-zA-Z0-9]+$/, message: "only letters and numbers allowed")
+ |> validate_username_not_alias()
+ |> validate_username_not_matching_other_handle()
+ |> validate_username_not_reserved()
+ |> validate_username_case_conflicts()
+ |> unique_constraint(:username,
+ name: :users_username_ci_unique,
+ message: "this username is already taken"
+ )
+ |> unique_constraint(:username,
+ name: :users_username_index,
+ message: "this username is already taken"
+ )
+ |> validate_length(:password, min: 8, max: 72)
+ |> validate_confirmation(:password, message: "does not match password")
+ |> generate_unique_id()
+ |> assign_initial_handle()
+ |> put_display_name_from_username()
+ |> maybe_put_default_preferred_email_domain()
+ |> hash_password()
+ end
+
+ @doc """
+ Changeset for admin user registration.
+ Allows admins to create users with additional options.
+ """
+ def admin_registration_changeset(user, attrs) do
+ user
+ |> cast(attrs, [
+ :username,
+ :password,
+ :password_confirmation,
+ :is_admin,
+ :trust_level,
+ :trust_level_locked
+ ])
+ |> normalize_username()
+ |> validate_required([:username, :password, :password_confirmation])
+ |> validate_length(:username, min: 2, max: 30)
+ |> validate_format(:username, ~r/^[a-zA-Z0-9]+$/, message: "only letters and numbers allowed")
+ |> validate_username_not_alias()
+ |> validate_username_not_matching_other_handle()
+ # Skip reserved username validation for admin-created users
+ |> validate_username_case_conflicts()
+ |> unique_constraint(:username,
+ name: :users_username_ci_unique,
+ message: "this username is already taken (case-insensitive)"
+ )
+ |> validate_length(:password, min: 8, max: 72)
+ |> validate_confirmation(:password, message: "does not match password")
+ |> validate_number(:trust_level, greater_than_or_equal_to: 0, less_than_or_equal_to: 4)
+ |> generate_unique_id()
+ |> assign_initial_handle()
+ |> put_display_name_from_username()
+ |> maybe_put_default_preferred_email_domain()
+ |> hash_password()
+ end
+
+ defp hash_password(%Ecto.Changeset{valid?: true, changes: %{password: password}} = changeset) do
+ change(changeset, %{
+ password_hash: Argon2.hash_pwd_salt(password),
+ last_password_change: DateTime.utc_now() |> DateTime.truncate(:second)
+ })
+ end
+
+ defp hash_password(changeset), do: changeset
+
+ defp validate_preferred_email_domain(changeset, user) do
+ case fetch_change(changeset, :preferred_email_domain) do
+ :error ->
+ changeset
+
+ {:ok, nil} ->
+ changeset
+
+ {:ok, preferred_domain} ->
+ allowed_domains = Elektrine.Domains.available_email_domains_for_user(user)
+
+ if preferred_domain in allowed_domains do
+ changeset
+ else
+ add_error(changeset, :preferred_email_domain, "must be one of your available domains")
+ end
+ end
+ end
+
+ # Generate a unique ID for the user
+ defp generate_unique_id(changeset) do
+ if get_field(changeset, :unique_id) do
+ changeset
+ else
+ # 8 bytes = 64 bits entropy
+ # 50% collision probability at ~5 billion users (vs 77k with 4 bytes)
+ unique_id = "usr_" <> (:crypto.strong_rand_bytes(8) |> Base.encode16(case: :lower))
+ put_change(changeset, :unique_id, unique_id)
+ end
+ end
+
+ # Assign an initial handle based on username
+ defp assign_initial_handle(changeset) do
+ if get_field(changeset, :handle) do
+ changeset
+ else
+ username = get_field(changeset, :username)
+
+ if username do
+ base_handle = String.downcase(username)
+ handle = find_available_handle(base_handle)
+ put_change(changeset, :handle, handle)
+ else
+ changeset
+ end
+ end
+ end
+
+ # Find an available handle by appending numbers if needed
+ defp find_available_handle(base_handle, attempt \\ 0) do
+ handle =
+ if attempt == 0 do
+ base_handle
+ else
+ "#{base_handle}#{attempt}"
+ end
+
+ # Check if handle is already taken (use exists? to avoid multiple results error)
+ import Ecto.Query
+ exists = Elektrine.Repo.exists?(from u in __MODULE__, where: u.handle == ^handle)
+
+ if exists do
+ find_available_handle(base_handle, attempt + 1)
+ else
+ handle
+ end
+ end
+
+ # Set display name from username if not provided
+ defp put_display_name_from_username(changeset) do
+ if get_field(changeset, :display_name) do
+ changeset
+ else
+ username = get_field(changeset, :username)
+
+ if username do
+ put_change(changeset, :display_name, username)
+ else
+ changeset
+ end
+ end
+ end
+
+ @doc """
+ A changeset for changing the user account.
+ """
+ def changeset(user, attrs) do
+ user
+ |> cast(attrs, [
+ :avatar,
+ :avatar_size,
+ :display_name,
+ :recovery_email,
+ :allow_group_adds_from,
+ :allow_direct_messages_from,
+ :allow_mentions_from,
+ :allow_calls_from,
+ :allow_friend_requests_from,
+ :profile_visibility,
+ :default_post_visibility,
+ :notify_on_new_follower,
+ :notify_on_direct_message,
+ :notify_on_mention,
+ :notify_on_reply,
+ :notify_on_like,
+ :notify_on_email_received,
+ :notify_on_discussion_reply,
+ :notify_on_comment,
+ :block_notifications_from_strangers,
+ :hide_notification_contents,
+ :locale,
+ :timezone,
+ :time_format,
+ :theme_mode,
+ :theme_overrides,
+ :email_signature,
+ :preferred_email_domain,
+ :onboarding_completed,
+ :onboarding_completed_at,
+ :onboarding_step,
+ :activitypub_manually_approve_followers,
+ :built_in_subdomain_mode,
+ :birthday,
+ :show_birthday,
+ :hide_followers,
+ :hide_follows,
+ :hide_favorites,
+ :also_known_as,
+ :moved_to,
+ :bluesky_enabled,
+ :bluesky_identifier,
+ :bluesky_app_password,
+ :bluesky_pds_url
+ ])
+ |> validate_length(:display_name, max: 100)
+ |> validate_format(:recovery_email, ~r/^[^\s@]+@[^\s@]+\.[^\s@]+$/,
+ message: "must be a valid email address with @ symbol and domain"
+ )
+ |> validate_length(:recovery_email, max: 160)
+ |> validate_length(:email_signature, max: 500)
+ |> validate_inclusion(:allow_group_adds_from, [
+ "everyone",
+ "following",
+ "followers",
+ "mutual",
+ "friends",
+ "nobody"
+ ])
+ |> validate_inclusion(:allow_direct_messages_from, [
+ "everyone",
+ "following",
+ "followers",
+ "mutual",
+ "friends",
+ "nobody"
+ ])
+ |> validate_inclusion(:allow_mentions_from, [
+ "everyone",
+ "following",
+ "followers",
+ "mutual",
+ "friends",
+ "nobody"
+ ])
+ |> validate_inclusion(:allow_calls_from, ["everyone", "friends", "nobody"])
+ |> validate_inclusion(:allow_friend_requests_from, ["everyone", "followers", "nobody"])
+ |> validate_inclusion(:profile_visibility, ["public", "followers", "private"])
+ |> validate_inclusion(:default_post_visibility, ["public", "followers", "friends", "private"])
+ |> validate_inclusion(:built_in_subdomain_mode, @built_in_subdomain_modes)
+ |> validate_inclusion(:locale, ~w(en zh), message: "is not a supported locale")
+ |> validate_inclusion(:time_format, ~w(12 24), message: "must be 12 or 24")
+ |> validate_birthday()
+ |> normalize_account_migration_metadata()
+ |> validate_account_migration_metadata()
+ |> validate_inclusion(:theme_mode, Elektrine.Theme.modes(),
+ message: "is not a supported theme mode"
+ )
+ |> Elektrine.Theme.validate_overrides(:theme_overrides)
+ |> validate_preferred_email_domain(user)
+ |> validate_bluesky_settings()
+ end
+
+ @doc """
+ A changeset for notification-only settings updates.
+ """
+ def notification_settings_changeset(user, attrs) do
+ cast(user, attrs, [
+ :notify_on_new_follower,
+ :notify_on_direct_message,
+ :notify_on_mention,
+ :notify_on_reply,
+ :notify_on_like,
+ :notify_on_email_received,
+ :notify_on_discussion_reply,
+ :notify_on_comment,
+ :block_notifications_from_strangers,
+ :hide_notification_contents
+ ])
+ end
+
+ @doc """
+ A changeset for changing the user password.
+ """
+ def password_changeset(user, attrs) do
+ user
+ |> cast(attrs, [:password, :password_confirmation, :current_password])
+ |> validate_required([:password, :password_confirmation, :current_password])
+ |> validate_length(:password, min: 8, max: 72)
+ |> validate_confirmation(:password, message: "does not match password")
+ |> validate_current_password()
+ |> hash_password()
+ end
+
+ @doc """
+ A changeset for importing users with pre-hashed passwords.
+ For use in migrations only.
+ """
+ def import_changeset(user, attrs) do
+ user
+ |> cast(attrs, [:username, :password_hash])
+ |> normalize_username()
+ |> validate_required([:username, :password_hash])
+ |> validate_length(:username, min: 2, max: 30)
+ |> validate_format(:username, ~r/^[a-zA-Z0-9]+$/, message: "only letters and numbers allowed")
+ |> maybe_put_default_preferred_email_domain()
+ |> unique_constraint(:username)
+ end
+
+ defp maybe_put_default_preferred_email_domain(changeset) do
+ case get_field(changeset, :preferred_email_domain) do
+ nil ->
+ put_change(
+ changeset,
+ :preferred_email_domain,
+ Elektrine.Domains.default_user_handle_domain()
+ )
+
+ "" ->
+ put_change(
+ changeset,
+ :preferred_email_domain,
+ Elektrine.Domains.default_user_handle_domain()
+ )
+
+ _ ->
+ changeset
+ end
+ end
+
+ @doc """
+ A changeset for trust-level updates managed by the trust system.
+ """
+ def trust_level_changeset(user, attrs) do
+ user
+ |> cast(attrs, [:trust_level, :promoted_at])
+ |> validate_number(:trust_level, greater_than_or_equal_to: 0, less_than_or_equal_to: 4)
+ end
+
+ @doc """
+ A changeset for admin user editing.
+ """
+ def admin_changeset(user, attrs) do
+ user
+ |> cast(attrs, [
+ :username,
+ :avatar,
+ :handle,
+ :display_name,
+ :recovery_email,
+ :locale,
+ :timezone,
+ :time_format,
+ :built_in_subdomain_mode,
+ :birthday,
+ :show_birthday,
+ :also_known_as,
+ :moved_to,
+ :verified,
+ :is_bot,
+ :banned,
+ :banned_reason,
+ :suspended,
+ :suspended_until,
+ :suspension_reason,
+ :is_admin,
+ :trust_level,
+ :trust_level_locked
+ ])
+ |> validate_required([:username])
+ |> normalize_username()
+ |> validate_length(:username, min: 2, max: 30)
+ |> validate_format(:username, ~r/^[a-zA-Z0-9]+$/, message: "only letters and numbers allowed")
+ |> validate_handle_immutable()
+ |> validate_handle()
+ |> validate_length(:display_name, max: 100)
+ |> validate_inclusion(:built_in_subdomain_mode, @built_in_subdomain_modes)
+ |> validate_format(:recovery_email, ~r/^[^\s@]+@[^\s@]+\.[^\s@]+$/,
+ message: "must be a valid email"
+ )
+ |> validate_inclusion(:locale, ["en", "zh"])
+ |> validate_inclusion(:time_format, ["12", "24"])
+ |> validate_birthday()
+ |> normalize_account_migration_metadata()
+ |> validate_account_migration_metadata()
+ |> validate_length(:banned_reason, max: 500)
+ |> validate_length(:suspension_reason, max: 500)
+ |> validate_number(:trust_level, greater_than_or_equal_to: 0, less_than_or_equal_to: 4)
+ |> maybe_set_ban_timestamp()
+ |> maybe_clear_suspension_data()
+ |> validate_username_not_alias()
+ |> validate_username_not_matching_other_handle()
+ |> unique_constraint(:handle,
+ name: :users_handle_ci_unique,
+ message: "this handle is already taken"
+ )
+ |> validate_username_not_reserved()
+ |> validate_username_case_conflicts()
+ |> unique_constraint(:username,
+ name: :users_username_ci_unique,
+ message: "this username is already taken"
+ )
+ |> unique_constraint(:username,
+ name: :users_username_index,
+ message: "this username is already taken"
+ )
+ end
+
+ # Set banned_at timestamp when banned status changes
+ defp maybe_set_ban_timestamp(changeset) do
+ case get_change(changeset, :banned) do
+ true -> put_change(changeset, :banned_at, DateTime.utc_now())
+ false -> put_change(changeset, :banned_at, nil) |> put_change(:banned_reason, nil)
+ _ -> changeset
+ end
+ end
+
+ # Clear suspension data when suspension is disabled
+ defp maybe_clear_suspension_data(changeset) do
+ case get_change(changeset, :suspended) do
+ false ->
+ changeset
+ |> put_change(:suspended_until, nil)
+ |> put_change(:suspension_reason, nil)
+
+ _ ->
+ changeset
+ end
+ end
+
+ @doc """
+ A changeset for banning a user.
+ """
+ def ban_changeset(user, attrs \\ %{}) do
+ user
+ |> cast(attrs, [:banned_reason])
+ |> put_change(:banned, true)
+ |> put_change(:banned_at, DateTime.utc_now() |> DateTime.truncate(:second))
+ end
+
+ @doc """
+ A changeset for updating user locale preference.
+ """
+ def locale_changeset(user, attrs) do
+ user
+ |> cast(attrs, [:locale, :timezone, :time_format])
+ |> validate_inclusion(:locale, ~w(en zh), message: "is not a supported locale")
+ |> validate_inclusion(:time_format, ~w(12 24), message: "must be 12 or 24")
+ end
+
+ @doc """
+ A changeset for unbanning a user.
+ """
+ def unban_changeset(user, attrs \\ %{}) do
+ user
+ |> cast(attrs, [])
+ |> put_change(:banned, false)
+ |> put_change(:banned_at, nil)
+ |> put_change(:banned_reason, nil)
+ end
+
+ @doc """
+ A changeset for suspending a user.
+ """
+ def suspend_changeset(user, attrs) do
+ user
+ |> cast(attrs, [:suspension_reason, :suspended_until])
+ |> validate_length(:suspension_reason, max: 500)
+ |> put_change(:suspended, true)
+ end
+
+ @doc """
+ A changeset for unsuspending a user.
+ """
+ def unsuspend_changeset(user, attrs \\ %{}) do
+ user
+ |> cast(attrs, [])
+ |> put_change(:suspended, false)
+ |> put_change(:suspended_until, nil)
+ |> put_change(:suspension_reason, nil)
+ end
+
+ @doc """
+ A changeset for enabling two-factor authentication.
+ """
+ def enable_two_factor_changeset(user, attrs) do
+ user
+ |> cast(attrs, [:two_factor_secret, :two_factor_backup_codes])
+ |> validate_required([:two_factor_secret, :two_factor_backup_codes])
+ |> put_change(:two_factor_enabled, true)
+ |> put_change(:two_factor_enabled_at, DateTime.utc_now() |> DateTime.truncate(:second))
+ end
+
+ @doc """
+ A changeset for disabling two-factor authentication.
+ """
+ def disable_two_factor_changeset(user, _attrs \\ %{}) do
+ user
+ |> cast(%{}, [])
+ |> put_change(:two_factor_enabled, false)
+ |> put_change(:two_factor_secret, nil)
+ |> put_change(:two_factor_backup_codes, nil)
+ |> put_change(:two_factor_enabled_at, nil)
+ end
+
+ @doc """
+ A changeset for updating backup codes after one is used.
+ """
+ def update_backup_codes_changeset(user, remaining_codes) do
+ user
+ |> cast(%{}, [])
+ |> put_change(:two_factor_backup_codes, remaining_codes)
+ end
+
+ @doc """
+ A changeset for updating login information.
+ """
+ def login_changeset(user, attrs) do
+ user
+ |> cast(attrs, [:last_login_ip, :last_login_at, :login_count])
+ end
+
+ # Private helper functions
+
+ defp validate_tos_agreement(changeset, attrs) do
+ # Only validate if this is a form submission (attrs has string keys and values)
+ # Skip validation on initial changeset creation
+ if is_map(attrs) && map_size(attrs) > 0 && Map.has_key?(attrs, "username") do
+ case Map.get(attrs, "agree_to_terms") do
+ "true" ->
+ changeset
+
+ _ ->
+ add_error(
+ changeset,
+ :agree_to_terms,
+ "You must agree to the Terms of Service and Privacy Policy"
+ )
+ end
+ else
+ changeset
+ end
+ end
+
+ defp validate_username_not_alias(changeset) do
+ username = get_field(changeset, :username)
+
+ if not is_nil(username) and Elektrine.Platform.Modules.compiled?(:email) do
+ # Check if this username would conflict with existing aliases on our domains
+ allowed_domains = Elektrine.Domains.supported_email_domains()
+
+ # Check each domain for conflicts (case-insensitive)
+ conflicts =
+ Enum.any?(allowed_domains, fn domain ->
+ alias_email = String.downcase("#{username}@#{domain}")
+
+ query =
+ from(a in Elektrine.Email.Alias,
+ where: fragment("lower(?)", a.alias_email) == ^alias_email and a.enabled == true,
+ limit: 1
+ )
+
+ case Elektrine.Repo.one(query) do
+ nil -> false
+ _alias -> true
+ end
+ end)
+
+ if conflicts do
+ add_error(changeset, :username, "this username conflicts with an existing email alias")
+ else
+ changeset
+ end
+ else
+ changeset
+ end
+ end
+
+ defp validate_username_not_matching_other_handle(changeset) do
+ username = get_field(changeset, :username)
+ user_id = get_field(changeset, :id)
+
+ if Elektrine.Strings.present?(username) do
+ query =
+ from(u in Elektrine.Accounts.User,
+ where: fragment("lower(?)", u.handle) == ^String.downcase(username)
+ )
+
+ query =
+ if user_id do
+ from(u in query, where: u.id != ^user_id)
+ else
+ query
+ end
+
+ case Elektrine.Repo.one(query) do
+ nil -> changeset
+ _ -> add_error(changeset, :username, "conflicts with an existing handle")
+ end
+ else
+ changeset
+ end
+ end
+
+ defp validate_username_case_conflicts(changeset) do
+ username = get_field(changeset, :username)
+ user_id = get_field(changeset, :id)
+
+ if username do
+ # Check for case-insensitive duplicates
+ query =
+ from(u in Elektrine.Accounts.User,
+ where: fragment("lower(?)", u.username) == ^String.downcase(username)
+ )
+
+ # Exclude current user if updating
+ query =
+ if user_id do
+ from(u in query, where: u.id != ^user_id)
+ else
+ query
+ end
+
+ case Elektrine.Repo.one(query) do
+ nil ->
+ changeset
+
+ _existing ->
+ add_error(
+ changeset,
+ :username,
+ "a user with this username already exists (case-insensitive check)"
+ )
+ end
+ else
+ changeset
+ end
+ end
+
+ defp validate_current_password(changeset) do
+ current_password = get_change(changeset, :current_password)
+
+ if current_password do
+ user = changeset.data
+
+ # Verify the current password
+ if verify_password(current_password, user.password_hash) do
+ changeset
+ else
+ add_error(changeset, :current_password, "is incorrect")
+ end
+ else
+ changeset
+ end
+ end
+
+ # Helper function to verify password against hash
+ defp verify_password(password, hash) when is_binary(password) and is_binary(hash) do
+ if String.starts_with?(hash, ["$2", "$2a$", "$2b$", "$2y$"]) do
+ Bcrypt.verify_pass(password, hash)
+ else
+ Argon2.verify_pass(password, hash)
+ end
+ end
+
+ defp verify_password(_, _), do: false
+
+ defp validate_username_not_reserved(changeset) do
+ username = get_field(changeset, :username)
+
+ if username do
+ # List of reserved/sensitive usernames
+ reserved_usernames =
+ [
+ # System accounts (critical - these would create system emails)
+ "admin",
+ "administrator",
+ "root",
+ "system",
+ "daemon",
+ "postmaster",
+ "webmaster",
+ "hostmaster",
+ # Service accounts
+ "support",
+ "help",
+ "contact",
+ "info",
+ "sales",
+ "billing",
+ "noreply",
+ "no-reply",
+ "donotreply",
+ "do-not-reply",
+ # ActivityPub endpoints (critical - would break federation)
+ "inbox",
+ "outbox",
+ "followers",
+ "following",
+ "actor",
+ "users",
+ "activities",
+ "ap",
+ # Well-known paths
+ "webfinger",
+ ".well-known",
+ "nodeinfo",
+ # Federation-related
+ "relay",
+ "instance",
+ "sharedInbox",
+ "abuse",
+ "security",
+ "privacy",
+ "legal",
+ "compliance",
+ "audit",
+ # Technical infrastructure
+ "api",
+ "www",
+ "mail",
+ "email",
+ "smtp",
+ "pop",
+ "imap",
+ "ftp",
+ "ssh",
+ "git",
+ "dev",
+ "test",
+ "staging",
+ "prod",
+ # Social/Marketing
+ "marketing",
+ "news",
+ "newsletter",
+ "notifications",
+ "social",
+ "media",
+ "press",
+ "blog",
+ "forum",
+ # Brand protection
+ "elektrine",
+ "z",
+ "zorg",
+ "official",
+ "verified",
+ # Common exploits
+ "null",
+ "undefined",
+ "nil",
+ "void",
+ "empty",
+ "blank",
+ "anonymous",
+ "guest",
+ "user",
+ "member",
+ "public",
+ # Application routes (prevent username/route conflicts)
+ "chat",
+ "timeline",
+ "discussions",
+ "friends",
+ "search",
+ "hashtag",
+ "vpn",
+ "account",
+ "login",
+ "register",
+ "password",
+ "logout",
+ "onboarding",
+ "about",
+ "terms",
+ "faq",
+ "locale",
+ "unsubscribe",
+ "resubscribe",
+ "pripyat",
+ "badges",
+ "reports",
+ "updates",
+ "storage",
+ "profile",
+ "settings",
+ "invite",
+ "invites",
+ "l",
+ "haraka"
+ ] ++ Elektrine.Domains.reserved_local_parts()
+
+ if String.downcase(username) in reserved_usernames do
+ add_error(changeset, :username, "this username is reserved and cannot be used")
+ else
+ changeset
+ end
+ else
+ changeset
+ end
+ end
+
+ defp normalize_username(changeset) do
+ case get_field(changeset, :username) do
+ nil ->
+ changeset
+
+ username ->
+ normalized = String.downcase(username)
+ put_change(changeset, :username, normalized)
+ end
+ end
+
+ defp validate_bluesky_settings(changeset) do
+ changeset
+ |> normalize_optional_string(:bluesky_identifier)
+ |> normalize_optional_string(:bluesky_app_password)
+ |> normalize_optional_string(:bluesky_pds_url)
+ |> normalize_bluesky_pds_url()
+ |> validate_length(:bluesky_identifier, max: 255)
+ |> validate_length(:bluesky_app_password, max: 255)
+ |> validate_length(:bluesky_pds_url, max: 255)
+ |> validate_bluesky_pds_url()
+ |> require_bluesky_credentials_when_enabled()
+ end
+
+ defp normalize_bluesky_pds_url(changeset) do
+ case get_change(changeset, :bluesky_pds_url) do
+ value when is_binary(value) and value != "" ->
+ normalized =
+ value
+ |> then(fn url ->
+ if String.starts_with?(url, ["http://", "https://"]), do: url, else: "https://" <> url
+ end)
+ |> String.trim_trailing("/")
+
+ put_change(changeset, :bluesky_pds_url, normalized)
+
+ _ ->
+ changeset
+ end
+ end
+
+ defp validate_bluesky_pds_url(changeset) do
+ validate_change(changeset, :bluesky_pds_url, fn :bluesky_pds_url, value ->
+ if configured_managed_bluesky_pds_url?(value) do
+ []
+ else
+ case URLValidator.validate(value) do
+ :ok -> []
+ {:error, _reason} -> [bluesky_pds_url: "must be a public http or https URL"]
+ end
+ end
+ end)
+ end
+
+ defp configured_managed_bluesky_pds_url?(value) when is_binary(value) do
+ configured =
+ Application.get_env(:elektrine, :bluesky, [])
+ |> Keyword.get(:managed_service_url)
+
+ normalize_bluesky_pds_url_value(value) == normalize_bluesky_pds_url_value(configured)
+ end
+
+ defp configured_managed_bluesky_pds_url?(_value), do: false
+
+ defp normalize_bluesky_pds_url_value(value) when is_binary(value) do
+ value
+ |> String.trim()
+ |> case do
+ "" ->
+ nil
+
+ url ->
+ if String.starts_with?(url, ["http://", "https://"]), do: url, else: "https://" <> url
+ end
+ |> case do
+ nil -> nil
+ url -> String.trim_trailing(url, "/")
+ end
+ end
+
+ defp normalize_bluesky_pds_url_value(_value), do: nil
+
+ defp normalize_optional_string(changeset, field) do
+ case get_change(changeset, field) do
+ nil ->
+ changeset
+
+ value when is_binary(value) ->
+ value
+ |> String.trim()
+ |> case do
+ "" -> put_change(changeset, field, nil)
+ trimmed -> put_change(changeset, field, trimmed)
+ end
+
+ _ ->
+ changeset
+ end
+ end
+
+ defp require_bluesky_credentials_when_enabled(changeset) do
+ if get_field(changeset, :bluesky_enabled) do
+ changeset
+ |> require_bluesky_identifier()
+ |> require_bluesky_app_password()
+ else
+ changeset
+ end
+ end
+
+ defp require_bluesky_identifier(changeset) do
+ case get_field(changeset, :bluesky_identifier) do
+ identifier when is_binary(identifier) ->
+ if Elektrine.Strings.present?(identifier),
+ do: changeset,
+ else: add_error(changeset, :bluesky_identifier, "is required when Bluesky is enabled")
+
+ _ ->
+ add_error(changeset, :bluesky_identifier, "is required when Bluesky is enabled")
+ end
+ end
+
+ defp require_bluesky_app_password(changeset) do
+ case get_field(changeset, :bluesky_app_password) do
+ password when is_binary(password) ->
+ if Elektrine.Strings.present?(password),
+ do: changeset,
+ else: add_error(changeset, :bluesky_app_password, "is required when Bluesky is enabled")
+
+ _ ->
+ add_error(changeset, :bluesky_app_password, "is required when Bluesky is enabled")
+ end
+ end
+
+ @doc """
+ A changeset for updating recovery email.
+ """
+ def recovery_email_changeset(user, attrs) do
+ user
+ |> cast(attrs, [:recovery_email])
+ |> validate_format(:recovery_email, ~r/^[^\s@]+@[^\s@]+\.[^\s@]+$/,
+ message: "must be a valid email address with @ symbol and domain"
+ )
+ |> validate_length(:recovery_email, max: 160)
+ end
+
+ @doc """
+ A changeset for initiating password reset.
+ """
+ def password_reset_changeset(user, token) do
+ expires_at = DateTime.utc_now() |> DateTime.add(3600, :second) |> DateTime.truncate(:second)
+
+ user
+ |> cast(%{}, [])
+ |> put_change(:password_reset_token, hash_sensitive_token(token))
+ |> put_change(:password_reset_token_expires_at, expires_at)
+ end
+
+ @doc """
+ A changeset for clearing password reset token.
+ """
+ def clear_password_reset_changeset(user) do
+ user
+ |> cast(%{}, [])
+ |> put_change(:password_reset_token, nil)
+ |> put_change(:password_reset_token_expires_at, nil)
+ end
+
+ @doc """
+ A changeset for resetting password with token.
+ """
+ def password_reset_with_token_changeset(user, attrs) do
+ user
+ |> cast(attrs, [:password, :password_confirmation])
+ |> validate_required([:password, :password_confirmation])
+ |> validate_length(:password, min: 8, max: 72)
+ |> validate_confirmation(:password, message: "does not match password")
+ |> hash_password()
+ |> put_change(:password_reset_token, nil)
+ |> put_change(:password_reset_token_expires_at, nil)
+ end
+
+ @doc """
+ Checks if a password reset token is valid and not expired.
+ """
+ def valid_password_reset_token?(%__MODULE__{password_reset_token: nil}), do: false
+ def valid_password_reset_token?(%__MODULE__{password_reset_token_expires_at: nil}), do: false
+
+ def valid_password_reset_token?(%__MODULE__{password_reset_token_expires_at: expires_at}) do
+ DateTime.compare(DateTime.utc_now(), expires_at) == :lt
+ end
+
+ @doc """
+ Hashes one-time recovery tokens before they are stored.
+ """
+ def hash_sensitive_token(token) when is_binary(token) do
+ :crypto.hash(:sha256, token) |> Base.encode16(case: :lower)
+ end
+
+ @doc """
+ Admin changeset for resetting user password.
+ """
+ def admin_password_reset_changeset(user, attrs) do
+ user
+ |> cast(attrs, [:password])
+ |> validate_required([:password])
+ |> validate_length(:password, min: 8, max: 72)
+ |> hash_password()
+ |> put_change(:password_reset_token, nil)
+ |> put_change(:password_reset_token_expires_at, nil)
+ end
+
+ @doc """
+ Admin changeset for resetting user 2FA.
+ """
+ def admin_2fa_reset_changeset(user) do
+ user
+ |> cast(%{}, [])
+ |> put_change(:two_factor_enabled, false)
+ |> put_change(:two_factor_secret, nil)
+ |> put_change(:two_factor_backup_codes, nil)
+ |> put_change(:two_factor_enabled_at, nil)
+ end
+
+ @doc """
+ Changeset for updating social handle.
+ """
+ def handle_changeset(user, attrs) do
+ user
+ |> cast(attrs, [:handle, :display_name])
+ |> validate_handle_immutable()
+ |> validate_handle()
+ |> validate_display_name()
+ |> unique_constraint(:handle,
+ name: :users_handle_ci_unique,
+ message: "this handle is already taken"
+ )
+ end
+
+ def built_in_subdomain_modes, do: BuiltInSubdomain.modes()
+
+ def built_in_subdomain_mode(user), do: BuiltInSubdomain.mode(user)
+
+ def built_in_subdomain_hosted_by_platform?(%__MODULE__{} = user),
+ do: BuiltInSubdomain.hosted_by_platform?(user)
+
+ def built_in_subdomain_hosted_by_platform?(_), do: true
+
+ # Validate handle format: 1-20 chars, alphanumeric + underscore
+ defp validate_handle(changeset) do
+ changeset
+ |> validate_required([:handle])
+ |> validate_length(:handle, min: 1, max: 20)
+ |> validate_format(:handle, ~r/^[a-zA-Z0-9_]+$/,
+ message: "can only contain letters, numbers, and underscores"
+ )
+ |> validate_handle_not_reserved()
+ |> validate_handle_not_matching_other_username()
+ |> normalize_handle()
+ end
+
+ defp validate_handle_immutable(%Ecto.Changeset{} = changeset) do
+ current_handle = changeset.data.handle
+ new_handle = get_change(changeset, :handle)
+
+ cond do
+ not Elektrine.Strings.present?(new_handle) ->
+ changeset
+
+ not Elektrine.Strings.present?(current_handle) ->
+ changeset
+
+ String.downcase(String.trim(new_handle)) == String.downcase(String.trim(current_handle)) ->
+ changeset
+
+ true ->
+ add_error(changeset, :handle, "cannot be changed once set")
+ end
+ end
+
+ # Normalize handle to lowercase for uniqueness checks
+ defp normalize_handle(changeset) do
+ case get_change(changeset, :handle) do
+ nil -> changeset
+ handle -> put_change(changeset, :handle, String.downcase(handle))
+ end
+ end
+
+ # Check if handle is reserved
+ defp validate_handle_not_reserved(changeset) do
+ handle = get_field(changeset, :handle)
+
+ if handle do
+ reserved_handles = [
+ "admin",
+ "administrator",
+ "root",
+ "system",
+ "moderator",
+ "mod",
+ "support",
+ "help",
+ "contact",
+ "info",
+ "api",
+ "www",
+ "mail",
+ "blog",
+ "news",
+ "about",
+ "legal",
+ "privacy",
+ "terms",
+ "tos",
+ "security",
+ "abuse",
+ "noreply",
+ "no-reply",
+ "elektrine",
+ "official",
+ # ActivityPub endpoints
+ "inbox",
+ "outbox",
+ "followers",
+ "following",
+ "actor",
+ "users",
+ "activities",
+ "ap",
+ "relay",
+ "instance",
+ "webfinger",
+ "nodeinfo"
+ ]
+
+ if String.downcase(handle) in reserved_handles do
+ add_error(changeset, :handle, "this handle is reserved")
+ else
+ # Also check if it's in the handle_history reservation period
+ validate_handle_not_recently_used(changeset, handle)
+ end
+ else
+ changeset
+ end
+ end
+
+ # Check if handle was recently used by another user (90-day reservation)
+ defp validate_handle_not_recently_used(changeset, handle) do
+ user_id = get_field(changeset, :id)
+
+ # Check handle history for recent usage
+ _ninety_days_ago = DateTime.utc_now() |> DateTime.add(-90 * 24 * 60 * 60, :second)
+
+ query =
+ from h in "handle_history",
+ where: fragment("lower(?)", h.handle) == ^String.downcase(handle),
+ where: h.user_id != ^user_id,
+ where: is_nil(h.reserved_until) or h.reserved_until > ^DateTime.utc_now(),
+ select: h.id,
+ limit: 1
+
+ case Elektrine.Repo.one(query) do
+ nil ->
+ changeset
+
+ _reserved ->
+ add_error(changeset, :handle, "this handle is reserved for 90 days after last use")
+ end
+ end
+
+ defp validate_handle_not_matching_other_username(changeset) do
+ handle = get_field(changeset, :handle)
+ user_id = get_field(changeset, :id)
+
+ if Elektrine.Strings.present?(handle) do
+ query =
+ from(u in Elektrine.Accounts.User,
+ where: fragment("lower(?)", u.username) == ^String.downcase(handle)
+ )
+
+ query =
+ if user_id do
+ from(u in query, where: u.id != ^user_id)
+ else
+ query
+ end
+
+ case Elektrine.Repo.one(query) do
+ nil -> changeset
+ _ -> add_error(changeset, :handle, "conflicts with an existing username")
+ end
+ else
+ changeset
+ end
+ end
+
+ # Validate display name
+ defp validate_display_name(changeset) do
+ changeset
+ |> validate_length(:display_name, max: 50)
+ |> validate_format(:display_name, ~r/^[^\n\r\t\x00-\x1F\x7F]+$/u,
+ message: "cannot contain control characters"
+ )
+ end
+
+ defp validate_birthday(changeset) do
+ validate_change(changeset, :birthday, fn :birthday, birthday ->
+ if Date.compare(birthday, Date.utc_today()) == :gt do
+ [birthday: "cannot be in the future"]
+ else
+ []
+ end
+ end)
+ end
+
+ defp normalize_account_migration_metadata(changeset) do
+ changeset
+ |> normalize_moved_to()
+ |> normalize_also_known_as()
+ end
+
+ defp normalize_moved_to(changeset) do
+ case get_change(changeset, :moved_to) do
+ nil ->
+ changeset
+
+ value ->
+ put_change(changeset, :moved_to, normalize_account_uri(value))
+ end
+ end
+
+ defp normalize_also_known_as(changeset) do
+ case get_change(changeset, :also_known_as) do
+ nil ->
+ changeset
+
+ values when is_list(values) ->
+ values =
+ values
+ |> Enum.map(&normalize_account_uri/1)
+ |> Enum.reject(&is_nil/1)
+ |> Enum.uniq()
+
+ put_change(changeset, :also_known_as, values)
+
+ _ ->
+ add_error(changeset, :also_known_as, "must be a list")
+ end
+ end
+
+ defp normalize_account_uri(value) when is_binary(value) do
+ value = String.trim(value)
+ if value == "", do: nil, else: value
+ end
+
+ defp normalize_account_uri(_), do: nil
+
+ defp validate_account_migration_metadata(changeset) do
+ changeset
+ |> validate_length(:moved_to, max: 2048)
+ |> validate_length(:also_known_as, max: 10)
+ |> validate_change(:moved_to, fn :moved_to, value ->
+ account_uri_errors(:moved_to, value)
+ end)
+ |> validate_change(:also_known_as, fn :also_known_as, values ->
+ if Enum.all?(values, &(account_uri_errors(:also_known_as, &1) == [])) do
+ []
+ else
+ [also_known_as: "must contain only http(s) URLs or acct: identifiers"]
+ end
+ end)
+ end
+
+ defp account_uri_errors(_field, nil), do: []
+
+ defp account_uri_errors(field, "acct:" <> rest) do
+ case String.split(rest, "@", parts: 2) do
+ [name, domain] when name != "" and domain != "" -> []
+ _ -> [{field, "must be a valid acct: identifier"}]
+ end
+ end
+
+ defp account_uri_errors(field, value) when is_binary(value) do
+ case URI.parse(value) do
+ %URI{scheme: scheme, host: host, userinfo: nil}
+ when scheme in ["http", "https"] and host not in [nil, ""] ->
+ []
+
+ _ ->
+ [{field, "must be an http(s) URL or acct: identifier"}]
+ end
+ end
+
+ defp account_uri_errors(field, _value), do: [{field, "must be a string"}]
+
+ # Validate unique_id format
+
+ @doc """
+ Check if a handle is available.
+ """
+ def handle_available?(handle) when is_binary(handle) do
+ handle = String.downcase(handle)
+
+ # Check if handle exists
+ # Check if handle is reserved
+ !Elektrine.Repo.exists?(
+ from u in __MODULE__, where: fragment("lower(?)", u.handle) == ^handle
+ ) &&
+ !handle_reserved?(handle)
+ end
+
+ defp handle_reserved?(handle) do
+ _ninety_days_ago = DateTime.utc_now() |> DateTime.add(-90 * 24 * 60 * 60, :second)
+
+ Elektrine.Repo.exists?(
+ from h in "handle_history",
+ where: fragment("lower(?)", h.handle) == ^String.downcase(handle),
+ where: is_nil(h.reserved_until) or h.reserved_until > ^DateTime.utc_now(),
+ select: h.id
+ )
+ end
+end
diff --git a/apps/elektrine/lib/elektrine/accounts/user_activity_stats.ex b/apps/elektrine/lib/elektrine/accounts/user_activity_stats.ex
new file mode 100644
index 0000000..75c3abb
--- /dev/null
+++ b/apps/elektrine/lib/elektrine/accounts/user_activity_stats.ex
@@ -0,0 +1,74 @@
+defmodule Elektrine.Accounts.UserActivityStats do
+ @moduledoc """
+ Tracks user activity metrics for trust level calculation.
+ Similar to Discourse's user_stats table.
+ """
+ use Ecto.Schema
+ import Ecto.Changeset
+
+ schema "user_activity_stats" do
+ belongs_to :user, Elektrine.Accounts.User
+
+ # Content creation
+ field :posts_created, :integer, default: 0
+ field :topics_created, :integer, default: 0
+ field :replies_created, :integer, default: 0
+
+ # Engagement metrics
+ field :likes_given, :integer, default: 0
+ field :likes_received, :integer, default: 0
+ field :replies_received, :integer, default: 0
+
+ # Reading metrics
+ field :posts_read, :integer, default: 0
+ field :topics_entered, :integer, default: 0
+ field :time_read_seconds, :integer, default: 0
+
+ # Visit tracking
+ field :days_visited, :integer, default: 0
+ field :last_visit_date, :date
+
+ # Moderation
+ field :flags_given, :integer, default: 0
+ field :flags_received, :integer, default: 0
+ field :flags_agreed, :integer, default: 0
+
+ # Penalties
+ field :posts_deleted, :integer, default: 0
+ field :suspensions_count, :integer, default: 0
+
+ timestamps(type: :utc_datetime)
+ end
+
+ def changeset(stats, attrs) do
+ stats
+ |> cast(attrs, [
+ :posts_created,
+ :topics_created,
+ :replies_created,
+ :likes_given,
+ :likes_received,
+ :replies_received,
+ :posts_read,
+ :topics_entered,
+ :time_read_seconds,
+ :days_visited,
+ :last_visit_date,
+ :flags_given,
+ :flags_received,
+ :flags_agreed,
+ :posts_deleted,
+ :suspensions_count
+ ])
+ |> validate_number(:posts_created, greater_than_or_equal_to: 0)
+ |> validate_number(:days_visited, greater_than_or_equal_to: 0)
+ end
+
+ @doc """
+ Increment a stat by a given amount.
+ """
+ def increment(stats, field, amount \\ 1) do
+ current_value = Map.get(stats, field, 0)
+ changeset(stats, %{field => current_value + amount})
+ end
+end
diff --git a/apps/elektrine/lib/elektrine/accounts/user_block.ex b/apps/elektrine/lib/elektrine/accounts/user_block.ex
new file mode 100644
index 0000000..32b1606
--- /dev/null
+++ b/apps/elektrine/lib/elektrine/accounts/user_block.ex
@@ -0,0 +1,36 @@
+defmodule Elektrine.Accounts.UserBlock do
+ @moduledoc false
+ use Ecto.Schema
+ import Ecto.Changeset
+
+ schema "user_blocks" do
+ belongs_to :blocker, Elektrine.Accounts.User
+ belongs_to :blocked, Elektrine.Accounts.User
+ field :reason, :string
+
+ timestamps()
+ end
+
+ @doc false
+ def changeset(user_block, attrs) do
+ user_block
+ |> cast(attrs, [:blocker_id, :blocked_id, :reason])
+ |> validate_required([:blocker_id, :blocked_id])
+ |> validate_length(:reason, max: 255)
+ |> validate_not_self_block()
+ |> unique_constraint([:blocker_id, :blocked_id])
+ |> foreign_key_constraint(:blocker_id)
+ |> foreign_key_constraint(:blocked_id)
+ end
+
+ defp validate_not_self_block(changeset) do
+ blocker_id = get_field(changeset, :blocker_id)
+ blocked_id = get_field(changeset, :blocked_id)
+
+ if blocker_id && blocked_id && blocker_id == blocked_id do
+ add_error(changeset, :blocked_id, "cannot block yourself")
+ else
+ changeset
+ end
+ end
+end
diff --git a/apps/elektrine/lib/elektrine/accounts/user_mute.ex b/apps/elektrine/lib/elektrine/accounts/user_mute.ex
new file mode 100644
index 0000000..aa691cf
--- /dev/null
+++ b/apps/elektrine/lib/elektrine/accounts/user_mute.ex
@@ -0,0 +1,37 @@
+defmodule Elektrine.Accounts.UserMute do
+ @moduledoc false
+ use Ecto.Schema
+ import Ecto.Changeset
+
+ schema "user_mutes" do
+ belongs_to :muter, Elektrine.Accounts.User
+ belongs_to :muted, Elektrine.Accounts.User
+ field :mute_notifications, :boolean, default: false
+ field :expires_at, :utc_datetime
+
+ timestamps()
+ end
+
+ @doc false
+ def changeset(user_mute, attrs) do
+ user_mute
+ |> cast(attrs, [:muter_id, :muted_id, :mute_notifications, :expires_at])
+ |> update_change(:expires_at, &Elektrine.Time.truncate/1)
+ |> validate_required([:muter_id, :muted_id])
+ |> validate_not_self_mute()
+ |> unique_constraint([:muter_id, :muted_id])
+ |> foreign_key_constraint(:muter_id)
+ |> foreign_key_constraint(:muted_id)
+ end
+
+ defp validate_not_self_mute(changeset) do
+ muter_id = get_field(changeset, :muter_id)
+ muted_id = get_field(changeset, :muted_id)
+
+ if muter_id && muted_id && muter_id == muted_id do
+ add_error(changeset, :muted_id, "cannot mute yourself")
+ else
+ changeset
+ end
+ end
+end
diff --git a/apps/elektrine/lib/elektrine/accounts/user_session.ex b/apps/elektrine/lib/elektrine/accounts/user_session.ex
new file mode 100644
index 0000000..103226b
--- /dev/null
+++ b/apps/elektrine/lib/elektrine/accounts/user_session.ex
@@ -0,0 +1,108 @@
+defmodule Elektrine.Accounts.UserSession do
+ @moduledoc false
+ use Ecto.Schema
+ import Ecto.Changeset
+
+ schema "user_sessions" do
+ belongs_to :user, Elektrine.Accounts.User
+
+ field :auth_method, :string, default: "password"
+ field :device_label, :string
+ field :browser, :string
+ field :platform, :string
+ field :ip_address, :string
+ field :user_agent, :string
+ field :remembered, :boolean, default: false
+ field :last_seen_at, :utc_datetime
+ field :revoked_at, :utc_datetime
+ field :revoked_reason, :string
+
+ timestamps(type: :utc_datetime)
+ end
+
+ def changeset(session, attrs) do
+ session
+ |> cast(attrs, [
+ :user_id,
+ :auth_method,
+ :device_label,
+ :browser,
+ :platform,
+ :ip_address,
+ :user_agent,
+ :remembered,
+ :last_seen_at,
+ :revoked_at,
+ :revoked_reason
+ ])
+ |> put_default_last_seen()
+ |> normalize_device_metadata()
+ |> validate_required([:user_id, :auth_method, :last_seen_at])
+ |> validate_length(:auth_method, max: 40)
+ |> validate_length(:device_label, max: 120)
+ |> validate_length(:browser, max: 80)
+ |> validate_length(:platform, max: 80)
+ |> validate_length(:ip_address, max: 120)
+ |> validate_length(:revoked_reason, max: 120)
+ |> foreign_key_constraint(:user_id)
+ end
+
+ def revoke_changeset(session, reason \\ "revoked") do
+ session
+ |> cast(%{revoked_at: now(), revoked_reason: reason}, [:revoked_at, :revoked_reason])
+ |> validate_required([:revoked_at])
+ |> validate_length(:revoked_reason, max: 120)
+ end
+
+ defp put_default_last_seen(changeset) do
+ case get_field(changeset, :last_seen_at) do
+ nil -> put_change(changeset, :last_seen_at, now())
+ _value -> changeset
+ end
+ end
+
+ defp normalize_device_metadata(changeset) do
+ user_agent = get_field(changeset, :user_agent)
+ browser = get_field(changeset, :browser) || browser_from_user_agent(user_agent)
+ platform = get_field(changeset, :platform) || platform_from_user_agent(user_agent)
+ label = get_field(changeset, :device_label) || device_label(browser, platform)
+
+ changeset
+ |> put_change(:browser, browser)
+ |> put_change(:platform, platform)
+ |> put_change(:device_label, label)
+ end
+
+ defp browser_from_user_agent(user_agent) when is_binary(user_agent) do
+ cond do
+ String.contains?(user_agent, "Edg/") -> "Edge"
+ String.contains?(user_agent, "Firefox/") -> "Firefox"
+ String.contains?(user_agent, "Chrome/") -> "Chrome"
+ String.contains?(user_agent, "Safari/") -> "Safari"
+ true -> "Browser"
+ end
+ end
+
+ defp browser_from_user_agent(_), do: "Browser"
+
+ defp platform_from_user_agent(user_agent) when is_binary(user_agent) do
+ cond do
+ String.contains?(user_agent, "Windows") -> "Windows"
+ String.contains?(user_agent, "Mac OS X") -> "macOS"
+ String.contains?(user_agent, "Android") -> "Android"
+ String.contains?(user_agent, "iPhone") or String.contains?(user_agent, "iPad") -> "iOS"
+ String.contains?(user_agent, "Linux") -> "Linux"
+ true -> "Unknown device"
+ end
+ end
+
+ defp platform_from_user_agent(_), do: "Unknown device"
+
+ defp device_label(browser, platform) do
+ [browser, platform]
+ |> Enum.reject(&is_nil/1)
+ |> Enum.join(" on ")
+ end
+
+ defp now, do: DateTime.utc_now() |> DateTime.truncate(:second)
+end
diff --git a/apps/elektrine/lib/elektrine/accounts/username_history.ex b/apps/elektrine/lib/elektrine/accounts/username_history.ex
new file mode 100644
index 0000000..df78ad7
--- /dev/null
+++ b/apps/elektrine/lib/elektrine/accounts/username_history.ex
@@ -0,0 +1,24 @@
+defmodule Elektrine.Accounts.UsernameHistory do
+ @moduledoc false
+ use Ecto.Schema
+ import Ecto.Changeset
+
+ schema "username_history" do
+ field :username, :string
+ field :previous_username, :string
+ field :changed_at, :utc_datetime
+
+ belongs_to :user, Elektrine.Accounts.User
+
+ timestamps(type: :utc_datetime)
+ end
+
+ def changeset(username_history, attrs) do
+ username_history
+ |> cast(attrs, [:username, :previous_username, :changed_at, :user_id])
+ |> validate_required([:username, :user_id, :changed_at])
+ |> validate_length(:username, min: 1, max: 30)
+ |> validate_length(:previous_username, max: 30)
+ |> foreign_key_constraint(:user_id)
+ end
+end
diff --git a/apps/elektrine/lib/elektrine/acme/wildcard_renewal_worker.ex b/apps/elektrine/lib/elektrine/acme/wildcard_renewal_worker.ex
new file mode 100644
index 0000000..df9e242
--- /dev/null
+++ b/apps/elektrine/lib/elektrine/acme/wildcard_renewal_worker.ex
@@ -0,0 +1,148 @@
+defmodule Elektrine.ACME.WildcardRenewalWorker do
+ @moduledoc """
+ Runs wildcard certificate renewal through acme.sh on an Oban schedule.
+
+ The worker intentionally delegates ACME protocol handling to acme.sh. Elektrine
+ provides the DNS-01 hook and scheduling; acme.sh keeps renewal state and only
+ renews certificates that are near expiry.
+ """
+
+ use Oban.Worker, queue: :default, max_attempts: 1
+
+ require Logger
+
+ @default_timeout_ms :timer.minutes(15)
+
+ @impl Oban.Worker
+ def perform(%Oban.Job{}) do
+ if enabled?() do
+ run_renewal()
+ else
+ Logger.debug("Wildcard ACME renewal skipped: disabled")
+ :ok
+ end
+ end
+
+ def enqueue do
+ %{}
+ |> new()
+ |> Elektrine.JobQueue.insert()
+ end
+
+ defp enabled? do
+ System.get_env("ACME_WILDCARD_RENEWAL_ENABLED") in ["1", "true", "TRUE", "yes", "YES"]
+ end
+
+ defp run_renewal do
+ with :ok <- bootstrap_if_needed() do
+ run_command(acme_sh(), ["--cron", "--home", acme_home()], "renewal")
+ end
+ end
+
+ defp bootstrap_if_needed do
+ acme_sh = acme_sh()
+
+ case validate_executable_path(acme_sh) do
+ :ok ->
+ if File.exists?(acme_sh) do
+ :ok
+ else
+ run_command(default_issue_executable(), [], "bootstrap")
+ end
+
+ {:error, reason} ->
+ Logger.error("Wildcard ACME bootstrap failed: #{reason}")
+ {:error, :invalid_executable}
+ end
+ end
+
+ defp run_command(executable, args, action) when is_binary(executable) and is_list(args) do
+ timeout = timeout_ms()
+
+ Logger.info("Running wildcard ACME #{action}")
+
+ case validate_executable_path(executable) do
+ :ok ->
+ case run_system_command(executable, args, timeout) do
+ {output, 0} ->
+ log_output(:info, "Wildcard ACME #{action} completed", output)
+ :ok
+
+ {output, status} ->
+ log_output(:error, "Wildcard ACME #{action} failed with status #{status}", output)
+ {:error, :acme_failed}
+
+ :timeout ->
+ Logger.error("Wildcard ACME #{action} timed out")
+ {:error, :acme_timeout}
+ end
+
+ {:error, reason} ->
+ Logger.error("Wildcard ACME #{action} failed: #{reason}")
+ {:error, :invalid_executable}
+ end
+ rescue
+ error ->
+ Logger.error("Wildcard ACME #{action} crashed: #{Exception.message(error)}")
+ {:error, :acme_crashed}
+ end
+
+ defp run_command(_executable, _args, action) do
+ Logger.error("Wildcard ACME #{action} failed: invalid executable")
+ {:error, :invalid_executable}
+ end
+
+ defp acme_sh do
+ System.get_env("ACME_SH_BIN") || Path.join(acme_home(), "acme.sh")
+ end
+
+ defp default_issue_executable do
+ "/app/scripts/acme/issue_elektrine_wildcard_cert.sh"
+ end
+
+ defp acme_home do
+ System.get_env("ACME_HOME") || "/data/acme.sh"
+ end
+
+ defp timeout_ms do
+ case Integer.parse(System.get_env("ACME_RENEW_TIMEOUT_MS") || "") do
+ {value, ""} when value > 0 -> value
+ _ -> @default_timeout_ms
+ end
+ end
+
+ defp validate_executable_path(executable) do
+ cond do
+ String.contains?(executable, "\0") ->
+ {:error, "executable path contains NUL"}
+
+ Path.type(executable) != :absolute ->
+ {:error, "executable path must be absolute"}
+
+ true ->
+ :ok
+ end
+ end
+
+ defp run_system_command(executable, args, timeout) do
+ task =
+ Task.async(fn ->
+ System.cmd(executable, args, stderr_to_stdout: true)
+ end)
+
+ case Task.yield(task, timeout) || Task.shutdown(task, :brutal_kill) do
+ {:ok, result} -> result
+ nil -> :timeout
+ end
+ end
+
+ defp log_output(level, message, output) do
+ output = String.trim(to_string(output))
+
+ if output == "" do
+ Logger.log(level, message)
+ else
+ Logger.log(level, "#{message}: #{output}")
+ end
+ end
+end
diff --git a/apps/elektrine/lib/elektrine/activitypub.ex b/apps/elektrine/lib/elektrine/activitypub.ex
new file mode 100644
index 0000000..e27cba2
--- /dev/null
+++ b/apps/elektrine/lib/elektrine/activitypub.ex
@@ -0,0 +1,2243 @@
+defmodule Elektrine.ActivityPub do
+ @moduledoc """
+ The ActivityPub context for federation.
+ Handles actors, activities, and federation with other instances.
+ """
+
+ import Ecto.Query, warn: false
+ require Logger
+ alias Elektrine.Domains
+ alias Elektrine.Repo
+
+ alias Elektrine.ActivityPub.{
+ Activity,
+ ActivityDeliveryWorker,
+ Actor,
+ CollectionFetcher,
+ Delivery,
+ DomainDeliveryHealth,
+ Fetcher,
+ HTTPSignature,
+ Instance,
+ KeyManager,
+ LemmyApi,
+ LocalReferences,
+ MastodonApi,
+ MRF,
+ ObjectDeliveries,
+ RelaySubscription,
+ RemoteFetch,
+ ReplyFetchPolicy,
+ RequestReplayCache,
+ Tombstones,
+ UserBlock
+ }
+
+ alias Elektrine.Accounts.User
+ alias Elektrine.Async
+ alias Elektrine.HTTP.SafeFetch
+ alias Elektrine.Security.URLValidator
+ alias Elektrine.Social.Conversation
+ alias Elektrine.Social.Conversations
+ alias Elektrine.Telemetry.Events
+ @public_audience_uri "https://www.w3.org/ns/activitystreams#Public"
+
+ @doc """
+ Fetches a single remote ActivityPub object for detail-page refresh paths.
+
+ UI layers should call this context boundary instead of invoking fetch modules
+ directly.
+ """
+ def fetch_remote_object_strict(uri) when is_binary(uri) do
+ RemoteFetch.fetch_object_strict(uri)
+ end
+
+ def fetch_remote_object_strict(_), do: {:error, :invalid_activitypub_id}
+
+ @doc """
+ Fetches a remote ActivityPub object for interactive UI paths, allowing recovery
+ through compatible instance APIs when the canonical object URL is not directly
+ fetchable.
+ """
+ def fetch_remote_object(uri) when is_binary(uri) do
+ RemoteFetch.fetch_object_uncached(uri)
+ end
+
+ def fetch_remote_object(_), do: {:error, :invalid_activitypub_id}
+
+ @doc """
+ Resolves a WebFinger handle through the federation fetch boundary.
+ """
+ def webfinger_lookup(acct, opts \\ []) do
+ RemoteFetch.webfinger_lookup(acct, opts)
+ end
+
+ @doc """
+ Gets the instance domain for this server.
+ """
+ def instance_domain do
+ Elektrine.Domains.instance_domain()
+ end
+
+ @doc """
+ Gets the instance base URL.
+ """
+ def instance_url do
+ instance_url_for_domain(instance_domain())
+ end
+
+ @doc """
+ Gets an instance-style base URL for a specific domain.
+ """
+ def instance_url_for_domain(domain) when is_binary(domain) do
+ Domains.inferred_base_url_for_domain(domain)
+ end
+
+ @doc """
+ Returns the canonical local ActivityPub identifier for a user.
+ """
+ def actor_identifier(%User{handle: handle, username: username}) do
+ if Elektrine.Strings.present?(handle), do: handle, else: username
+ end
+
+ def actor_identifier(%{handle: handle, username: username}) do
+ if Elektrine.Strings.present?(handle), do: handle, else: username
+ end
+
+ def actor_identifier(identifier) when is_binary(identifier) do
+ identifier
+ |> String.trim()
+ |> String.trim_leading("@")
+ |> String.downcase()
+ end
+
+ def actor_identifier(_), do: nil
+
+ @doc """
+ Returns all local actor identifiers that should resolve for a user.
+
+ The handle is canonical. The username remains as a compatibility alias.
+ """
+ def actor_identifiers(%User{} = user) do
+ [actor_identifier(user), user.username]
+ |> Enum.filter(&Elektrine.Strings.present?/1)
+ |> Enum.uniq()
+ end
+
+ @doc """
+ Returns the local ActivityPub actor URI for a user or explicit identifier.
+ """
+ def actor_uri(user_or_identifier, base_url \\ instance_url())
+
+ def actor_uri(%User{} = user, base_url) do
+ actor_uri(actor_identifier(user), base_url)
+ end
+
+ def actor_uri(identifier, base_url) when is_binary(identifier) and is_binary(base_url) do
+ "#{String.trim_trailing(base_url, "/")}/users/#{actor_identifier(identifier)}"
+ end
+
+ def actor_uri(_, _), do: nil
+
+ @doc """
+ Returns the legacy username-based actor URI for a local user.
+ """
+ def actor_uri_by_username(user_or_identifier, base_url \\ instance_url())
+
+ def actor_uri_by_username(%User{username: username}, base_url) when is_binary(username) do
+ actor_uri(username, base_url)
+ end
+
+ def actor_uri_by_username(_, _), do: nil
+
+ @doc """
+ Returns the local ActivityPub key id for a user or explicit identifier.
+ """
+ def actor_key_id(user_or_identifier, base_url \\ instance_url())
+
+ def actor_key_id(%User{} = user, base_url) do
+ "#{actor_uri(user, base_url)}#main-key"
+ end
+
+ def actor_key_id(identifier, base_url) when is_binary(identifier) and is_binary(base_url) do
+ "#{actor_uri(identifier, base_url)}#main-key"
+ end
+
+ def actor_key_id(_, _), do: nil
+
+ @doc """
+ Returns a local actor collection URI for a user.
+ """
+ def user_collection_uri(user_or_identifier, collection, base_url \\ instance_url())
+
+ def user_collection_uri(%User{} = user, collection, base_url)
+ when collection in ["inbox", "outbox", "followers", "following"] do
+ "#{actor_uri(user, base_url)}/#{collection}"
+ end
+
+ def user_collection_uri(identifier, collection, base_url)
+ when is_binary(identifier) and collection in ["inbox", "outbox", "followers", "following"] do
+ "#{actor_uri(identifier, base_url)}/#{collection}"
+ end
+
+ def user_collection_uri(%User{} = user, collection, base_url) when is_atom(collection) do
+ user_collection_uri(user, Atom.to_string(collection), base_url)
+ end
+
+ def user_collection_uri(identifier, collection, base_url)
+ when is_binary(identifier) and is_atom(collection) do
+ user_collection_uri(identifier, Atom.to_string(collection), base_url)
+ end
+
+ def user_collection_uri(_, _, _), do: nil
+
+ @doc """
+ Returns the local ActivityPub object URI for a user's status.
+ """
+ def user_status_uri(user_or_identifier, message_id, base_url \\ instance_url())
+
+ def user_status_uri(%User{} = user, message_id, base_url) do
+ "#{actor_uri(user, base_url)}/statuses/#{message_id}"
+ end
+
+ def user_status_uri(identifier, message_id, base_url)
+ when is_binary(identifier) and is_binary(base_url) do
+ "#{actor_uri(identifier, base_url)}/statuses/#{message_id}"
+ end
+
+ @doc """
+ Returns the canonical ActivityPub slug for a local community.
+ """
+ def community_slug(name) when is_binary(name) do
+ name
+ |> String.trim()
+ |> String.downcase()
+ |> String.replace(~r/[^a-z0-9]+/, "-")
+ |> String.trim("-")
+ end
+
+ @doc """
+ Returns the local ActivityPub actor URI for a community.
+ """
+ def community_actor_uri(name, base_url \\ instance_url()) when is_binary(name) do
+ "#{String.trim_trailing(base_url, "/")}/c/#{community_slug(name)}"
+ end
+
+ @doc """
+ Returns the inbox URI for a local community actor.
+ """
+ def community_inbox_uri(name, base_url \\ instance_url()) when is_binary(name) do
+ "#{community_actor_uri(name, base_url)}/inbox"
+ end
+
+ @doc """
+ Returns the outbox URI for a local community actor.
+ """
+ def community_outbox_uri(name, base_url \\ instance_url()) when is_binary(name) do
+ "#{community_actor_uri(name, base_url)}/outbox"
+ end
+
+ @doc """
+ Returns the followers collection URI for a local community actor.
+ """
+ def community_followers_uri(name, base_url \\ instance_url()) when is_binary(name) do
+ "#{community_actor_uri(name, base_url)}/followers"
+ end
+
+ @doc """
+ Returns the moderators collection URI for a local community actor.
+ """
+ def community_moderators_uri(name, base_url \\ instance_url()) when is_binary(name) do
+ "#{community_actor_uri(name, base_url)}/moderators"
+ end
+
+ @doc """
+ Returns the ActivityPub object URI for a local community post.
+ """
+ def community_post_uri(name, message_id, base_url \\ instance_url()) when is_binary(name) do
+ "#{community_actor_uri(name, base_url)}/posts/#{message_id}"
+ end
+
+ @doc """
+ Returns the HTML community page URL.
+ """
+ def community_web_url(name, base_url \\ instance_url()) when is_binary(name) do
+ "#{String.trim_trailing(base_url, "/")}/communities/#{encode_path_segment(name)}"
+ end
+
+ @doc """
+ Returns the HTML discussion post URL for a local community post.
+ """
+ def community_post_web_url(name, message_id, base_url \\ instance_url())
+ when is_binary(name) do
+ "#{community_web_url(name, base_url)}/post/#{message_id}"
+ end
+
+ @doc """
+ Resolves a local community from either its stored name or its ActivityPub slug.
+ """
+ def get_community_by_identifier(identifier) when is_binary(identifier) do
+ normalized_identifier =
+ identifier
+ |> String.trim()
+ |> String.downcase()
+
+ slug = community_slug(identifier)
+
+ from(c in Conversation,
+ where: c.type == "community",
+ where:
+ fragment("LOWER(?)", c.name) == ^normalized_identifier or
+ fragment("regexp_replace(lower(?), '[^a-z0-9]+', '-', 'g')", c.name) == ^slug,
+ order_by: [
+ desc: fragment("LOWER(?) = ?", c.name, ^normalized_identifier)
+ ],
+ limit: 1,
+ preload: [:creator]
+ )
+ |> Repo.one()
+ end
+
+ def get_community_by_identifier(_), do: nil
+
+ defp encode_path_segment(segment) when is_binary(segment) do
+ URI.encode(segment, &URI.char_unreserved?/1)
+ end
+
+ @doc """
+ Returns local actor URI prefixes used to detect local `/users/:identifier` actor URLs.
+ """
+ def local_actor_prefixes do
+ LocalReferences.actor_prefixes()
+ end
+
+ @doc """
+ Resolves a local actor/profile URI to the owning user's username.
+
+ Canonical actor URLs use handles, but legacy username-based URLs still resolve.
+ """
+ def local_username_from_uri(uri) when is_binary(uri) do
+ LocalReferences.local_username_from_uri(uri)
+ end
+
+ def local_username_from_uri(uri), do: LocalReferences.local_username_from_uri(uri)
+
+ @doc """
+ Best-effort resolution of the local user targeted by an incoming activity.
+
+ This is used for shared inbox processing so per-user moderation checks
+ still apply even when the request did not hit `/users/:username/inbox`.
+ """
+ def resolve_target_user(activity) when is_map(activity) do
+ LocalReferences.resolve_target_user(activity)
+ end
+
+ def resolve_target_user(activity), do: LocalReferences.resolve_target_user(activity)
+
+ @doc """
+ Best-effort resolution of the local user id targeted by an incoming activity.
+ """
+ def resolve_target_user_id(activity) when is_map(activity) do
+ LocalReferences.resolve_target_user_id(activity)
+ end
+
+ def resolve_target_user_id(activity), do: LocalReferences.resolve_target_user_id(activity)
+
+ ## Actors
+
+ @doc """
+ Gets or creates a remote actor by their URI.
+ Fetches the actor document if not cached or stale.
+ Uses in-memory caching to reduce database load during high traffic.
+ """
+ def get_or_fetch_actor(uri) do
+ case normalize_actor_uri_input(uri) do
+ normalized_uri when is_binary(normalized_uri) ->
+ Elektrine.AppCache.get_actor(normalized_uri, fn ->
+ do_get_or_fetch_actor(normalized_uri)
+ end)
+
+ _ ->
+ {:error, :invalid_actor_uri}
+ end
+ end
+
+ defp normalize_actor_uri_input(uri) when is_binary(uri) do
+ case String.trim(uri) do
+ "" -> nil
+ normalized_uri -> normalized_uri
+ end
+ end
+
+ defp normalize_actor_uri_input(values) when is_list(values) do
+ Enum.find_value(values, &normalize_actor_uri_input/1)
+ end
+
+ defp normalize_actor_uri_input(value) when is_map(value) do
+ value
+ |> Map.take(["id", "url", "href", :id, :url, :href])
+ |> Map.values()
+ |> Enum.find_value(&normalize_actor_uri_input/1)
+ end
+
+ defp normalize_actor_uri_input(_), do: nil
+
+ defp do_get_or_fetch_actor(uri) do
+ case get_actor_by_uri(uri) do
+ nil ->
+ fetch_and_cache_actor(uri)
+
+ %Actor{last_fetched_at: nil} = actor ->
+ # Never fetched, refresh
+ fetch_and_cache_actor(uri, actor)
+
+ %Actor{last_fetched_at: last_fetched} = actor ->
+ # Refresh if older than 24 hours
+ if DateTime.diff(DateTime.utc_now(), last_fetched, :hour) > 24 do
+ fetch_and_cache_actor(uri, actor)
+ else
+ {:ok, actor}
+ end
+ end
+ end
+
+ @doc """
+ Gets an actor by their ActivityPub URI.
+
+ Returns the oldest actor if duplicates exist, so duplicate rows do not crash
+ callers while the bad data is being cleaned up.
+ """
+ def get_actor_by_uri(uri) do
+ from(a in Actor,
+ where: a.uri == ^uri,
+ order_by: [asc: a.inserted_at, asc: a.id],
+ limit: 1
+ )
+ |> Repo.one()
+ end
+
+ @doc """
+ Gets a remote actor by their database ID.
+ """
+ def get_remote_actor(id) do
+ Repo.get(Actor, id)
+ end
+
+ @doc """
+ Gets an actor by username and domain.
+ Returns the oldest actor if duplicates exist (to be cleaned up by migration).
+ """
+ def get_actor_by_username_and_domain(username, domain) do
+ import Ecto.Query
+
+ from(a in Actor,
+ where: a.username == ^username and a.domain == ^domain,
+ order_by: [asc: a.inserted_at],
+ limit: 1
+ )
+ |> Repo.one()
+ end
+
+ @doc """
+ Fetches an actor document from a remote instance and caches it.
+ """
+ def fetch_and_cache_actor(uri, existing_actor_or_opts \\ nil)
+
+ def fetch_and_cache_actor(uri, opts) when is_list(opts) do
+ do_fetch_and_cache_actor(uri, nil, opts)
+ end
+
+ def fetch_and_cache_actor(uri, existing_actor) do
+ do_fetch_and_cache_actor(uri, existing_actor, [])
+ end
+
+ defp do_fetch_and_cache_actor(uri, existing_actor, opts) do
+ with {:ok, actor_data} <- RemoteFetch.fetch_actor(uri, opts),
+ :ok <- validate_fetched_actor_identity(uri, actor_data),
+ :ok <- validate_fetched_actor_urls(actor_data),
+ {:ok, actor_data} <- apply_actor_policies(actor_data, uri),
+ {:ok, actor} <- cache_actor(actor_data, existing_actor) do
+ {:ok, actor}
+ else
+ {:reject, reason} ->
+ Logger.info("MRF rejected actor document #{uri}: #{reason}")
+ {:error, :mrf_rejected}
+
+ {:error, reason} ->
+ {:error, reason}
+ end
+ end
+
+ defp apply_actor_policies(actor_data, _uri) when is_map(actor_data) do
+ case MRF.filter(actor_data) do
+ {:ok, filtered_actor_data} -> {:ok, filtered_actor_data}
+ {:reject, reason} -> {:reject, reason}
+ end
+ end
+
+ defp apply_actor_policies(actor_data, _uri), do: {:ok, actor_data}
+
+ defp validate_fetched_actor_identity(uri, %{"id" => actor_id} = actor_data)
+ when is_binary(uri) and is_binary(actor_id) and is_map(actor_data) do
+ if actor_identity_matches?(uri, actor_id, actor_data) do
+ :ok
+ else
+ Logger.warning(
+ "Rejected fetched actor due to id mismatch: requested=#{inspect(uri)} returned=#{inspect(actor_id)}"
+ )
+
+ {:error, :actor_id_mismatch}
+ end
+ end
+
+ defp validate_fetched_actor_identity(_uri, _actor_data), do: {:error, :actor_id_mismatch}
+
+ defp actor_identity_matches?(requested_uri, actor_id, actor_data) when is_map(actor_data) do
+ comparable_actor_uri(requested_uri) == comparable_actor_uri(actor_id) ||
+ actor_username_alias_match?(requested_uri, actor_id, actor_data)
+ end
+
+ defp actor_username_alias_match?(requested_uri, actor_id, actor_data) do
+ with %URI{host: requested_host} <- URI.parse(requested_uri),
+ %URI{host: actor_host} <- URI.parse(actor_id),
+ true <- is_binary(requested_host) and is_binary(actor_host),
+ true <- String.downcase(requested_host) == String.downcase(actor_host),
+ requested_username when is_binary(requested_username) <-
+ actor_uri_username(requested_uri),
+ fetched_username when is_binary(fetched_username) <- fetched_actor_username(actor_data) do
+ String.downcase(requested_username) == String.downcase(fetched_username)
+ else
+ _ -> false
+ end
+ end
+
+ defp actor_uri_username(uri) when is_binary(uri) do
+ case URI.parse(uri) do
+ %URI{path: "/users/" <> username} -> username |> String.split("/", parts: 2) |> List.first()
+ %URI{path: "/@" <> username} -> username |> String.split("/", parts: 2) |> List.first()
+ _ -> nil
+ end
+ end
+
+ defp actor_uri_username(_), do: nil
+
+ defp fetched_actor_username(actor_data) when is_map(actor_data) do
+ actor_data["preferredUsername"] ||
+ actor_data["preferred_username"] ||
+ actor_data["name"]
+ end
+
+ defp fetched_actor_username(_), do: nil
+
+ defp validate_fetched_actor_urls(actor_data) when is_map(actor_data) do
+ actor_data
+ |> actor_document_urls()
+ |> Enum.reduce_while(:ok, fn {field, url}, :ok ->
+ case URLValidator.validate(url) do
+ :ok ->
+ {:cont, :ok}
+
+ {:error, reason} ->
+ Logger.warning(
+ "Rejected fetched actor due to unsafe #{field} URL #{inspect(url)}: #{inspect(reason)}"
+ )
+
+ {:halt, {:error, :unsafe_actor_document}}
+ end
+ end)
+ end
+
+ defp actor_document_urls(actor_data) do
+ [
+ {:id, actor_data["id"]},
+ {:inbox, actor_data["inbox"]},
+ {:outbox, actor_data["outbox"]},
+ {:followers, actor_data["followers"]},
+ {:following, actor_data["following"]},
+ {:shared_inbox, get_in(actor_data, ["endpoints", "sharedInbox"])}
+ ]
+ |> Enum.filter(fn {_field, url} -> Elektrine.Strings.present?(url) end)
+ end
+
+ defp comparable_actor_uri(uri) when is_binary(uri) do
+ uri
+ |> String.trim()
+ |> case do
+ "" ->
+ nil
+
+ trimmed ->
+ case URI.parse(trimmed) do
+ %URI{scheme: scheme, host: host} = parsed
+ when is_binary(scheme) and is_binary(host) and host != "" ->
+ normalized_path =
+ parsed.path
+ |> Kernel.||("/")
+ |> case do
+ "/" -> "/"
+ path -> String.trim_trailing(path, "/")
+ end
+
+ parsed
+ |> Map.put(:scheme, String.downcase(scheme))
+ |> Map.put(:host, String.downcase(host))
+ |> Map.put(:path, normalized_path)
+ |> Map.put(:fragment, nil)
+ |> URI.to_string()
+
+ _ ->
+ trimmed
+ end
+ end
+ end
+
+ defp comparable_actor_uri(_), do: nil
+
+ defp cache_actor(actor_data, existing_actor) do
+ uri = URI.parse(actor_data["id"])
+ domain = uri.host
+
+ # Ensure instance record exists
+ get_or_create_instance(domain)
+
+ # Extract and cache custom emojis from actor profile
+ Async.start(fn ->
+ Elektrine.Emojis.process_activitypub_tags(actor_data["tag"], domain)
+ end)
+
+ # Extract username from preferredUsername or id
+ username =
+ actor_data["preferredUsername"] ||
+ extract_username_from_uri(actor_data["id"])
+
+ # Parse the published date from the actor data
+ published_at =
+ case actor_data["published"] do
+ nil ->
+ nil
+
+ date_string ->
+ case DateTime.from_iso8601(date_string) do
+ {:ok, datetime, _} -> DateTime.truncate(datetime, :second)
+ _ -> nil
+ end
+ end
+
+ attrs = %{
+ uri: actor_data["id"],
+ username: username,
+ domain: domain,
+ display_name: actor_data["name"],
+ summary: actor_data["summary"],
+ avatar_url: get_icon_url(actor_data),
+ header_url: get_image_url(actor_data),
+ inbox_url: actor_data["inbox"],
+ outbox_url: actor_data["outbox"],
+ followers_url: actor_data["followers"],
+ following_url: actor_data["following"],
+ public_key: extract_public_key(actor_data),
+ manually_approves_followers: actor_data["manuallyApprovesFollowers"] || false,
+ actor_type: actor_data["type"] || "Person",
+ last_fetched_at: DateTime.utc_now(),
+ published_at: published_at,
+ moderators_url: actor_data["moderators"],
+ metadata: actor_data
+ }
+
+ actor = existing_actor || get_actor_by_username_and_domain(attrs.username, attrs.domain)
+
+ upsert_cached_actor(actor, attrs)
+ end
+
+ defp upsert_cached_actor(%Actor{} = existing_actor, attrs) do
+ attrs = merge_existing_actor_metadata(existing_actor, attrs)
+
+ existing_actor
+ |> Actor.changeset(attrs)
+ |> Repo.update()
+ end
+
+ defp upsert_cached_actor(nil, attrs) do
+ insert_opts = [
+ on_conflict: {:replace_all_except, [:id, :inserted_at]},
+ conflict_target: :uri,
+ returning: true
+ ]
+
+ case %Actor{} |> Actor.changeset(attrs) |> Repo.insert(insert_opts) do
+ {:ok, actor} ->
+ {:ok, actor}
+
+ {:error, %Ecto.Changeset{} = changeset} ->
+ recover_actor_conflict(attrs, changeset)
+ end
+ end
+
+ # During federation bursts, multiple requests can race to create the same actor
+ # (or a canonicalized variant such as trailing-slash URI differences).
+ # Recover by reloading and refreshing the existing row instead of failing.
+ defp recover_actor_conflict(attrs, original_changeset) do
+ actor =
+ get_actor_by_uri(attrs.uri) ||
+ get_actor_by_username_and_domain(attrs.username, attrs.domain)
+
+ case actor do
+ nil ->
+ {:error, original_changeset}
+
+ %Actor{} = existing_actor ->
+ attrs = merge_existing_actor_metadata(existing_actor, attrs)
+
+ case existing_actor |> Actor.changeset(attrs) |> Repo.update() do
+ {:ok, updated_actor} ->
+ {:ok, updated_actor}
+
+ {:error, _changeset} ->
+ attrs_without_identity = Map.drop(attrs, [:uri, :username, :domain])
+
+ case existing_actor
+ |> Actor.changeset(attrs_without_identity)
+ |> Repo.update() do
+ {:ok, updated_actor} -> {:ok, updated_actor}
+ {:error, _} -> {:ok, existing_actor}
+ end
+ end
+ end
+ end
+
+ defp merge_existing_actor_metadata(%Actor{} = existing_actor, attrs) when is_map(attrs) do
+ existing_metadata = normalize_actor_metadata(existing_actor.metadata)
+ incoming_metadata = normalize_actor_metadata(Map.get(attrs, :metadata))
+
+ Map.put(attrs, :metadata, Map.merge(existing_metadata, incoming_metadata))
+ end
+
+ defp merge_existing_actor_metadata(_, attrs), do: attrs
+
+ defp normalize_actor_metadata(metadata) when is_map(metadata) do
+ Map.new(metadata, fn {key, value} -> {to_string(key), value} end)
+ end
+
+ defp normalize_actor_metadata(_), do: %{}
+
+ defp extract_username_from_uri(uri) do
+ # Extract username from URIs like https://mastodon.social/users/alice
+ uri
+ |> String.split("/")
+ |> List.last()
+ end
+
+ defp get_icon_url(%{"icon" => %{"url" => url}}), do: url
+ defp get_icon_url(%{"icon" => url}) when is_binary(url), do: url
+ defp get_icon_url(_), do: nil
+
+ defp get_image_url(%{"image" => %{"url" => url}}), do: url
+ defp get_image_url(%{"image" => url}) when is_binary(url), do: url
+ defp get_image_url(_), do: nil
+
+ defp extract_public_key(%{"publicKey" => %{"publicKeyPem" => pem}}), do: pem
+ defp extract_public_key(_), do: nil
+
+ ## Activities
+
+ @doc """
+ Creates an activity.
+ """
+ def create_activity(attrs) do
+ %Activity{}
+ |> Activity.changeset(attrs)
+ |> Repo.insert()
+ end
+
+ @doc """
+ Gets an activity by its ActivityPub ID.
+ """
+ def get_activity_by_id(activity_id) do
+ Repo.get_by(Activity, activity_id: activity_id)
+ end
+
+ defdelegate record_remote_delete_receipt(activity, actor_uri, object_id), to: Tombstones
+ defdelegate record_remote_tombstone(activity, actor_uri, object_id), to: Tombstones
+ defdelegate remote_delete_recorded?(actor_uri, object_refs), to: Tombstones
+ defdelegate remote_tombstone_recorded?(actor_uri, object_refs), to: Tombstones
+
+ @doc """
+ Gets the most recent local activity for a user, type, and object.
+ """
+ def get_latest_local_activity(user_id, activity_type, object_id, opts \\ [])
+
+ def get_latest_local_activity(_user_id, _activity_type, object_id, _opts)
+ when not is_binary(object_id) do
+ nil
+ end
+
+ def get_latest_local_activity(user_id, activity_type, object_id, opts) do
+ content = Keyword.get(opts, :content)
+
+ Activity
+ |> where(
+ [a],
+ a.internal_user_id == ^user_id and a.local == true and a.activity_type == ^activity_type and
+ a.object_id == ^object_id
+ )
+ |> maybe_filter_activity_content(content)
+ |> order_by([a], desc: a.inserted_at, desc: a.id)
+ |> limit(1)
+ |> Repo.one()
+ end
+
+ @doc """
+ Lists activities for a user's outbox.
+ """
+ def list_outbox_activities(user_id, opts \\ []) do
+ limit = Keyword.get(opts, :limit, 20)
+ offset = Keyword.get(opts, :offset, 0)
+
+ Activity
+ |> where([a], a.internal_user_id == ^user_id and a.local == true)
+ |> where([a], a.activity_type in ["Create", "Announce"])
+ |> where_public_outbox_activity()
+ |> order_by([a], desc: a.inserted_at)
+ |> limit(^limit)
+ |> offset(^offset)
+ |> Repo.all()
+ end
+
+ @doc """
+ Counts local outbox activities for a user.
+ """
+ def count_outbox_activities(user_id) do
+ Activity
+ |> where([a], a.internal_user_id == ^user_id and a.local == true)
+ |> where([a], a.activity_type in ["Create", "Announce"])
+ |> where_public_outbox_activity()
+ |> Repo.aggregate(:count, :id)
+ end
+
+ defp maybe_filter_activity_content(query, nil), do: query
+
+ defp maybe_filter_activity_content(query, content) when is_binary(content) do
+ where(query, [a], fragment("?->>'content' = ?", a.data, ^content))
+ end
+
+ defp where_public_outbox_activity(query) do
+ where(
+ query,
+ [a],
+ fragment(
+ """
+ jsonb_exists(COALESCE(?->'to', '[]'::jsonb), ?) OR
+ jsonb_exists(COALESCE(?->'cc', '[]'::jsonb), ?) OR
+ jsonb_exists(COALESCE(?->'object'->'to', '[]'::jsonb), ?) OR
+ jsonb_exists(COALESCE(?->'object'->'cc', '[]'::jsonb), ?)
+ """,
+ a.data,
+ ^@public_audience_uri,
+ a.data,
+ ^@public_audience_uri,
+ a.data,
+ ^@public_audience_uri,
+ a.data,
+ ^@public_audience_uri
+ )
+ )
+ end
+
+ ## Deliveries
+
+ @doc """
+ Creates delivery records for an activity to be sent to remote inboxes.
+ Also enqueues Oban jobs for processing.
+ """
+ def create_deliveries(activity_id, inbox_urls) do
+ now = NaiveDateTime.utc_now() |> NaiveDateTime.truncate(:second)
+ activity = Repo.get(Activity, activity_id)
+ object_id = activity && (activity.object_id || object_id_from_activity_data(activity.data))
+
+ record_object_deliveries(object_id, activity_id, inbox_urls)
+
+ deliveries =
+ inbox_urls
+ |> deliverable_inboxes_for_activity(activity)
+ |> Enum.map(fn inbox_url ->
+ %{
+ activity_id: activity_id,
+ inbox_url: inbox_url,
+ status: "pending",
+ attempts: 0,
+ inserted_at: now,
+ updated_at: now
+ }
+ end)
+
+ {count, inserted} = Repo.insert_all(Delivery, deliveries, returning: [:id])
+
+ # Enqueue Oban jobs for the inserted deliveries
+ if count > 0 do
+ delivery_ids = Enum.map(inserted, & &1.id)
+ ActivityDeliveryWorker.enqueue_many(delivery_ids)
+ end
+
+ {count, inserted}
+ end
+
+ defp deliverable_inboxes_for_activity(inbox_urls, %Activity{activity_type: type})
+ when type in ["Delete", "Update"] do
+ Enum.uniq(inbox_urls)
+ end
+
+ defp deliverable_inboxes_for_activity(inbox_urls, _activity) do
+ DomainDeliveryHealth.filter_deliverable_inboxes(inbox_urls)
+ end
+
+ defp object_id_from_activity_data(%{"object" => object}) when is_binary(object), do: object
+ defp object_id_from_activity_data(%{"object" => %{"id" => id}}) when is_binary(id), do: id
+ defp object_id_from_activity_data(_), do: nil
+
+ defdelegate record_object_deliveries(object_id, activity_id, inbox_urls), to: ObjectDeliveries
+ defdelegate get_object_delivery_inboxes(object_id), to: ObjectDeliveries
+
+ @doc """
+ Gets a delivery by ID with its associated activity.
+ """
+ def get_delivery(delivery_id) do
+ Delivery
+ |> where([d], d.id == ^delivery_id)
+ |> preload(:activity)
+ |> Repo.one()
+ end
+
+ @doc """
+ Gets pending deliveries that are ready to be attempted.
+ """
+ def get_pending_deliveries(limit \\ 100) do
+ now = DateTime.utc_now()
+
+ Delivery
+ |> where([d], d.status == "pending")
+ |> where([d], is_nil(d.next_retry_at) or d.next_retry_at <= ^now)
+ |> where([d], d.attempts < 10)
+ |> limit(^limit)
+ |> preload(:activity)
+ |> Repo.all()
+ end
+
+ @doc """
+ Gets delivery IDs that are pending and ready to be retried.
+ """
+ def get_retryable_delivery_ids(limit \\ 500) do
+ started_at = System.monotonic_time(:millisecond)
+ now = DateTime.utc_now()
+
+ retryable_deliveries =
+ Delivery
+ |> where([d], d.status == "pending")
+ |> where([d], is_nil(d.next_retry_at) or d.next_retry_at <= ^now)
+ |> where([d], d.attempts < 10)
+ |> order_by([d], asc: d.updated_at)
+ |> limit(^limit)
+ |> select([d], {d.id, d.inbox_url})
+ |> Repo.all()
+
+ delivery_ids =
+ retryable_deliveries
+ |> Enum.filter(fn {_id, inbox_url} -> DomainDeliveryHealth.deliverable_url?(inbox_url) end)
+ |> Enum.map(fn {id, _inbox_url} -> id end)
+
+ Events.db_hot_path(
+ :activitypub,
+ :get_retryable_delivery_ids,
+ System.monotonic_time(:millisecond) - started_at,
+ %{limit: limit, result_count: length(delivery_ids)}
+ )
+
+ delivery_ids
+ end
+
+ @doc """
+ Marks a delivery as delivered.
+ """
+ def mark_delivery_delivered(delivery_id) do
+ case Repo.get(Delivery, delivery_id) |> Repo.preload(:activity) do
+ nil ->
+ {:error, :not_found}
+
+ delivery ->
+ result =
+ delivery
+ |> Delivery.changeset(%{
+ status: "delivered",
+ last_attempt_at: DateTime.utc_now()
+ })
+ |> Repo.update()
+
+ if match?({:ok, _}, result) do
+ DomainDeliveryHealth.record_delivery_success(delivery.inbox_url)
+ ObjectDeliveries.mark_object_delivery_delivered(delivery)
+ end
+
+ result
+ end
+ end
+
+ @doc """
+ Cleans up old failed deliveries (older than 7 days).
+ Returns count of deleted deliveries.
+ """
+ def cleanup_old_deliveries do
+ seven_days_ago = DateTime.utc_now() |> DateTime.add(-7, :day)
+
+ {count, _} =
+ from(d in Delivery,
+ where: d.status == "failed" and d.inserted_at < ^seven_days_ago
+ )
+ |> Repo.delete_all()
+
+ count + RequestReplayCache.prune_expired()
+ end
+
+ @doc """
+ Marks a delivery as failed and schedules retry.
+ """
+ def mark_delivery_failed(delivery_id, error_message) do
+ case Repo.get(Delivery, delivery_id) do
+ nil ->
+ {:error, :not_found}
+
+ delivery ->
+ DomainDeliveryHealth.record_delivery_failure(delivery.inbox_url, error_message)
+ attempts = delivery.attempts + 1
+
+ # Exponential backoff: 5min, 15min, 1hr, 3hr, 12hr, then 24hr per attempt
+ # until the attempt-10 cutoff below. Attempts 7-9 must keep a real
+ # backoff — leaving next_retry_at nil makes the retry query treat them
+ # as immediately due, firing 7→10 back-to-back with no delay.
+ next_retry_minutes =
+ case attempts do
+ 1 -> 5
+ 2 -> 15
+ 3 -> 60
+ 4 -> 180
+ 5 -> 720
+ _ -> 1440
+ end
+
+ next_retry_at =
+ DateTime.add(DateTime.utc_now(), next_retry_minutes * 60, :second)
+
+ status = if attempts >= 10, do: "failed", else: "pending"
+
+ delivery
+ |> Delivery.changeset(%{
+ status: status,
+ attempts: attempts,
+ last_attempt_at: DateTime.utc_now(),
+ next_retry_at: next_retry_at,
+ error_message: error_message
+ })
+ |> Repo.update()
+ end
+ end
+
+ ## Instances
+
+ @doc """
+ Gets or creates an instance record.
+ """
+ def get_or_create_instance(domain) do
+ domain = normalize_instance_domain(domain)
+
+ case get_instance_by_domain(domain) do
+ nil ->
+ %Instance{}
+ |> Instance.changeset(%{domain: domain})
+ |> Repo.insert()
+ |> case do
+ {:ok, instance} ->
+ {:ok, instance}
+
+ {:error, _changeset} ->
+ case get_instance_by_domain(domain) do
+ %Instance{} = instance -> {:ok, instance}
+ nil -> {:error, :instance_insert_failed}
+ end
+ end
+
+ instance ->
+ {:ok, instance}
+ end
+ end
+
+ @doc """
+ Checks if an instance is blocked.
+ """
+ def instance_blocked?(domain) do
+ case get_instance_by_domain(domain) do
+ %Instance{blocked: true} -> true
+ _ -> false
+ end
+ end
+
+ defp get_instance_by_domain(domain) when is_binary(domain) do
+ normalized = normalize_instance_domain(domain)
+
+ Instance
+ |> where([i], fragment("lower(?)", i.domain) == ^normalized)
+ |> order_by([i], asc: i.id)
+ |> limit(1)
+ |> Repo.one()
+ end
+
+ defp get_instance_by_domain(_), do: nil
+
+ defp normalize_instance_domain(domain) when is_binary(domain) do
+ domain
+ |> String.trim()
+ |> String.downcase()
+ end
+
+ defp normalize_instance_domain(domain), do: domain
+
+ @doc """
+ Blocks an instance.
+ """
+ def block_instance(domain, reason, admin_user_id) do
+ case get_or_create_instance(domain) do
+ {:ok, instance} ->
+ result =
+ instance
+ |> Instance.changeset(%{
+ blocked: true,
+ reason: reason,
+ blocked_by_id: admin_user_id,
+ blocked_at: DateTime.utc_now()
+ })
+ |> Repo.update()
+
+ notify_all_home_feeds(:instance_blocked)
+ result
+
+ error ->
+ error
+ end
+ end
+
+ ## User blocks
+
+ @doc """
+ Blocks a remote actor or domain for a specific user.
+ """
+ def block_for_user(user_id, blocked_uri, type \\ "user") do
+ result =
+ %UserBlock{}
+ |> UserBlock.changeset(%{
+ user_id: user_id,
+ blocked_uri: blocked_uri,
+ block_type: type
+ })
+ |> Repo.insert()
+
+ notify_home_feed_policy_changed(user_id, :activitypub_blocked)
+ result
+ end
+
+ @doc """
+ Checks if a user has blocked an actor or domain.
+ """
+ def user_blocked?(user_id, uri) do
+ # Check both direct URI block and domain block
+ %URI{host: domain} = URI.parse(uri)
+
+ UserBlock
+ |> where([b], b.user_id == ^user_id)
+ |> where(
+ [b],
+ b.blocked_uri == ^uri or (b.block_type == "domain" and b.blocked_uri == ^domain)
+ )
+ |> Repo.exists?()
+ end
+
+ ## Relays (stub functions for now - full relay implementation exists in relay_manager.ex)
+
+ @doc """
+ Gets all relay inbox URLs for sending activities.
+ """
+ def get_relay_inboxes do
+ from(s in RelaySubscription,
+ where: s.status == "active" and s.accepted == true and not is_nil(s.relay_inbox),
+ select: s.relay_inbox
+ )
+ |> Repo.all()
+ |> Enum.uniq()
+ end
+
+ @doc """
+ Returns true when the actor URI belongs to an accepted relay subscription.
+ """
+ def active_relay_actor_uri?(actor_uri) when is_binary(actor_uri) do
+ RelaySubscription
+ |> where([s], s.relay_uri == ^actor_uri and s.status == "active" and s.accepted == true)
+ |> Repo.exists?()
+ end
+
+ def active_relay_actor_uri?(_), do: false
+
+ ## Community/Group Actors
+
+ @doc """
+ Gets or creates an ActivityPub Group actor for a local community.
+ """
+ def get_or_create_community_actor(community_id) when is_integer(community_id) do
+ case Repo.get_by(Actor, community_id: community_id) do
+ nil ->
+ # Fetch the community
+ case Conversations.get_conversation_basic(community_id) do
+ {:ok, community} ->
+ create_community_actor(community)
+
+ {:error, _} ->
+ {:error, :community_not_found}
+ end
+
+ actor ->
+ ensure_community_actor_keys(actor)
+ end
+ end
+
+ defp create_community_actor(community) do
+ base_url = instance_url()
+ community_slug = community_slug(community.name)
+ actor_url = community_actor_uri(community.name, base_url)
+ {public_key, private_key} = HTTPSignature.generate_key_pair()
+
+ attrs = %{
+ uri: actor_url,
+ username: community_slug,
+ domain: instance_domain(),
+ display_name: community.name,
+ summary: community.description,
+ avatar_url: community.avatar_url,
+ inbox_url: community_inbox_uri(community.name, base_url),
+ outbox_url: community_outbox_uri(community.name, base_url),
+ followers_url: community_followers_uri(community.name, base_url),
+ moderators_url: community_moderators_uri(community.name, base_url),
+ public_key: public_key,
+ manually_approves_followers: !community.is_public,
+ actor_type: "Group",
+ community_id: community.id,
+ published_at: community.inserted_at,
+ last_fetched_at: DateTime.utc_now(),
+ metadata: Actor.put_metadata_private_key(%{}, private_key)
+ }
+
+ case %Actor{}
+ |> Actor.changeset(attrs)
+ |> Repo.insert() do
+ {:ok, actor} -> ensure_community_actor_keys(actor)
+ error -> error
+ end
+ end
+
+ defp ensure_community_actor_keys(%Actor{} = actor) do
+ case KeyManager.ensure_user_has_keys(actor) do
+ {:ok, actor_with_keys} -> {:ok, actor_with_keys}
+ {:error, _reason} -> {:ok, actor}
+ end
+ end
+
+ @doc """
+ Gets a community actor by community name.
+ """
+ def get_community_actor_by_name(name) do
+ community_slug = community_slug(name)
+
+ Repo.get_by(Actor,
+ username: community_slug,
+ domain: instance_domain(),
+ actor_type: "Group"
+ )
+ end
+
+ @doc """
+ Fetches a remote user's timeline from their outbox.
+ Returns a list of post objects from their public timeline.
+ """
+ def fetch_remote_user_timeline(remote_actor_id, opts \\ []) do
+ limit = Keyword.get(opts, :limit, 20)
+ fetcher = Keyword.get(opts, :fetcher, Fetcher)
+
+ case Repo.get(Actor, remote_actor_id) do
+ nil ->
+ {:error, :actor_not_found}
+
+ %Actor{outbox_url: nil} ->
+ {:error, :no_outbox_url}
+
+ %Actor{outbox_url: outbox_url} ->
+ case fetcher.fetch_object(outbox_url) do
+ {:ok, outbox_data} ->
+ posts = extract_posts_from_outbox(outbox_data, limit, fetcher)
+ {:ok, posts}
+
+ {:error, reason} ->
+ {:error, reason}
+ end
+ end
+ end
+
+ defp extract_posts_from_outbox(outbox_data, limit, fetcher) do
+ outbox_data
+ |> collect_outbox_items(limit, fetcher)
+ |> Enum.map(fn item ->
+ case item do
+ # Create activity wrapping an object
+ %{"type" => "Create", "object" => %{"type" => type} = object}
+ when type in ["Note", "Article", "Question", "Page"] ->
+ object
+
+ # Announce activity wrapping a Create activity
+ %{
+ "type" => "Announce",
+ "object" => %{"type" => "Create", "object" => %{"type" => type} = inner_object}
+ }
+ when type in ["Note", "Article", "Question", "Page"] ->
+ inner_object
+
+ # Announce activity with direct embedded object (some instances)
+ %{"type" => "Announce", "object" => %{"type" => type} = object}
+ when type in ["Note", "Article", "Question", "Page"] ->
+ object
+
+ # Announce activity where object is a URL
+ %{"type" => "Announce", "object" => object_url} when is_binary(object_url) ->
+ case fetcher.fetch_object(object_url) do
+ {:ok, %{"type" => type} = object}
+ when type in ["Note", "Article", "Question", "Page"] ->
+ object
+
+ {:ok, %{"type" => "Create", "object" => %{"type" => type} = inner_object}}
+ when type in ["Note", "Article", "Question", "Page"] ->
+ inner_object
+
+ _ ->
+ nil
+ end
+
+ # Direct Note/Page object (some instances send these directly)
+ %{"type" => type} = object when type in ["Note", "Article", "Question", "Page"] ->
+ object
+
+ # URL string - need to fetch the object
+ url when is_binary(url) ->
+ case fetcher.fetch_object(url) do
+ {:ok, %{"type" => type} = object}
+ when type in ["Note", "Article", "Question", "Page"] ->
+ object
+
+ {:ok, %{"type" => "Create", "object" => %{"type" => type} = inner_object}}
+ when type in ["Note", "Article", "Question", "Page"] ->
+ inner_object
+
+ _ ->
+ nil
+ end
+
+ _ ->
+ nil
+ end
+ end)
+ |> Enum.filter(fn post ->
+ post &&
+ post["inReplyTo"] in [nil, ""] &&
+ post["type"] in [
+ "Page",
+ "Article",
+ "Question",
+ # Mastodon/Pleroma top-level statuses are usually Note.
+ # Comments are filtered by inReplyTo above.
+ "Note"
+ ]
+ end)
+ |> Enum.take(limit)
+ end
+
+ defp collect_outbox_items(outbox_data, limit, fetcher) do
+ {items, next_page_url} = extract_outbox_page(outbox_data)
+
+ if length(items) >= limit or is_nil(next_page_url) do
+ items
+ else
+ collect_next_outbox_items(items, next_page_url, limit, fetcher, MapSet.new())
+ end
+ end
+
+ defp collect_next_outbox_items(items, _next_page_url, limit, _fetcher, _visited_urls)
+ when length(items) >= limit,
+ do: items
+
+ defp collect_next_outbox_items(items, nil, _limit, _fetcher, _visited_urls), do: items
+
+ defp collect_next_outbox_items(items, next_page_url, limit, fetcher, visited_urls) do
+ if MapSet.member?(visited_urls, next_page_url) do
+ items
+ else
+ visited_urls = MapSet.put(visited_urls, next_page_url)
+
+ case fetcher.fetch_object(next_page_url) do
+ {:ok, page} ->
+ {page_items, following_page_url} = extract_outbox_page(page)
+ combined_items = items ++ page_items
+
+ if length(combined_items) >= limit or is_nil(following_page_url) do
+ combined_items
+ else
+ collect_next_outbox_items(
+ combined_items,
+ following_page_url,
+ limit,
+ fetcher,
+ visited_urls
+ )
+ end
+
+ _ ->
+ items
+ end
+ end
+ end
+
+ defp extract_outbox_page(outbox_data) do
+ case outbox_data do
+ %{"orderedItems" => items} when is_list(items) ->
+ {items, outbox_data["next"]}
+
+ %{"items" => items} when is_list(items) ->
+ {items, outbox_data["next"]}
+
+ # Handle first as a URL string
+ %{"first" => first_page_url} when is_binary(first_page_url) ->
+ {[], first_page_url}
+
+ # Handle first as an object with id
+ %{"first" => %{"id" => first_page_url}} when is_binary(first_page_url) ->
+ {[], first_page_url}
+
+ # Handle first as an object with items directly
+ %{"first" => %{"orderedItems" => items} = first} when is_list(items) ->
+ {items, first["next"]}
+
+ %{"first" => %{"items" => items} = first} when is_list(items) ->
+ {items, first["next"]}
+
+ _ ->
+ {[], nil}
+ end
+ end
+
+ @doc """
+ Fetches replies for a remote post.
+ Returns a list of reply objects.
+ """
+ def fetch_remote_post_replies(post_object, opts \\ []) do
+ limit = opts |> Keyword.get(:limit, 10) |> ReplyFetchPolicy.clamp_collection_limit()
+ post_id = post_object["id"]
+ post_url = post_object["url"] || post_id
+
+ # Link posts often use the external article URL as object.url. Keep using that for
+ # rendering, but use the ActivityPub/community post URL when deciding how to load
+ # comments so Lemmy-style replies aren't fetched from the submitted link target.
+ community_post_ref =
+ cond do
+ lemmy_post_url?(post_id) -> post_id
+ lemmy_post_url?(post_url) -> post_url
+ true -> post_id || post_url
+ end
+
+ lemmy_post? = post_object["type"] == "Page" && lemmy_post_url?(community_post_ref)
+
+ # Prefer standard replies collection, but accept comments collections used by
+ # some platforms.
+ replies_url =
+ extract_collection_url(post_object["replies"]) ||
+ extract_collection_url(post_object["comments"])
+
+ expected_replies =
+ [
+ normalize_reply_count(post_object["repliesCount"]),
+ normalize_reply_count(collection_total_items(post_object["replies"])),
+ normalize_reply_count(collection_total_items(post_object["comments"]))
+ ]
+ |> Enum.max(fn -> 0 end)
+
+ has_replies_count = expected_replies > 0
+ post_id = post_id || post_url
+
+ cond do
+ # Lemmy community posts are more reliably served by the instance API than the
+ # AP replies collection, which is often empty or requires extra indirection.
+ lemmy_post? ->
+ case fetch_lemmy_comments(community_post_ref, limit) do
+ {:ok, replies} when replies != [] ->
+ {:ok, replies}
+
+ _ when replies_url != nil ->
+ fetch_replies_from_collection(replies_url, limit, community_post_ref)
+
+ other ->
+ other
+ end
+
+ # Standard ActivityPub replies collection
+ replies_url != nil ->
+ case fetch_replies_from_collection(replies_url, limit, post_id || post_url) do
+ {:ok, replies} when replies != [] ->
+ {:ok, replies}
+
+ other ->
+ # ActivityPub collection returned empty but we expect replies - try
+ # Mastodon-compatible context endpoints when the post URL supports them.
+ if expected_replies > 0 and is_binary(post_url) and
+ MastodonApi.mastodon_compatible?(%{activitypub_id: post_url}) do
+ fetch_mastodon_context_replies(post_url, limit)
+ else
+ other
+ end
+ end
+
+ # Has replies count but no replies field - try Mastodon-compatible context APIs.
+ has_replies_count && is_binary(post_url) &&
+ MastodonApi.mastodon_compatible?(%{activitypub_id: post_url}) ->
+ case fetch_mastodon_context_replies(post_url, limit) do
+ {:ok, replies} when replies != [] -> {:ok, replies}
+ _ -> fetch_pleroma_replies(post_id, limit)
+ end
+
+ true ->
+ {:ok, []}
+ end
+ end
+
+ defp extract_collection_url(%{"first" => %{"id" => id}}) when is_binary(id), do: id
+ defp extract_collection_url(%{"first" => first_url}) when is_binary(first_url), do: first_url
+ defp extract_collection_url(%{"id" => id}) when is_binary(id), do: id
+ defp extract_collection_url(url) when is_binary(url), do: url
+ defp extract_collection_url(_), do: nil
+
+ defp collection_total_items(collection) when is_map(collection) do
+ Map.get(collection, "totalItems") || Map.get(collection, :totalItems)
+ end
+
+ defp collection_total_items(_), do: nil
+
+ defp normalize_reply_count(value) when is_integer(value), do: max(value, 0)
+
+ defp normalize_reply_count(value) when is_binary(value) do
+ case Integer.parse(String.trim(value)) do
+ {parsed, _} -> max(parsed, 0)
+ :error -> 0
+ end
+ end
+
+ defp normalize_reply_count(_), do: 0
+
+ # Fetch replies using Mastodon's /api/v1/statuses/:id/context endpoint
+ defp fetch_mastodon_context_replies(post_url, limit) do
+ case MastodonApi.fetch_status_context(post_url) do
+ {:ok, descendants} ->
+ # Convert Mastodon API format to ActivityPub-like objects
+ replies =
+ descendants
+ |> Enum.take(limit)
+ |> Enum.map(fn status ->
+ in_reply_to =
+ cond do
+ is_binary(status.in_reply_to_uri) ->
+ status.in_reply_to_uri
+
+ status.in_reply_to_id ->
+ post_url
+
+ true ->
+ nil
+ end
+
+ %{
+ "id" => status.uri || status.url || "#{post_url}#status-#{status.id}",
+ "url" => status.url || status.uri,
+ "type" => "Note",
+ "content" => status.content,
+ "attributedTo" => status.account[:url] || status.account[:uri],
+ "published" => status.created_at,
+ "inReplyTo" => in_reply_to,
+ "likes" => %{"totalItems" => status.favourites_count},
+ "shares" => %{"totalItems" => status.reblogs_count},
+ "replies" => %{"totalItems" => status.replies_count},
+ "_mastodon_account" => status.account
+ }
+ end)
+ |> supplement_mastodon_thread_replies(post_url, limit)
+ |> ReplyFetchPolicy.filter_same_host_replies(post_url)
+
+ {:ok, replies}
+
+ {:error, _} ->
+ {:ok, []}
+ end
+ end
+
+ defp supplement_mastodon_thread_replies(replies, _post_url, limit)
+ when not is_list(replies) or limit <= 0 do
+ Enum.take(List.wrap(replies), max(limit, 0))
+ end
+
+ defp supplement_mastodon_thread_replies(replies, post_url, limit) do
+ seen_ids =
+ replies
+ |> Enum.map(& &1["id"])
+ |> Enum.filter(&is_binary/1)
+ |> MapSet.new()
+
+ additional_replies =
+ [post_url | replies]
+ |> collect_supplemental_thread_replies(limit, seen_ids, [])
+ |> ReplyFetchPolicy.filter_same_host_replies(post_url)
+
+ merge_unique_replies(replies, additional_replies, limit)
+ end
+
+ defp collect_supplemental_thread_replies([], _limit, _seen_ids, acc), do: Enum.reverse(acc)
+
+ defp collect_supplemental_thread_replies(_queue, limit, _seen_ids, acc)
+ when length(acc) >= limit do
+ acc |> Enum.reverse() |> Enum.take(limit)
+ end
+
+ defp collect_supplemental_thread_replies([seed | rest], limit, seen_ids, acc) do
+ remaining = max(limit - length(acc), 0)
+
+ case fetch_thread_seed_object(seed) do
+ %{"replies" => replies_collection} when not is_nil(replies_collection) ->
+ fetched_replies = fetch_supplemental_reply_page(replies_collection, remaining)
+
+ {fresh_replies, updated_seen_ids} =
+ Enum.reduce(fetched_replies, {[], seen_ids}, fn reply, {fresh, seen} ->
+ case reply["id"] do
+ id when is_binary(id) ->
+ if MapSet.member?(seen, id) do
+ {fresh, seen}
+ else
+ {[reply | fresh], MapSet.put(seen, id)}
+ end
+
+ _ ->
+ {[reply | fresh], seen}
+ end
+ end)
+
+ fresh_replies = Enum.reverse(fresh_replies)
+
+ next_queue =
+ rest ++
+ Enum.filter(fresh_replies, fn reply ->
+ reply_has_nested_replies?(reply)
+ end)
+
+ collect_supplemental_thread_replies(
+ next_queue,
+ limit,
+ updated_seen_ids,
+ fresh_replies ++ acc
+ )
+
+ %{} = object ->
+ next_queue = if(reply_has_nested_replies?(object), do: rest ++ [object], else: rest)
+ collect_supplemental_thread_replies(next_queue, limit, seen_ids, acc)
+
+ _ ->
+ collect_supplemental_thread_replies(rest, limit, seen_ids, acc)
+ end
+ end
+
+ defp fetch_thread_seed_object(%{} = object), do: normalize_thread_reply_object(object)
+
+ defp fetch_thread_seed_object(seed) when is_binary(seed) do
+ case RemoteFetch.fetch_object(seed) do
+ {:ok, object} -> normalize_thread_reply_object(object)
+ _ -> nil
+ end
+ end
+
+ defp fetch_thread_seed_object(_), do: nil
+
+ defp normalize_thread_reply_object(%{"type" => type} = object)
+ when type in ["Note", "Article", "Page", "Question"] do
+ object
+ end
+
+ defp normalize_thread_reply_object(%{"object" => %{} = object}) do
+ normalize_thread_reply_object(object)
+ end
+
+ defp normalize_thread_reply_object(_), do: nil
+
+ defp fetch_supplemental_reply_page(replies_collection, limit) when limit > 0 do
+ case CollectionFetcher.fetch_collection(replies_collection, max_items: limit) do
+ {:ok, items} -> normalize_thread_reply_items(items, limit)
+ {:partial, items} -> normalize_thread_reply_items(items, limit)
+ _ -> []
+ end
+ end
+
+ defp fetch_supplemental_reply_page(_, _), do: []
+
+ defp normalize_thread_reply_items(items, limit) do
+ items
+ |> Enum.map(&normalize_thread_reply_object/1)
+ |> Enum.reject(&is_nil/1)
+ |> Enum.take(limit)
+ end
+
+ defp reply_has_nested_replies?(%{} = reply) do
+ replies = reply["replies"]
+
+ cond do
+ is_map(replies) -> true
+ is_binary(replies) -> true
+ true -> Elektrine.ActivityPub.Helpers.extract_interaction_count(reply, "replies") > 0
+ end
+ end
+
+ defp reply_has_nested_replies?(_), do: false
+
+ defp merge_unique_replies(primary, secondary, limit) do
+ {merged, _seen} =
+ Enum.reduce(primary ++ secondary, {[], MapSet.new()}, fn reply, {acc, seen} ->
+ case reply["id"] do
+ id when is_binary(id) ->
+ if MapSet.member?(seen, id) do
+ {acc, seen}
+ else
+ {[reply | acc], MapSet.put(seen, id)}
+ end
+
+ _ ->
+ {[reply | acc], seen}
+ end
+ end)
+
+ merged
+ |> Enum.reverse()
+ |> Enum.take(limit)
+ end
+
+ # Check if URL is a community-post format across Lemmy/PieFed/Mbin.
+ defp lemmy_post_url?(url) when is_binary(url) do
+ LemmyApi.community_post_url?(url)
+ end
+
+ defp lemmy_post_url?(_), do: false
+
+ # Fetch comments from instance API
+ defp fetch_lemmy_comments(post_url, limit) do
+ case resolve_lemmy_post_reference(post_url) do
+ {:ok, domain, post_id} ->
+ # First try the origin domain
+ case fetch_lemmy_comments_from_instance(domain, post_id, post_url, limit) do
+ {:ok, comments} when comments != [] ->
+ {:ok, comments}
+
+ _ ->
+ # If empty, try to find the community's home instance
+ # The post might be federated from another instance
+ try_fetch_from_community_instance(post_url, limit)
+ end
+
+ :error ->
+ {:ok, []}
+ end
+ end
+
+ defp resolve_lemmy_post_reference(post_url) do
+ case Regex.run(~r{https?://([^/]+)/post/(\d+)}, post_url) do
+ [_, domain, post_id] ->
+ {:ok, domain, post_id}
+
+ _ ->
+ case Regex.run(~r{https?://([^/]+)/c/[^/]+/p/(\d+)}, post_url) do
+ [_, domain, post_id] ->
+ {:ok, domain, post_id}
+
+ _ ->
+ case Regex.run(~r{https?://([^/]+)/m/[^/]+/[pt]/(\d+)}, post_url) do
+ [_, domain, post_id] ->
+ {:ok, domain, post_id}
+
+ _ ->
+ resolve_lemmy_post_reference_via_api(post_url)
+ end
+ end
+ end
+ end
+
+ defp resolve_lemmy_post_reference_via_api(post_url) do
+ case URI.parse(post_url) do
+ %URI{host: domain} when is_binary(domain) ->
+ resolve_url = "https://#{domain}/api/v4/resolve_object?q=#{URI.encode_www_form(post_url)}"
+
+ case safe_get_lemmy_api(resolve_url, [{"Accept", "application/json"}],
+ receive_timeout: 10_000
+ ) do
+ {:ok, %Finch.Response{status: 200, body: body}} ->
+ case Jason.decode(body) do
+ {:ok, %{"post" => %{"post" => %{"id" => post_id}}}} when is_integer(post_id) ->
+ {:ok, domain, Integer.to_string(post_id)}
+
+ {:ok, %{"post" => %{"post" => %{"id" => post_id}}}} when is_binary(post_id) ->
+ {:ok, domain, post_id}
+
+ _ ->
+ :error
+ end
+
+ _ ->
+ :error
+ end
+
+ _ ->
+ :error
+ end
+ end
+
+ # Try to fetch comments from the community's home instance
+ defp try_fetch_from_community_instance(post_url, limit) do
+ # Fetch the post object to get the audience (community)
+ case RemoteFetch.fetch_object(post_url) do
+ {:ok, post_object} ->
+ # Get the community URL from audience field
+ community_url = post_object["audience"]
+
+ if is_binary(community_url) do
+ # Extract community's home domain
+ case URI.parse(community_url) do
+ %URI{host: community_domain} when is_binary(community_domain) ->
+ # Resolve the post on the community's home instance
+ resolve_url =
+ "https://#{community_domain}/api/v4/resolve_object?q=#{URI.encode_www_form(post_url)}"
+
+ case safe_get_lemmy_api(resolve_url, [{"Accept", "application/json"}],
+ receive_timeout: 10_000
+ ) do
+ {:ok, %Finch.Response{status: 200, body: body}} ->
+ case Jason.decode(body) do
+ {:ok, %{"post" => %{"post" => %{"id" => local_post_id}}}} ->
+ # Now fetch comments using the local post ID
+ fetch_lemmy_comments_from_instance(
+ community_domain,
+ to_string(local_post_id),
+ post_url,
+ limit
+ )
+
+ _ ->
+ {:ok, []}
+ end
+
+ _ ->
+ {:ok, []}
+ end
+
+ _ ->
+ {:ok, []}
+ end
+ else
+ {:ok, []}
+ end
+
+ _ ->
+ {:ok, []}
+ end
+ end
+
+ # Fetch comments from a specific instance
+ defp fetch_lemmy_comments_from_instance(domain, post_id, post_url, limit) do
+ replies = LemmyApi.fetch_post_comments_from_instance(domain, post_id, post_url, limit)
+
+ {:ok, replies}
+ end
+
+ # Fetch replies using context API (Mastodon/Pleroma/Akkoma)
+ defp fetch_pleroma_replies(post_ap_id, limit) do
+ require Logger
+
+ # Extract domain from the ActivityPub ID
+ case URI.parse(post_ap_id) do
+ %URI{host: domain} when is_binary(domain) ->
+ # Step 1: Search for the post to get its internal ID
+ search_url =
+ "https://#{domain}/api/v2/search?q=#{URI.encode_www_form(post_ap_id)}&type=statuses&resolve=true&limit=1"
+
+ case safe_get(search_url, [{"Accept", "application/json"}], receive_timeout: 15_000) do
+ {:ok, %Finch.Response{status: 200, body: body}} ->
+ case Jason.decode(body) do
+ {:ok, %{"statuses" => [%{"id" => status_id, "uri" => root_uri} = root_status | _]}} ->
+ # Step 2: Fetch context (ancestors + descendants)
+ context_url = "https://#{domain}/api/v1/statuses/#{status_id}/context"
+
+ case safe_get(context_url, [{"Accept", "application/json"}],
+ receive_timeout: 15_000
+ ) do
+ {:ok, %Finch.Response{status: 200, body: context_body}} ->
+ case Jason.decode(context_body) do
+ {:ok, %{"ancestors" => ancestors, "descendants" => descendants}}
+ when is_list(descendants) ->
+ # Build a lookup map from local ID -> AP URI
+ # Include the root post, ancestors, and descendants
+ all_statuses = [root_status | ancestors] ++ descendants
+
+ id_to_uri_map =
+ all_statuses
+ |> Enum.map(fn s -> {s["id"], s["uri"] || s["url"]} end)
+ |> Map.new()
+
+ # Convert Mastodon API statuses to ActivityPub-like objects
+ # with proper inReplyTo URIs
+ replies =
+ descendants
+ |> Enum.take(limit)
+ |> Enum.map(
+ &mastodon_status_to_activitypub(&1, id_to_uri_map, post_ap_id)
+ )
+ |> Enum.filter(&(&1 != nil))
+
+ Logger.debug(
+ "Fetched #{length(replies)} replies from context API for #{post_ap_id}"
+ )
+
+ {:ok, replies}
+
+ {:ok, %{"descendants" => descendants}} when is_list(descendants) ->
+ # Fallback if no ancestors field - use root post for mapping
+ id_to_uri_map = %{status_id => root_uri}
+
+ replies =
+ descendants
+ |> Enum.take(limit)
+ |> Enum.map(
+ &mastodon_status_to_activitypub(&1, id_to_uri_map, post_ap_id)
+ )
+ |> Enum.filter(&(&1 != nil))
+
+ {:ok, replies}
+
+ _ ->
+ {:ok, []}
+ end
+
+ {:error, reason} ->
+ Logger.warning(
+ "Failed to fetch context for #{post_ap_id}: #{inspect(reason)}"
+ )
+
+ {:ok, []}
+
+ _ ->
+ {:ok, []}
+ end
+
+ _ ->
+ Logger.debug("Post not found in search: #{post_ap_id}")
+ {:ok, []}
+ end
+
+ {:error, reason} ->
+ Logger.warning("Failed to search for #{post_ap_id}: #{inspect(reason)}")
+ {:ok, []}
+
+ _ ->
+ {:ok, []}
+ end
+
+ _ ->
+ {:ok, []}
+ end
+ end
+
+ # Convert a Mastodon API status to an ActivityPub-like object
+ # id_to_uri_map maps local status IDs to their ActivityPub URIs
+ # root_post_uri is the URI of the post we're fetching replies for
+ defp mastodon_status_to_activitypub(status, id_to_uri_map, root_post_uri) do
+ # Extract the ActivityPub URI if available
+ ap_id = status["uri"] || status["url"]
+
+ # Resolve inReplyTo: look up the parent's AP URI from the map
+ # Fall back to akkoma/pleroma extensions, then to root post
+ in_reply_to =
+ cond do
+ # Check Akkoma extension first (provides direct AP ID)
+ ap_uri = get_in(status, ["akkoma", "in_reply_to_apid"]) ->
+ ap_uri
+
+ # Check Pleroma extension (note: in_reply_to_account_acct is the account, not the post)
+ get_in(status, ["pleroma", "in_reply_to_account_acct"]) ->
+ # This is the account acct, not the post URI - fall through to ID lookup
+ nil
+
+ # Look up from our ID map
+ parent_id = status["in_reply_to_id"] ->
+ Map.get(id_to_uri_map, parent_id)
+
+ true ->
+ nil
+ end
+
+ # If we couldn't resolve inReplyTo but we know there is one,
+ # assume it's the root post (for direct replies)
+ in_reply_to =
+ if is_nil(in_reply_to) && status["in_reply_to_id"] do
+ root_post_uri
+ else
+ in_reply_to
+ end
+
+ %{
+ "id" => ap_id,
+ "type" => "Note",
+ "content" => status["content"],
+ "published" => status["created_at"],
+ "attributedTo" => get_in(status, ["account", "url"]) || get_in(status, ["account", "uri"]),
+ "inReplyTo" => in_reply_to,
+ "to" => ["https://www.w3.org/ns/activitystreams#Public"],
+ "cc" => [],
+ "sensitive" => status["sensitive"] || false,
+ "summary" => status["spoiler_text"],
+ # Store original API data for reference
+ "_mastodon" => %{
+ "id" => status["id"],
+ "in_reply_to_id" => status["in_reply_to_id"],
+ "account" => status["account"],
+ "favourites_count" => status["favourites_count"],
+ "reblogs_count" => status["reblogs_count"],
+ "replies_count" => status["replies_count"]
+ }
+ }
+ end
+
+ # Fetch replies from standard ActivityPub collection
+ defp fetch_replies_from_collection(replies_url, limit, root_ref) do
+ case RemoteFetch.fetch_object(replies_url) do
+ {:ok, replies_data} ->
+ {items, next_page} = extract_items_from_collection(replies_data)
+
+ # If items is empty but there's a next page, fetch it
+ {items, _} =
+ if items == [] && next_page do
+ case RemoteFetch.fetch_object(next_page) do
+ {:ok, next_data} -> extract_items_from_collection(next_data)
+ _ -> {[], nil}
+ end
+ else
+ {items, next_page}
+ end
+
+ # Fetch full objects for URI-only items, then filter and extract reply objects
+ replies =
+ items
+ |> Enum.take(limit)
+ |> Enum.map(fn item ->
+ case item do
+ # Item is already a full object
+ %{"type" => _type} = obj ->
+ obj
+
+ # Item is a Create activity - extract the object
+ %{"object" => %{"type" => _} = obj} ->
+ obj
+
+ # Item is just a URI - fetch it
+ uri when is_binary(uri) ->
+ case RemoteFetch.fetch_object(uri) do
+ {:ok, fetched_obj} -> fetched_obj
+ _ -> nil
+ end
+
+ _ ->
+ nil
+ end
+ end)
+ |> Enum.filter(fn item ->
+ case item do
+ %{"type" => type} when type in ["Note", "Article", "Page"] -> true
+ _ -> false
+ end
+ end)
+ |> ReplyFetchPolicy.filter_same_host_replies(root_ref)
+
+ {:ok, replies}
+
+ {:error, reason} ->
+ {:error, reason}
+ end
+ end
+
+ # Returns {items, next_page_url}
+ defp extract_items_from_collection(collection_data) do
+ case collection_data do
+ %{"orderedItems" => items} when is_list(items) ->
+ {items, collection_data["next"]}
+
+ %{"items" => items} when is_list(items) ->
+ {items, collection_data["next"]}
+
+ %{"first" => %{"orderedItems" => items} = first} when is_list(items) ->
+ {items, first["next"]}
+
+ %{"first" => %{"items" => items} = first} when is_list(items) ->
+ {items, first["next"]}
+
+ _ ->
+ {[], nil}
+ end
+ end
+
+ # Group Follow functions
+
+ alias Elektrine.ActivityPub.GroupFollow
+
+ @doc """
+ Creates a group follow relationship (remote actor following local Group).
+ """
+ def create_group_follow(remote_actor_id, group_actor_id, activitypub_id, pending \\ false) do
+ %GroupFollow{}
+ |> GroupFollow.changeset(%{
+ remote_actor_id: remote_actor_id,
+ group_actor_id: group_actor_id,
+ activitypub_id: activitypub_id,
+ pending: pending
+ })
+ |> Repo.insert()
+ end
+
+ @doc """
+ Gets an existing group follow by remote actor and group actor.
+ """
+ def get_group_follow(remote_actor_id, group_actor_id) do
+ Repo.get_by(GroupFollow, remote_actor_id: remote_actor_id, group_actor_id: group_actor_id)
+ end
+
+ @doc """
+ Deletes a group follow relationship.
+ """
+ def delete_group_follow(remote_actor_id, group_actor_id) do
+ case get_group_follow(remote_actor_id, group_actor_id) do
+ nil -> {:ok, :not_found}
+ follow -> Repo.delete(follow)
+ end
+ end
+
+ @doc """
+ Gets all followers (remote actors) of a local Group actor.
+ Returns list of Actor structs with inbox_url.
+ """
+ def get_group_followers(group_actor_id) do
+ from(gf in GroupFollow,
+ where: gf.group_actor_id == ^group_actor_id and gf.pending == false,
+ join: actor in assoc(gf, :remote_actor),
+ select: actor
+ )
+ |> Repo.all()
+ end
+
+ @doc """
+ Gets the inbox URLs for all followers of a local Group actor.
+ Uses shared inbox when available.
+ """
+ def get_group_follower_inboxes(group_actor_id) do
+ get_group_followers(group_actor_id)
+ |> Enum.map(fn actor ->
+ # Shared inbox is in metadata.endpoints.sharedInbox, not a direct field
+ shared_inbox = get_in(actor.metadata || %{}, ["endpoints", "sharedInbox"])
+ shared_inbox || actor.inbox_url
+ end)
+ |> Enum.filter(&is_binary/1)
+ |> Enum.uniq()
+ end
+
+ @doc """
+ Gets the count of followers for a local Group actor.
+ """
+ def get_group_follower_count(group_actor_id) do
+ from(gf in GroupFollow,
+ where: gf.group_actor_id == ^group_actor_id and gf.pending == false,
+ select: count(gf.id)
+ )
+ |> Repo.one()
+ end
+
+ @doc """
+ Gets a local Group actor by its URI.
+ Returns nil if not found or not a Group.
+ """
+ def get_local_group_actor_by_uri(uri) do
+ from(a in Actor,
+ join: c in Conversation,
+ on: c.id == a.community_id,
+ where: a.uri == ^uri and a.domain == ^instance_domain() and a.actor_type == "Group",
+ where: c.type == "community" and c.is_public == true
+ )
+ |> Repo.one()
+ end
+
+ defp safe_get(url, headers, opts) do
+ request = Finch.build(:get, url, headers)
+ SafeFetch.request(request, Elektrine.Finch, opts)
+ end
+
+ defp safe_get_lemmy_api(url, headers, opts) do
+ case safe_get(url, headers, opts) do
+ {:ok, %Finch.Response{status: 404}} ->
+ url
+ |> fallback_lemmy_api_url()
+ |> case do
+ nil -> {:ok, %Finch.Response{status: 404, headers: [], body: ""}}
+ fallback_url -> safe_get(fallback_url, headers, opts)
+ end
+
+ other ->
+ other
+ end
+ end
+
+ defp fallback_lemmy_api_url(url) when is_binary(url) do
+ if String.contains?(url, "/api/v4/") do
+ String.replace(url, "/api/v4/", "/api/v3/", global: false)
+ else
+ nil
+ end
+ end
+
+ defp fallback_lemmy_api_url(_), do: nil
+
+ defp notify_home_feed_policy_changed(user_id, reason) when is_integer(user_id) do
+ module = Module.concat([Elektrine, Social, HomeFeedInvalidationWorker])
+
+ if Code.ensure_loaded?(module) do
+ _ = module.clear_user(user_id, reason)
+ end
+
+ :ok
+ rescue
+ _ -> :ok
+ end
+
+ defp notify_all_home_feeds(reason) do
+ module = Module.concat([Elektrine, Social, HomeFeed])
+
+ if Code.ensure_loaded?(module) do
+ _ = module.clear_all(reason)
+ end
+
+ :ok
+ rescue
+ _ -> :ok
+ end
+end
diff --git a/apps/elektrine/lib/elektrine/activitypub/activity.ex b/apps/elektrine/lib/elektrine/activitypub/activity.ex
new file mode 100644
index 0000000..a825397
--- /dev/null
+++ b/apps/elektrine/lib/elektrine/activitypub/activity.ex
@@ -0,0 +1,56 @@
+defmodule Elektrine.ActivityPub.Activity do
+ @moduledoc false
+ use Ecto.Schema
+ import Ecto.Changeset
+
+ schema "activitypub_activities" do
+ field :activity_id, :string
+ field :activity_type, :string
+ field :actor_uri, :string
+ field :object_id, :string
+ field :data, :map
+ field :local, :boolean, default: false
+
+ # Processing state for async handling
+ field :processed, :boolean, default: false
+ field :processed_at, :utc_datetime
+ field :process_error, :string
+ field :process_attempts, :integer, default: 0
+
+ belongs_to :internal_user, Elektrine.Accounts.User
+ belongs_to :internal_message, Elektrine.Social.Message
+
+ has_many :deliveries, Elektrine.ActivityPub.Delivery
+
+ timestamps()
+ end
+
+ @doc false
+ def changeset(activity, attrs) do
+ activity
+ |> cast(attrs, [
+ :activity_id,
+ :activity_type,
+ :actor_uri,
+ :object_id,
+ :data,
+ :local,
+ :internal_user_id,
+ :internal_message_id,
+ :processed,
+ :processed_at,
+ :process_error,
+ :process_attempts
+ ])
+ |> validate_required([:activity_id, :activity_type, :actor_uri, :data])
+ |> unique_constraint(:activity_id)
+ end
+
+ @doc """
+ Changeset for marking an activity as processed.
+ """
+ def mark_processed_changeset(activity, attrs \\ %{}) do
+ activity
+ |> cast(attrs, [:processed, :processed_at, :process_error, :process_attempts])
+ end
+end
diff --git a/apps/elektrine/lib/elektrine/activitypub/activity_delivery_worker.ex b/apps/elektrine/lib/elektrine/activitypub/activity_delivery_worker.ex
new file mode 100644
index 0000000..f044cb0
--- /dev/null
+++ b/apps/elektrine/lib/elektrine/activitypub/activity_delivery_worker.ex
@@ -0,0 +1,282 @@
+defmodule Elektrine.ActivityPub.ActivityDeliveryWorker do
+ @moduledoc """
+ Oban worker for delivering ActivityPub activities to remote instances.
+
+ Replaces the old DeliveryWorker GenServer with guaranteed delivery
+ and automatic retries.
+
+ Uses Lemmy-style per-domain throttling to prevent overwhelming
+ remote instances with too many concurrent requests.
+ """
+
+ use Oban.Worker,
+ queue: :activitypub_delivery,
+ max_attempts: 1,
+ unique: [
+ period: :infinity,
+ fields: [:args, :worker],
+ keys: [:delivery_id],
+ states: :incomplete
+ ]
+
+ require Logger
+
+ alias Elektrine.ActivityPub
+ alias Elektrine.ActivityPub.DomainThrottler
+ alias Elektrine.ActivityPub.Publisher
+ alias Elektrine.Telemetry.Events
+
+ @impl Oban.Worker
+ def perform(%Oban.Job{args: %{"delivery_id" => delivery_id}}) do
+ started_at = System.monotonic_time(:millisecond)
+
+ case ActivityPub.get_delivery(delivery_id) do
+ nil ->
+ Logger.warning("ActivityDeliveryWorker: Delivery #{delivery_id} not found")
+
+ Events.federation(:delivery_worker, :perform, :missing_delivery, nil, %{
+ delivery_id: delivery_id
+ })
+
+ {:discard, :missing_delivery}
+
+ %{status: status} when status != "pending" ->
+ Logger.debug(
+ "ActivityDeliveryWorker: Delivery #{delivery_id} already processed (#{status})"
+ )
+
+ Events.federation(:delivery_worker, :perform, :already_processed, nil, %{
+ delivery_id: delivery_id,
+ status: status
+ })
+
+ {:discard, :already_processed}
+
+ delivery ->
+ # Extract domain from inbox URL for throttling
+ domain = inbox_domain(delivery.inbox_url)
+
+ # Try to acquire a processing slot for this domain (Lemmy-style)
+ case DomainThrottler.acquire(domain) do
+ {:ok, :acquired} ->
+ result = process_delivery(delivery)
+
+ # Release slot with success/failure status
+ success? =
+ case result do
+ {:ok, _} -> true
+ _ -> false
+ end
+
+ DomainThrottler.release(domain, success?)
+
+ outcome =
+ case result do
+ {:ok, _} -> :success
+ _ -> :failure
+ end
+
+ Events.federation(
+ :delivery_worker,
+ :perform,
+ outcome,
+ System.monotonic_time(:millisecond) - started_at,
+ %{
+ delivery_id: delivery_id,
+ actor_domain: domain
+ }
+ )
+
+ # Delivery retries are driven by next_retry_at + DeliveryRetryWorker.
+ # Reflect terminal delivery failures without asking Oban to retry them.
+ case result do
+ {:ok, _} -> :ok
+ {:error, reason} -> {:discard, reason}
+ end
+
+ {:error, :throttled} ->
+ # Domain has too many concurrent deliveries, snooze and retry
+ Logger.debug("Throttled delivery to #{domain}, snoozing for 5s")
+
+ Events.federation(
+ :delivery_worker,
+ :perform,
+ :throttled,
+ System.monotonic_time(:millisecond) - started_at,
+ %{
+ delivery_id: delivery_id,
+ actor_domain: domain
+ }
+ )
+
+ {:snooze, 5}
+
+ {:error, :backoff, remaining_ms} ->
+ # Domain is in backoff due to failures
+ snooze_seconds = max(1, div(normalize_remaining_ms(remaining_ms), 1000))
+ Logger.debug("Domain #{domain} in backoff, snoozing delivery for #{snooze_seconds}s")
+
+ Events.federation(
+ :delivery_worker,
+ :perform,
+ :backoff,
+ System.monotonic_time(:millisecond) - started_at,
+ %{
+ delivery_id: delivery_id,
+ actor_domain: domain,
+ backoff_ms: remaining_ms
+ }
+ )
+
+ {:snooze, snooze_seconds}
+ end
+ end
+ end
+
+ defp process_delivery(delivery) do
+ started_at = System.monotonic_time(:millisecond)
+ activity = delivery.activity
+ domain = inbox_domain(delivery.inbox_url)
+
+ case get_signing_entity(activity) do
+ {:ok, entity} ->
+ # Attempt delivery
+ case Publisher.deliver(activity.data, entity, delivery.inbox_url) do
+ {:ok, :delivered} ->
+ ActivityPub.mark_delivery_delivered(delivery.id)
+ Logger.info("ActivityDeliveryWorker: Delivered to #{delivery.inbox_url}")
+
+ Events.federation(
+ :delivery_worker,
+ :deliver,
+ :success,
+ System.monotonic_time(:millisecond) - started_at,
+ %{
+ delivery_id: delivery.id,
+ actor_domain: domain
+ }
+ )
+
+ {:ok, :delivered}
+
+ {:error, reason} ->
+ ActivityPub.mark_delivery_failed(delivery.id, reason)
+
+ Logger.warning(
+ "ActivityDeliveryWorker: Failed to deliver to #{delivery.inbox_url}: #{inspect(reason)}"
+ )
+
+ Events.federation(
+ :delivery_worker,
+ :deliver,
+ :failure,
+ System.monotonic_time(:millisecond) - started_at,
+ %{
+ delivery_id: delivery.id,
+ actor_domain: domain,
+ reason: inspect(reason)
+ }
+ )
+
+ {:error, reason}
+ end
+
+ {:error, reason} ->
+ ActivityPub.mark_delivery_failed(delivery.id, reason)
+ Logger.warning("ActivityDeliveryWorker: #{reason} for delivery #{delivery.id}")
+
+ Events.federation(
+ :delivery_worker,
+ :deliver,
+ :failure,
+ System.monotonic_time(:millisecond) - started_at,
+ %{
+ delivery_id: delivery.id,
+ actor_domain: domain,
+ reason: inspect(reason)
+ }
+ )
+
+ {:error, reason}
+ end
+ rescue
+ e ->
+ Logger.error(
+ "ActivityDeliveryWorker: Error processing delivery #{delivery.id}: #{inspect(e)}"
+ )
+
+ ActivityPub.mark_delivery_failed(delivery.id, inspect(e))
+
+ Events.federation(:delivery_worker, :deliver, :failure, nil, %{
+ delivery_id: delivery.id,
+ actor_domain: inbox_domain(delivery.inbox_url),
+ reason: inspect(e)
+ })
+
+ {:error, inspect(e)}
+ end
+
+ defp get_signing_entity(activity) do
+ cond do
+ is_integer(activity.internal_user_id) ->
+ case Elektrine.Repo.get(Elektrine.Accounts.User, activity.internal_user_id) do
+ nil ->
+ {:error, "User not found"}
+
+ user ->
+ {:ok, user}
+ end
+
+ is_binary(activity.actor_uri) ->
+ case ActivityPub.get_actor_by_uri(activity.actor_uri) do
+ %ActivityPub.Actor{} = actor ->
+ if actor.domain == ActivityPub.instance_domain() do
+ {:ok, actor}
+ else
+ {:error, "Local actor not found"}
+ end
+
+ _ ->
+ {:error, "Local actor not found"}
+ end
+
+ true ->
+ {:error, "No signing entity found"}
+ end
+ end
+
+ @doc """
+ Enqueue a delivery for processing.
+ """
+ def enqueue(delivery_id) do
+ %{delivery_id: delivery_id}
+ |> new()
+ |> Elektrine.JobQueue.insert()
+ end
+
+ @doc """
+ Enqueue multiple deliveries for processing.
+ """
+ def enqueue_many(delivery_ids) when is_list(delivery_ids) do
+ delivery_ids
+ |> Enum.uniq()
+ |> Enum.map(&enqueue/1)
+ end
+
+ defp inbox_domain(inbox_url) when is_binary(inbox_url) do
+ case URI.parse(inbox_url) do
+ %URI{host: host} when is_binary(host) and host != "" -> host
+ _ -> "unknown"
+ end
+ end
+
+ defp inbox_domain(_), do: "unknown"
+
+ defp normalize_remaining_ms(remaining_ms) when is_integer(remaining_ms) and remaining_ms > 0,
+ do: remaining_ms
+
+ defp normalize_remaining_ms(remaining_ms) when is_float(remaining_ms) and remaining_ms > 0,
+ do: round(remaining_ms)
+
+ defp normalize_remaining_ms(_), do: 1_000
+end
diff --git a/apps/elektrine/lib/elektrine/activitypub/actor.ex b/apps/elektrine/lib/elektrine/activitypub/actor.ex
new file mode 100644
index 0000000..edd1a0f
--- /dev/null
+++ b/apps/elektrine/lib/elektrine/activitypub/actor.ex
@@ -0,0 +1,160 @@
+defmodule Elektrine.ActivityPub.Actor do
+ @moduledoc false
+ use Ecto.Schema
+ import Ecto.Changeset
+
+ alias Elektrine.Secrets.EncryptedString
+ alias Elektrine.Security.SafeExternalURL
+
+ schema "activitypub_actors" do
+ field :uri, :string
+ field :username, :string
+ field :domain, :string
+ field :display_name, :string
+ field :summary, :string
+ field :avatar_url, :string
+ field :header_url, :string
+ field :inbox_url, :string
+ field :outbox_url, :string
+ field :followers_url, :string
+ field :following_url, :string
+ field :public_key, :string
+ field :manually_approves_followers, :boolean, default: false
+ field :actor_type, :string, default: "Person"
+ field :last_fetched_at, :utc_datetime
+ field :published_at, :utc_datetime
+ field :metadata, :map, default: %{}
+ field :moderators_url, :string
+
+ # Relationship to local community (if this actor represents a local community)
+ belongs_to :community, Elektrine.Social.Conversation, foreign_key: :community_id
+
+ timestamps()
+ end
+
+ @doc false
+ def changeset(actor, attrs) do
+ actor
+ |> cast(attrs, [
+ :uri,
+ :username,
+ :domain,
+ :display_name,
+ :summary,
+ :avatar_url,
+ :header_url,
+ :inbox_url,
+ :outbox_url,
+ :followers_url,
+ :following_url,
+ :public_key,
+ :manually_approves_followers,
+ :actor_type,
+ :last_fetched_at,
+ :published_at,
+ :metadata,
+ :moderators_url,
+ :community_id
+ ])
+ |> truncate_utc_datetimes([:last_fetched_at, :published_at])
+ |> update_change(:metadata, &encrypt_metadata_private_key/1)
+ |> validate_required([:uri, :username, :domain, :inbox_url])
+ |> validate_safe_href(:uri)
+ |> validate_safe_href(:inbox_url)
+ |> validate_optional_safe_href(:avatar_url)
+ |> validate_optional_safe_href(:header_url)
+ |> validate_optional_safe_href(:outbox_url)
+ |> validate_optional_safe_href(:followers_url)
+ |> validate_optional_safe_href(:following_url)
+ |> validate_optional_safe_href(:moderators_url)
+ |> validate_inclusion(:actor_type, [
+ "Person",
+ "Group",
+ "Organization",
+ "Service",
+ "Application"
+ ])
+ |> unique_constraint(:uri)
+ |> unique_constraint([:username, :domain],
+ name: :activitypub_actors_username_domain_unique_index
+ )
+ |> unique_constraint(:community_id)
+ end
+
+ def metadata_private_key(%__MODULE__{metadata: metadata}),
+ do: decrypt_metadata_private_key(metadata)
+
+ def metadata_private_key(_actor), do: nil
+
+ def put_metadata_private_key(metadata, private_key) when is_binary(private_key) do
+ metadata
+ |> normalize_metadata()
+ |> Map.put("private_key", encrypt_private_key(private_key))
+ end
+
+ def put_metadata_private_key(metadata, _private_key), do: normalize_metadata(metadata)
+
+ defp encrypt_metadata_private_key(metadata) when is_map(metadata) do
+ case Map.get(metadata, "private_key") || Map.get(metadata, :private_key) do
+ private_key when is_binary(private_key) -> put_metadata_private_key(metadata, private_key)
+ _ -> metadata
+ end
+ end
+
+ defp encrypt_metadata_private_key(metadata), do: metadata
+
+ defp decrypt_metadata_private_key(metadata) when is_map(metadata) do
+ case Map.get(metadata, "private_key") || Map.get(metadata, :private_key) do
+ private_key when is_binary(private_key) ->
+ case EncryptedString.decrypt(private_key) do
+ {:ok, decrypted} -> decrypted
+ :error -> private_key
+ end
+
+ _ ->
+ nil
+ end
+ end
+
+ defp decrypt_metadata_private_key(_metadata), do: nil
+
+ defp truncate_utc_datetimes(changeset, fields) do
+ Enum.reduce(fields, changeset, fn field, changeset ->
+ update_change(changeset, field, &Elektrine.Time.truncate/1)
+ end)
+ end
+
+ defp encrypt_private_key(private_key) do
+ case EncryptedString.encrypt(private_key) do
+ {:ok, encrypted} -> encrypted
+ :error -> private_key
+ end
+ end
+
+ defp normalize_metadata(metadata) when is_map(metadata),
+ do: Map.new(metadata, fn {key, value} -> {to_string(key), value} end)
+
+ defp normalize_metadata(_metadata), do: %{}
+
+ defp validate_safe_href(changeset, field) do
+ validate_change(changeset, field, fn _, url ->
+ safe_href_errors(field, url)
+ end)
+ end
+
+ defp validate_optional_safe_href(changeset, field) do
+ validate_change(changeset, field, fn
+ _, nil -> []
+ _, "" -> []
+ _, url -> safe_href_errors(field, url)
+ end)
+ end
+
+ defp safe_href_errors(field, url) do
+ case SafeExternalURL.normalize_href(url) do
+ {:ok, _url} -> []
+ {:error, :userinfo_not_allowed} -> [{field, "must not include username or password"}]
+ {:error, _reason} -> [{field, "must be a valid HTTP or HTTPS URL"}]
+ end
+ end
+end
diff --git a/apps/elektrine/lib/elektrine/activitypub/actor_refresh_worker.ex b/apps/elektrine/lib/elektrine/activitypub/actor_refresh_worker.ex
new file mode 100644
index 0000000..ab79b54
--- /dev/null
+++ b/apps/elektrine/lib/elektrine/activitypub/actor_refresh_worker.ex
@@ -0,0 +1,82 @@
+defmodule Elektrine.ActivityPub.ActorRefreshWorker do
+ @moduledoc """
+ Bounded refresh of stale remote actors and their profile metadata.
+ """
+
+ use Oban.Worker,
+ queue: :federation_metadata,
+ max_attempts: 2,
+ priority: 8,
+ unique: [
+ period: 3_600,
+ keys: [:type, :actor_id],
+ states: [:available, :scheduled, :executing, :retryable]
+ ]
+
+ import Ecto.Query
+
+ alias Elektrine.ActivityPub.{Actor, FederationLoadGuard}
+ alias Elektrine.Repo
+
+ @default_limit 200
+ @stale_hours 24
+
+ def enqueue(actor_id) when is_integer(actor_id) do
+ %{"type" => "single", "actor_id" => actor_id}
+ |> new()
+ |> Elektrine.JobQueue.insert()
+ end
+
+ @impl Oban.Worker
+ def perform(%Oban.Job{args: %{"type" => "stale"} = args}) do
+ if FederationLoadGuard.skip_nonessential?(__MODULE__) do
+ {:discard, :federation_overloaded}
+ else
+ args
+ |> Map.get("limit", @default_limit)
+ |> enqueue_stale_actor_batch()
+
+ :ok
+ end
+ end
+
+ def perform(%Oban.Job{args: %{"type" => "single", "actor_id" => actor_id}})
+ when is_integer(actor_id) do
+ if FederationLoadGuard.skip_nonessential?(__MODULE__) do
+ {:discard, :federation_overloaded}
+ else
+ refresh_actor(actor_id)
+ end
+ end
+
+ defp enqueue_stale_actor_batch(limit) do
+ limit = max(1, min(limit || @default_limit, @default_limit))
+ cutoff = DateTime.utc_now() |> DateTime.add(-@stale_hours, :hour)
+
+ Actor
+ |> where([a], not is_nil(a.uri))
+ |> where([a], is_nil(a.last_fetched_at) or a.last_fetched_at < ^cutoff)
+ |> order_by([a], asc: a.last_fetched_at, asc: a.id)
+ |> limit(^limit)
+ |> select([a], a.id)
+ |> Repo.all()
+ |> Enum.each(&enqueue/1)
+ end
+
+ defp refresh_actor(actor_id) do
+ case Repo.get(Actor, actor_id) do
+ %Actor{uri: uri} when is_binary(uri) ->
+ case Elektrine.ActivityPub.get_or_fetch_actor(uri) do
+ {:ok, _actor} -> :ok
+ {:error, :invalid_actor_uri} -> {:discard, :invalid_actor_uri}
+ {:error, reason} -> {:error, reason}
+ end
+
+ nil ->
+ {:discard, :actor_not_found}
+
+ _actor ->
+ {:discard, :missing_actor_uri}
+ end
+ end
+end
diff --git a/apps/elektrine/lib/elektrine/activitypub/builder.ex b/apps/elektrine/lib/elektrine/activitypub/builder.ex
new file mode 100644
index 0000000..2d2c564
--- /dev/null
+++ b/apps/elektrine/lib/elektrine/activitypub/builder.ex
@@ -0,0 +1,1340 @@
+defmodule Elektrine.ActivityPub.Builder do
+ @moduledoc """
+ Builds ActivityPub JSON-LD documents for actors, activities, and objects.
+ """
+
+ alias Elektrine.Accounts.User
+ alias Elektrine.ActivityPub
+ alias Elektrine.Messaging
+ alias Elektrine.Social.{Conversation, Message}
+
+ @toot_context %{
+ "toot" => "http://joinmastodon.org/ns#",
+ "discoverable" => "toot:discoverable",
+ "indexable" => "toot:indexable"
+ }
+ @actor_context [
+ "https://www.w3.org/ns/activitystreams",
+ "https://w3id.org/security/v1",
+ @toot_context
+ ]
+ @object_context ["https://www.w3.org/ns/activitystreams", @toot_context]
+
+ @doc """
+ Builds an Actor document for a user.
+ """
+ def build_actor(%User{} = user), do: build_actor(user, %{})
+
+ def build_actor(%User{} = user, opts) when is_map(opts) do
+ base_url = Map.get(opts, :base_url, ActivityPub.instance_url())
+ moved_to = Map.get(opts, :moved_to)
+ also_known_as = Map.get(opts, :also_known_as, [])
+ actor_identifier = Map.get(opts, :actor_identifier, ActivityPub.actor_identifier(user))
+ actor_url = ActivityPub.actor_uri(actor_identifier, base_url)
+ public_profile? = user.profile_visibility == "public"
+
+ %{
+ "@context" => @actor_context,
+ "id" => actor_url,
+ "type" => "Person",
+ "preferredUsername" => ActivityPub.actor_identifier(user),
+ "name" => actor_display_name(user, public_profile?),
+ "summary" => if(public_profile?, do: build_user_summary(user), else: nil),
+ "url" => actor_profile_url(user, base_url, actor_url, public_profile?),
+ "inbox" => "#{actor_url}/inbox",
+ "outbox" => "#{actor_url}/outbox",
+ "followers" => "#{actor_url}/followers",
+ "following" => "#{actor_url}/following",
+ "published" => format_datetime(user.inserted_at),
+ "manuallyApprovesFollowers" => user.activitypub_manually_approve_followers || false,
+ "discoverable" => user.profile_visibility == "public",
+ "indexable" => user.profile_visibility == "public",
+ "icon" => if(public_profile?, do: build_icon(user, base_url), else: nil),
+ "image" => if(public_profile?, do: build_header_image(user, base_url), else: nil),
+ "publicKey" => %{
+ "id" => "#{actor_url}#main-key",
+ "owner" => actor_url,
+ "publicKeyPem" => String.trim(user.activitypub_public_key || "")
+ },
+ "endpoints" => %{
+ "sharedInbox" => "#{base_url}/inbox"
+ }
+ }
+ |> maybe_put(
+ "attachment",
+ if(public_profile?, do: build_profile_attachments(user), else: nil)
+ )
+ |> maybe_put("movedTo", moved_to)
+ |> maybe_put_list("alsoKnownAs", also_known_as)
+ end
+
+ defp actor_display_name(user, true), do: user.display_name || user.username
+ defp actor_display_name(user, false), do: user.username
+
+ defp actor_profile_url(user, base_url, _actor_url, true) do
+ handle = if is_binary(user.handle) and user.handle != "", do: user.handle, else: user.username
+ "#{base_url}/#{handle}"
+ end
+
+ defp actor_profile_url(_user, _base_url, actor_url, false), do: actor_url
+
+ defp build_user_summary(user) do
+ # Get user profile bio if available
+ case user.profile do
+ %Ecto.Association.NotLoaded{} -> nil
+ nil -> nil
+ profile -> Map.get(profile, :description)
+ end
+ end
+
+ defp build_icon(user, base_url) do
+ if user.avatar do
+ # Convert avatar to full URL for federation
+ avatar_url = user.avatar |> Elektrine.Uploads.avatar_url() |> absolutize_url(base_url)
+
+ %{
+ "type" => "Image",
+ "mediaType" => guess_media_type(avatar_url),
+ "url" => avatar_url
+ }
+ else
+ nil
+ end
+ end
+
+ defp build_header_image(user, base_url) do
+ # Check if user has a banner image
+ case user.profile do
+ %Ecto.Association.NotLoaded{} ->
+ nil
+
+ nil ->
+ nil
+
+ profile ->
+ banner = Map.get(profile, :banner_url)
+
+ if banner do
+ # Ensure banner URL is absolute
+ banner_url =
+ if String.starts_with?(banner, "http") do
+ banner
+ else
+ "#{base_url}#{banner}"
+ end
+
+ %{
+ "type" => "Image",
+ "mediaType" => guess_media_type(banner_url),
+ "url" => banner_url
+ }
+ else
+ nil
+ end
+ end
+ end
+
+ defp build_profile_attachments(%User{profile: %Ecto.Association.NotLoaded{}}), do: nil
+ defp build_profile_attachments(%User{profile: nil}), do: nil
+
+ defp build_profile_attachments(%User{profile: profile}) do
+ case Map.get(profile, :links) do
+ %Ecto.Association.NotLoaded{} ->
+ nil
+
+ links when is_list(links) ->
+ links
+ |> Enum.filter(&profile_link_exportable?/1)
+ |> Enum.sort_by(&{Map.get(&1, :position) || 0, Map.get(&1, :id) || 0})
+ |> Enum.map(&build_profile_attachment/1)
+ |> case do
+ [] -> nil
+ attachments -> attachments
+ end
+
+ _ ->
+ nil
+ end
+ end
+
+ defp profile_link_exportable?(link) do
+ Map.get(link, :is_active, false) &&
+ present_text?(Map.get(link, :title)) &&
+ present_text?(Map.get(link, :url))
+ end
+
+ defp build_profile_attachment(link) do
+ %{
+ "type" => "PropertyValue",
+ "name" => String.trim(Map.get(link, :title)),
+ "value" => build_profile_attachment_value(String.trim(Map.get(link, :url)))
+ }
+ end
+
+ defp build_profile_attachment_value(url) do
+ escaped_url = escape_html(url)
+
+ rel =
+ if http_profile_link?(url) do
+ ~s( rel="me nofollow noopener noreferrer" target="_blank")
+ else
+ ~s( rel="nofollow noopener noreferrer")
+ end
+
+ ~s(#{escaped_url} )
+ end
+
+ defp escape_html(text) when is_binary(text) do
+ text
+ |> Phoenix.HTML.html_escape()
+ |> Phoenix.HTML.safe_to_string()
+ end
+
+ defp http_profile_link?(url) when is_binary(url) do
+ String.starts_with?(url, "http://") or String.starts_with?(url, "https://")
+ end
+
+ defp present_text?(value) when is_binary(value), do: Elektrine.Strings.present?(value)
+ defp present_text?(_value), do: false
+
+ defp absolutize_url(nil, _base_url), do: nil
+
+ defp absolutize_url(url, base_url) when is_binary(url) do
+ if String.starts_with?(url, "http://") or String.starts_with?(url, "https://") do
+ url
+ else
+ "#{String.trim_trailing(base_url, "/")}#{url}"
+ end
+ end
+
+ @doc """
+ Builds a Group actor document for a community/discussion (Lemmy-compatible).
+ """
+ def build_group(%Conversation{} = community) do
+ base_url = ActivityPub.instance_url()
+ community_slug = ActivityPub.community_slug(community.name)
+ actor_url = ActivityPub.community_actor_uri(community.name, base_url)
+ public_key = get_community_public_key(community)
+
+ %{
+ "@context" => [
+ "https://www.w3.org/ns/activitystreams",
+ "https://w3id.org/security/v1",
+ @toot_context,
+ %{
+ "lemmy" => "https://join-lemmy.org/ns#",
+ "moderators" => %{
+ "@type" => "@id",
+ "@id" => "lemmy:moderators"
+ },
+ "postingRestrictedToMods" => "lemmy:postingRestrictedToMods"
+ }
+ ],
+ "id" => actor_url,
+ "type" => "Group",
+ "preferredUsername" => community_slug,
+ "name" => community.name,
+ "summary" => community.description || "A community on #{ActivityPub.instance_domain()}",
+ "url" => ActivityPub.community_web_url(community.name, base_url),
+ "inbox" => ActivityPub.community_inbox_uri(community.name, base_url),
+ "outbox" => ActivityPub.community_outbox_uri(community.name, base_url),
+ "followers" => ActivityPub.community_followers_uri(community.name, base_url),
+ "moderators" => ActivityPub.community_moderators_uri(community.name, base_url),
+ "published" => format_datetime(community.inserted_at),
+ "manuallyApprovesFollowers" => !community.is_public,
+ "discoverable" => community.is_public,
+ "indexable" => community.is_public,
+ "postingRestrictedToMods" => false,
+ "icon" => build_community_icon(community),
+ "publicKey" => %{
+ "id" => "#{actor_url}#main-key",
+ "owner" => actor_url,
+ "publicKeyPem" => String.trim(public_key)
+ },
+ "endpoints" => %{
+ "sharedInbox" => "#{base_url}/inbox"
+ },
+ "attributedTo" => community_moderator_actor_uris(community, base_url)
+ }
+ end
+
+ defp get_community_public_key(community) do
+ case ActivityPub.get_or_create_community_actor(community.id) do
+ {:ok, actor} ->
+ actor =
+ case Elektrine.ActivityPub.KeyManager.ensure_user_has_keys(actor) do
+ {:ok, actor_with_keys} -> actor_with_keys
+ _ -> actor
+ end
+
+ actor.public_key || ""
+
+ {:error, _reason} ->
+ ""
+ end
+ end
+
+ defp build_community_icon(community) do
+ if community.avatar_url do
+ %{
+ "type" => "Image",
+ "mediaType" => guess_media_type(community.avatar_url),
+ "url" => community.avatar_url
+ }
+ else
+ nil
+ end
+ end
+
+ def community_moderator_actor_uris(
+ %Conversation{} = community,
+ base_url \\ ActivityPub.instance_url()
+ ) do
+ community
+ |> moderator_users()
+ |> Enum.map(&ActivityPub.actor_uri(&1, base_url))
+ end
+
+ defp moderator_users(%Conversation{} = community) do
+ community.id
+ |> Messaging.get_community_moderators()
+ |> Enum.map(& &1.user)
+ |> Enum.filter(&(&1 && &1.activitypub_enabled))
+ |> case do
+ [] -> creator_user(community)
+ users -> users
+ end
+ end
+
+ defp creator_user(%Conversation{creator_id: nil}), do: []
+
+ defp creator_user(%Conversation{creator_id: creator_id}) do
+ case Elektrine.Repo.get(User, creator_id) do
+ %User{activitypub_enabled: true} = user -> [user]
+ _ -> []
+ end
+ end
+
+ @doc """
+ Builds a Note object from a message.
+ """
+ def build_note(%Message{} = message, %User{} = user) do
+ base_url = ActivityPub.instance_url()
+
+ object_id =
+ message.activitypub_id || ActivityPub.user_status_uri(user, message.id, base_url)
+
+ # Check if posting to a community - walk up reply chain if needed
+ community_uri = get_community_uri_from_chain(message)
+
+ note = %{
+ "@context" => @object_context,
+ "id" => object_id,
+ "type" => "Note",
+ "attributedTo" => ActivityPub.actor_uri(user, base_url),
+ "content" => format_content(message),
+ "published" => format_datetime(message.inserted_at),
+ "to" => build_to_addresses(message, user, community_uri),
+ "cc" => build_cc_addresses(message, user),
+ "indexable" => message_indexable?(message),
+ "sensitive" => message.sensitive || false,
+ "attachment" => build_attachments(message),
+ "tag" => build_tags(message),
+ "url" => object_id,
+ "inReplyTo" => build_in_reply_to(message)
+ }
+
+ # Add audience for community posts
+ note =
+ if community_uri do
+ Map.put(note, "audience", community_uri)
+ else
+ note
+ end
+
+ note
+ # Mastodon only treats an Update as a content edit (rather than a silent
+ # metadata refresh) when the object carries an `updated` timestamp newer
+ # than `published`; without it, edits to plain Notes are ignored remotely.
+ |> maybe_put("updated", format_datetime(message.edited_at))
+ |> maybe_add_content_warning(message)
+ |> maybe_add_title_as_name(message)
+ |> maybe_add_quote_url(message)
+ end
+
+ @doc """
+ Formats a datetime for ActivityPub (ISO8601).
+ """
+ def format_datetime(%DateTime{} = dt), do: DateTime.to_iso8601(dt)
+
+ def format_datetime(%NaiveDateTime{} = ndt) do
+ # Convert NaiveDateTime to DateTime (assume UTC)
+ DateTime.from_naive!(ndt, "Etc/UTC") |> DateTime.to_iso8601()
+ end
+
+ def format_datetime(nil), do: nil
+
+ @doc """
+ Builds a community post object for local Group actors.
+ """
+ def build_community_note(%Message{} = post, %Conversation{} = community, opts \\ []) do
+ base_url = get_builder_opt(opts, :base_url, ActivityPub.instance_url())
+ community_actor_url = ActivityPub.community_actor_uri(community.name, base_url)
+ post_id = ActivityPub.community_post_uri(community.name, post.id, base_url)
+
+ author_uri =
+ case get_builder_opt(opts, :author_uri) do
+ value when is_binary(value) ->
+ if Elektrine.Strings.present?(value) do
+ value
+ else
+ case post.sender do
+ %{username: _username} = author -> ActivityPub.actor_uri(author, base_url)
+ _ -> community_actor_url
+ end
+ end
+
+ _ ->
+ case post.sender do
+ %{username: _username} = author -> ActivityPub.actor_uri(author, base_url)
+ _ -> community_actor_url
+ end
+ end
+
+ object_type = if post.reply_to_id, do: "Note", else: "Page"
+ cc = community_cc_addresses(post, community_actor_url)
+
+ %{
+ "@context" => @object_context,
+ "id" => post_id,
+ "type" => object_type,
+ "attributedTo" => author_uri,
+ "content" => format_html_content(post.content),
+ "mediaType" => "text/html",
+ "published" => format_datetime(post.inserted_at),
+ "to" => ["https://www.w3.org/ns/activitystreams#Public"],
+ "cc" => cc,
+ "attachment" => build_attachments(post),
+ "tag" => build_tags(post),
+ "audience" => community_actor_url,
+ "url" => ActivityPub.community_post_web_url(community.name, post.id, base_url),
+ "inReplyTo" => build_community_in_reply_to(post, community, base_url),
+ "indexable" => message_indexable?(post),
+ "sensitive" => post.sensitive || false,
+ "context" => community_actor_url,
+ "commentsEnabled" => is_nil(post.locked_at),
+ "stickied" => post.is_pinned || false,
+ "distinguished" => false
+ }
+ |> maybe_put("updated", format_datetime(post.edited_at))
+ |> maybe_put("summary", post.content_warning)
+ |> maybe_put("name", if(object_type == "Page", do: post.title, else: nil))
+ |> maybe_add_quote_url(post)
+ end
+
+ @doc """
+ Builds a local community object, choosing Question for polls.
+ """
+ def build_community_object(%Message{} = post, %Conversation{} = community, opts \\ []) do
+ case get_builder_opt(opts, :poll) do
+ %{options: _} = poll -> build_community_question(post, community, poll, opts)
+ _ -> build_community_note(post, community, opts)
+ end
+ end
+
+ defp format_content(message) do
+ format_html_content(message.content)
+ end
+
+ defp format_html_content(content) when is_binary(content) do
+ if Elektrine.Strings.present?(content) do
+ content
+ |> String.replace("\n", " ")
+ |> HtmlSanitizeEx.basic_html()
+ else
+ ""
+ end
+ end
+
+ defp format_html_content(_), do: ""
+
+ defp build_to_addresses(message, user, community_uri) do
+ base_url = ActivityPub.instance_url()
+
+ base =
+ case message.visibility do
+ "public" ->
+ ["https://www.w3.org/ns/activitystreams#Public"]
+
+ vis when vis in ["followers", "unlisted"] ->
+ # Unlisted uses the same primary audience as followers-only; the
+ # Public magic URI is demoted to `cc` (see build_cc_addresses), which
+ # is exactly how Mastodon distinguishes unlisted from public.
+ [ActivityPub.user_collection_uri(user, "followers", base_url)]
+
+ _ ->
+ # Direct/conversation - send to specific recipients
+ []
+ end
+
+ # Add community URI if posting to a community
+ if community_uri do
+ [community_uri | base]
+ else
+ base
+ end
+ end
+
+ defp build_cc_addresses(message, user) do
+ base_url = ActivityPub.instance_url()
+
+ base_cc =
+ case message.visibility do
+ "public" ->
+ # CC to followers when posting publicly
+ [ActivityPub.user_collection_uri(user, "followers", base_url)]
+
+ "unlisted" ->
+ # Public in cc (not to) = unlisted: reaches the public web but stays
+ # out of remote public/federated timelines.
+ ["https://www.w3.org/ns/activitystreams#Public"]
+
+ _ ->
+ []
+ end
+
+ # Add mentioned users (handle nil content)
+ mention_uris = mention_uris_for_message(message)
+
+ base_cc ++ mention_uris
+ end
+
+ defp build_attachments(message) do
+ if message.media_urls && message.media_urls != [] do
+ # Get alt texts from metadata if available
+ alt_texts =
+ if message.media_metadata && message.media_metadata["alt_texts"] do
+ message.media_metadata["alt_texts"]
+ else
+ %{}
+ end
+
+ Enum.with_index(message.media_urls)
+ |> Enum.map(fn {key, idx} ->
+ full_url = Elektrine.Uploads.attachment_url(key, message)
+ media_type = guess_media_type(full_url)
+
+ # Use appropriate ActivityPub type based on media type
+ attachment_type =
+ cond do
+ String.starts_with?(media_type, "image/") -> "Image"
+ String.starts_with?(media_type, "video/") -> "Video"
+ String.starts_with?(media_type, "audio/") -> "Audio"
+ true -> "Document"
+ end
+
+ # Build attachment object (Pixelfed-compatible)
+ attachment = %{
+ "type" => attachment_type,
+ "mediaType" => media_type,
+ "url" => full_url
+ }
+
+ # Add alt text (name field) if available
+ alt_text = Map.get(alt_texts, to_string(idx))
+
+ if Elektrine.Strings.present?(alt_text) do
+ Map.put(attachment, "name", alt_text)
+ else
+ attachment
+ end
+ end)
+ else
+ []
+ end
+ end
+
+ defp guess_media_type(url) do
+ cond do
+ String.ends_with?(url, [".jpg", ".jpeg"]) -> "image/jpeg"
+ String.ends_with?(url, ".png") -> "image/png"
+ String.ends_with?(url, ".gif") -> "image/gif"
+ String.ends_with?(url, ".webp") -> "image/webp"
+ String.ends_with?(url, ".mp4") -> "video/mp4"
+ String.ends_with?(url, ".webm") -> "video/webm"
+ String.ends_with?(url, ".ogv") -> "video/ogg"
+ String.ends_with?(url, ".mov") -> "video/quicktime"
+ String.ends_with?(url, ".mp3") -> "audio/mpeg"
+ String.ends_with?(url, ".ogg") -> "audio/ogg"
+ String.ends_with?(url, ".wav") -> "audio/wav"
+ String.ends_with?(url, ".m4a") -> "audio/mp4"
+ String.ends_with?(url, ".aac") -> "audio/aac"
+ String.ends_with?(url, ".flac") -> "audio/flac"
+ true -> "application/octet-stream"
+ end
+ end
+
+ defp build_tags(message) do
+ tags = []
+
+ # Add hashtags
+ hashtag_tags =
+ if message.extracted_hashtags && message.extracted_hashtags != [] do
+ Enum.map(message.extracted_hashtags, fn tag ->
+ %{
+ "type" => "Hashtag",
+ "name" => "##{tag}",
+ "href" => "#{Elektrine.ActivityPub.instance_url()}/tags/#{tag}"
+ }
+ end)
+ else
+ []
+ end
+
+ # Add mentions from content
+ mention_tags =
+ if message.content do
+ Elektrine.ActivityPub.Mentions.resolve_mentions(message.content)
+ |> Enum.map(fn mention ->
+ %{
+ "type" => "Mention",
+ "href" => mention.uri,
+ "name" => "@#{mention.handle}"
+ }
+ end)
+ else
+ []
+ end
+
+ tags ++ hashtag_tags ++ mention_tags
+ end
+
+ defp build_in_reply_to(message) do
+ if message.reply_to_id do
+ # Get the parent message
+ parent = Elektrine.Messaging.get_message(message.reply_to_id)
+
+ if parent && parent.activitypub_id do
+ parent.activitypub_id
+ else
+ nil
+ end
+ else
+ nil
+ end
+ end
+
+ # Add content warning as summary field (for sensitive content / spoiler text)
+ defp maybe_add_content_warning(note, message) do
+ if Elektrine.Strings.present?(message.content_warning) do
+ Map.put(note, "summary", message.content_warning)
+ else
+ note
+ end
+ end
+
+ # Add title as name field (for titled posts like Article-style)
+ defp maybe_add_title_as_name(note, message) do
+ if Elektrine.Strings.present?(message.title) do
+ Map.put(note, "name", message.title)
+ else
+ note
+ end
+ end
+
+ # Add quoteUrl for quote posts (Mastodon-style quoting)
+ defp maybe_add_quote_url(note, message) do
+ if message.quoted_message_id do
+ # Load the quoted message if not already loaded
+ quoted_message =
+ case message.quoted_message do
+ %Elektrine.Social.Message{} = qm -> qm
+ _ -> Elektrine.Repo.get(Elektrine.Social.Message, message.quoted_message_id)
+ end
+
+ if quoted_message && quoted_message.activitypub_id do
+ # Use quoteUrl field (Mastodon 4.3+ standard)
+ note
+ |> Map.put("quoteUrl", quoted_message.activitypub_id)
+ # Also add _misskey_quote for Misskey/Calckey compatibility
+ |> Map.put("_misskey_quote", quoted_message.activitypub_id)
+ else
+ note
+ end
+ else
+ note
+ end
+ end
+
+ @doc """
+ Builds a Create activity for a message.
+ """
+ def build_create_activity(%Message{} = message, %User{} = user) do
+ base_url = ActivityPub.instance_url()
+ note = build_note(message, user)
+ activity_id = "#{note["id"]}/activity"
+
+ %{
+ "@context" => @object_context,
+ "id" => activity_id,
+ "type" => "Create",
+ "actor" => ActivityPub.actor_uri(user, base_url),
+ "published" => format_datetime(message.inserted_at),
+ "to" => note["to"],
+ "cc" => note["cc"],
+ "object" => note
+ }
+ end
+
+ @doc """
+ Builds a Question object for a poll.
+ """
+ def build_question(%Message{} = message, %User{} = user, poll) do
+ base_url = ActivityPub.instance_url()
+
+ object_id =
+ message.activitypub_id || ActivityPub.user_status_uri(user, message.id, base_url)
+
+ community_uri = get_community_uri_from_chain(message)
+
+ %{
+ "@context" => "https://www.w3.org/ns/activitystreams",
+ "id" => object_id,
+ "type" => "Question",
+ "attributedTo" => ActivityPub.actor_uri(user, base_url),
+ "name" => poll.question,
+ "content" => poll_content(message, poll),
+ "published" => format_datetime(message.inserted_at),
+ "endTime" => poll_end_time(poll),
+ "closed" => format_datetime(poll.closes_at),
+ "votersCount" => poll_voters_count(poll),
+ poll_choice_key(poll) => build_poll_options(poll),
+ "to" => build_to_addresses(message, user, community_uri),
+ "cc" => build_cc_addresses(message, user),
+ "attachment" => build_attachments(message),
+ "tag" => build_tags(message),
+ "url" => object_id,
+ "inReplyTo" => build_in_reply_to(message),
+ "indexable" => message_indexable?(message),
+ "sensitive" => message.sensitive || false
+ }
+ |> maybe_put("audience", community_uri)
+ |> maybe_put("updated", format_datetime(message.edited_at))
+ |> maybe_add_content_warning(message)
+ |> maybe_add_quote_url(message)
+ end
+
+ defp message_indexable?(%Message{visibility: "public", is_draft: draft}) do
+ draft != true
+ end
+
+ defp message_indexable?(_), do: false
+
+ @doc """
+ Builds a Question object for a local community poll.
+ """
+ def build_community_question(%Message{} = post, %Conversation{} = community, poll, opts \\ []) do
+ base_url = get_builder_opt(opts, :base_url, ActivityPub.instance_url())
+ community_actor_url = ActivityPub.community_actor_uri(community.name, base_url)
+ post_id = ActivityPub.community_post_uri(community.name, post.id, base_url)
+
+ author_uri =
+ case get_builder_opt(opts, :author_uri) do
+ value when is_binary(value) ->
+ if Elektrine.Strings.present?(value) do
+ value
+ else
+ case post.sender do
+ %{username: _username} = author -> ActivityPub.actor_uri(author, base_url)
+ _ -> community_actor_url
+ end
+ end
+
+ _ ->
+ case post.sender do
+ %{username: _username} = author -> ActivityPub.actor_uri(author, base_url)
+ _ -> community_actor_url
+ end
+ end
+
+ %{
+ "@context" => @object_context,
+ "id" => post_id,
+ "type" => "Question",
+ "attributedTo" => author_uri,
+ "name" => post.title || poll.question,
+ "content" => poll_content(post, poll),
+ "mediaType" => "text/html",
+ "published" => format_datetime(post.inserted_at),
+ "endTime" => poll_end_time(poll),
+ "closed" => format_datetime(poll.closes_at),
+ "votersCount" => poll_voters_count(poll),
+ poll_choice_key(poll) => build_poll_options(poll),
+ "to" => ["https://www.w3.org/ns/activitystreams#Public"],
+ "cc" => community_cc_addresses(post, community_actor_url),
+ "attachment" => build_attachments(post),
+ "tag" => build_tags(post),
+ "audience" => community_actor_url,
+ "url" => ActivityPub.community_post_web_url(community.name, post.id, base_url),
+ "inReplyTo" => build_community_in_reply_to(post, community, base_url),
+ "indexable" => message_indexable?(post),
+ "sensitive" => post.sensitive || false,
+ "context" => community_actor_url,
+ "commentsEnabled" => is_nil(post.locked_at),
+ "stickied" => post.is_pinned || false,
+ "distinguished" => false
+ }
+ |> maybe_put("updated", format_datetime(post.edited_at))
+ |> maybe_put("summary", post.content_warning)
+ |> maybe_add_quote_url(post)
+ end
+
+ @doc """
+ Builds a Create activity for a local community object.
+ """
+ def build_community_create_activity(%Message{} = post, %Conversation{} = community, object) do
+ base_url = ActivityPub.instance_url()
+
+ %{
+ "@context" => "https://www.w3.org/ns/activitystreams",
+ "id" => "#{object["id"]}/activity",
+ "type" => "Create",
+ "actor" => ActivityPub.community_actor_uri(community.name, base_url),
+ "published" => format_datetime(post.inserted_at),
+ "to" => ["https://www.w3.org/ns/activitystreams#Public"],
+ "cc" => community_create_cc_addresses(post, community, base_url),
+ "object" => object
+ }
+ end
+
+ defp build_community_in_reply_to(%Message{reply_to_id: nil}, _community, _base_url), do: nil
+
+ defp build_community_in_reply_to(%Message{reply_to_id: reply_to_id}, community, base_url) do
+ case Elektrine.Messaging.get_message(reply_to_id) do
+ %Message{activitypub_id: activitypub_id, id: parent_id} when is_binary(activitypub_id) ->
+ if Elektrine.Strings.present?(activitypub_id) do
+ activitypub_id
+ else
+ ActivityPub.community_post_uri(community.name, parent_id, base_url)
+ end
+
+ %Message{id: parent_id} ->
+ ActivityPub.community_post_uri(community.name, parent_id, base_url)
+
+ _ ->
+ nil
+ end
+ end
+
+ defp get_builder_opt(opts, key, default \\ nil)
+
+ defp get_builder_opt(opts, key, default) when is_list(opts) do
+ Keyword.get(opts, key, default)
+ end
+
+ defp get_builder_opt(opts, key, default) when is_map(opts) do
+ Map.get(opts, key, default)
+ end
+
+ defp get_builder_opt(_, _, default), do: default
+
+ defp community_cc_addresses(%Message{} = post, community_actor_url) do
+ [community_actor_url | mention_uris_for_message(post)]
+ |> Enum.reject(&is_nil/1)
+ |> Enum.uniq()
+ end
+
+ defp community_create_cc_addresses(%Message{} = post, %Conversation{} = community, base_url) do
+ [
+ ActivityPub.community_followers_uri(community.name, base_url)
+ | mention_uris_for_message(post)
+ ]
+ |> Enum.reject(&is_nil/1)
+ |> Enum.uniq()
+ end
+
+ defp mention_uris_for_message(%Message{content: content}) when is_binary(content),
+ do: Elektrine.ActivityPub.Mentions.get_mention_uris(content)
+
+ defp mention_uris_for_message(_), do: []
+
+ defp poll_content(%Message{} = message, poll) do
+ case format_content(message) do
+ "" -> format_html_content(poll.question)
+ content -> content
+ end
+ end
+
+ defp build_poll_options(poll) do
+ poll.options
+ |> Enum.sort_by(& &1.position)
+ |> Enum.map(fn option ->
+ %{
+ "type" => "Note",
+ "name" => option.option_text,
+ "replies" => %{
+ "type" => "Collection",
+ "totalItems" => option.vote_count || 0
+ }
+ }
+ end)
+ end
+
+ defp poll_choice_key(poll) do
+ if poll.allow_multiple, do: "anyOf", else: "oneOf"
+ end
+
+ defp poll_voters_count(poll) do
+ poll.voters_count || poll.total_votes || 0
+ end
+
+ defp poll_end_time(poll) do
+ if poll.closes_at do
+ format_datetime(poll.closes_at)
+ else
+ DateTime.add(DateTime.utc_now(), 24 * 3600, :second) |> DateTime.to_iso8601()
+ end
+ end
+
+ @doc """
+ Builds a Follow activity.
+ """
+ def build_follow_activity(%User{} = follower, target_uri) do
+ base_url = ActivityPub.instance_url()
+ activity_id = "#{base_url}/activities/#{Ecto.UUID.generate()}"
+
+ %{
+ "@context" => "https://www.w3.org/ns/activitystreams",
+ "id" => activity_id,
+ "type" => "Follow",
+ "actor" => ActivityPub.actor_uri(follower, base_url),
+ "object" => target_uri,
+ "to" => [target_uri],
+ "published" => DateTime.utc_now() |> DateTime.to_iso8601()
+ }
+ end
+
+ @doc """
+ Builds a Move activity for account migration.
+ """
+ def build_move_activity(%User{}, old_actor_uri, new_actor_uri)
+ when is_binary(old_actor_uri) and is_binary(new_actor_uri) do
+ base_url = actor_base_url_from_uri(old_actor_uri) || ActivityPub.instance_url()
+ activity_id = "#{base_url}/activities/#{Ecto.UUID.generate()}"
+
+ %{
+ "@context" => "https://www.w3.org/ns/activitystreams",
+ "id" => activity_id,
+ "type" => "Move",
+ "actor" => old_actor_uri,
+ "object" => old_actor_uri,
+ "target" => new_actor_uri,
+ "to" => ["https://www.w3.org/ns/activitystreams#Public"],
+ "cc" => ["#{old_actor_uri}/followers"],
+ "published" => DateTime.utc_now() |> DateTime.to_iso8601()
+ }
+ end
+
+ @doc """
+ Builds an Accept activity (for accepting follows).
+ """
+ def build_accept_activity(%User{} = user, follow_activity) do
+ base_url = ActivityPub.instance_url()
+ activity_id = "#{base_url}/activities/#{Ecto.UUID.generate()}"
+
+ %{
+ "@context" => "https://www.w3.org/ns/activitystreams",
+ "id" => activity_id,
+ "type" => "Accept",
+ "actor" => ActivityPub.actor_uri(user, base_url),
+ "object" => follow_activity
+ }
+ end
+
+ @doc """
+ Builds a Reject activity (for rejecting follows).
+ """
+ def build_reject_activity(%User{} = user, follow_activity) do
+ base_url = ActivityPub.instance_url()
+ activity_id = "#{base_url}/activities/#{Ecto.UUID.generate()}"
+
+ %{
+ "@context" => "https://www.w3.org/ns/activitystreams",
+ "id" => activity_id,
+ "type" => "Reject",
+ "actor" => ActivityPub.actor_uri(user, base_url),
+ "object" => follow_activity
+ }
+ end
+
+ @doc """
+ Builds an Announce activity (boost/share).
+ """
+ def build_announce_activity(%User{} = user, object_uri) do
+ base_url = ActivityPub.instance_url()
+
+ # Deterministic id per (user, object): a user has at most one active boost of
+ # a given post, so a stable id is semantically correct AND lets Undo Announce
+ # reconstruct the exact original id when the stored activity row is missing —
+ # instead of minting a fresh UUID that strict remote servers can't match to
+ # the boost, leaving a ghost boost.
+ activity_id = "#{base_url}/activities/#{announce_ref(user, object_uri)}"
+
+ %{
+ "@context" => "https://www.w3.org/ns/activitystreams",
+ "id" => activity_id,
+ "type" => "Announce",
+ "actor" => ActivityPub.actor_uri(user, base_url),
+ "object" => object_uri,
+ "to" => ["https://www.w3.org/ns/activitystreams#Public"],
+ "cc" => [ActivityPub.user_collection_uri(user, "followers", base_url)],
+ "published" => DateTime.utc_now() |> DateTime.to_iso8601()
+ }
+ end
+
+ defp announce_ref(%User{id: user_id}, object_uri) do
+ :crypto.hash(:sha256, "announce:#{user_id}:#{object_uri}")
+ |> Base.url_encode64(padding: false)
+ |> binary_part(0, 24)
+ end
+
+ @doc """
+ Builds a Like activity.
+ """
+ def build_like_activity(%User{} = user, object_uri) do
+ base_url = ActivityPub.instance_url()
+ activity_id = "#{base_url}/activities/#{Ecto.UUID.generate()}"
+
+ %{
+ "@context" => "https://www.w3.org/ns/activitystreams",
+ "id" => activity_id,
+ "type" => "Like",
+ "actor" => ActivityPub.actor_uri(user, base_url),
+ "object" => object_uri,
+ "published" => DateTime.utc_now() |> DateTime.to_iso8601()
+ }
+ end
+
+ @doc """
+ Builds a Dislike activity (downvote).
+ """
+ def build_dislike_activity(%User{} = user, object_uri) do
+ base_url = ActivityPub.instance_url()
+ activity_id = "#{base_url}/activities/#{Ecto.UUID.generate()}"
+
+ %{
+ "@context" => "https://www.w3.org/ns/activitystreams",
+ "id" => activity_id,
+ "type" => "Dislike",
+ "actor" => ActivityPub.actor_uri(user, base_url),
+ "object" => object_uri,
+ "published" => DateTime.utc_now() |> DateTime.to_iso8601()
+ }
+ end
+
+ @doc """
+ Builds an EmojiReact activity (for custom emoji reactions).
+ """
+ def build_emoji_react_activity(%User{} = user, object_uri, emoji) do
+ base_url = ActivityPub.instance_url()
+ activity_id = "#{base_url}/activities/#{Ecto.UUID.generate()}"
+
+ %{
+ "@context" => "https://www.w3.org/ns/activitystreams",
+ "id" => activity_id,
+ "type" => "EmojiReact",
+ "actor" => ActivityPub.actor_uri(user, base_url),
+ "object" => object_uri,
+ "content" => emoji
+ }
+ end
+
+ @doc """
+ Builds a Block activity.
+ """
+ def build_block_activity(%User{} = user, target_uri) do
+ base_url = ActivityPub.instance_url()
+ activity_id = "#{base_url}/activities/#{Ecto.UUID.generate()}"
+
+ %{
+ "@context" => "https://www.w3.org/ns/activitystreams",
+ "id" => activity_id,
+ "type" => "Block",
+ "actor" => ActivityPub.actor_uri(user, base_url),
+ "object" => target_uri
+ }
+ end
+
+ @doc """
+ Builds an Undo activity (for undoing follows, likes, etc.).
+ """
+ def build_undo_activity(%User{} = user, original_activity) do
+ base_url = ActivityPub.instance_url()
+ activity_id = "#{base_url}/activities/#{Ecto.UUID.generate()}"
+
+ %{
+ "@context" => "https://www.w3.org/ns/activitystreams",
+ "id" => activity_id,
+ "type" => "Undo",
+ "actor" => ActivityPub.actor_uri(user, base_url),
+ "object" => original_activity,
+ "published" => DateTime.utc_now() |> DateTime.to_iso8601()
+ }
+ |> maybe_copy_routing_from_original(original_activity)
+ end
+
+ defp maybe_copy_routing_from_original(activity, original_activity)
+ when is_map(original_activity) do
+ Enum.reduce(["to", "cc", "audience", "context"], activity, fn field, acc ->
+ case Map.get(original_activity, field) do
+ nil -> acc
+ value -> Map.put(acc, field, value)
+ end
+ end)
+ end
+
+ defp maybe_copy_routing_from_original(activity, _), do: activity
+
+ @doc """
+ Builds a Delete activity with a Tombstone object.
+ """
+ def build_delete_activity(actor_or_user, object_uri, former_type \\ "Note", opts \\ [])
+
+ def build_delete_activity(%User{} = user, object_uri, former_type, opts) do
+ base_url = ActivityPub.instance_url()
+ activity_id = "#{base_url}/activities/#{Ecto.UUID.generate()}"
+ {to, cc} = activity_audience(nil, opts, ["https://www.w3.org/ns/activitystreams#Public"], [])
+
+ tombstone = %{
+ "type" => "Tombstone",
+ "id" => object_uri,
+ "formerType" => former_type,
+ "deleted" => DateTime.utc_now() |> DateTime.to_iso8601()
+ }
+
+ %{
+ "@context" => "https://www.w3.org/ns/activitystreams",
+ "id" => activity_id,
+ "type" => "Delete",
+ "actor" => ActivityPub.actor_uri(user, base_url),
+ "object" => tombstone
+ }
+ |> maybe_put_list("to", to)
+ |> maybe_put_list("cc", cc)
+ end
+
+ def build_delete_activity(
+ %Elektrine.ActivityPub.Actor{} = actor,
+ object_uri,
+ former_type,
+ opts
+ ) do
+ activity_id = "#{actor.uri}/activities/#{Ecto.UUID.generate()}"
+ {to, cc} = activity_audience(nil, opts, ["https://www.w3.org/ns/activitystreams#Public"], [])
+
+ tombstone = %{
+ "type" => "Tombstone",
+ "id" => object_uri,
+ "formerType" => former_type,
+ "deleted" => DateTime.utc_now() |> DateTime.to_iso8601()
+ }
+
+ %{
+ "@context" => "https://www.w3.org/ns/activitystreams",
+ "id" => activity_id,
+ "type" => "Delete",
+ "actor" => actor.uri,
+ "object" => tombstone
+ }
+ |> maybe_put_list("to", to)
+ |> maybe_put_list("cc", cc)
+ end
+
+ @doc """
+ Builds an Update activity (for profile or post updates).
+ """
+ def build_update_activity(actor_or_user, object, opts \\ [])
+
+ def build_update_activity(%User{} = user, object, opts) do
+ base_url = ActivityPub.instance_url()
+ activity_id = "#{base_url}/activities/#{Ecto.UUID.generate()}"
+
+ {to, cc} =
+ activity_audience(object, opts, ["https://www.w3.org/ns/activitystreams#Public"], [])
+
+ %{
+ "@context" => "https://www.w3.org/ns/activitystreams",
+ "id" => activity_id,
+ "type" => "Update",
+ "actor" => ActivityPub.actor_uri(user, base_url),
+ "object" => object
+ }
+ |> maybe_put_list("to", to)
+ |> maybe_put_list("cc", cc)
+ end
+
+ def build_update_activity(%Elektrine.ActivityPub.Actor{} = actor, object, opts) do
+ activity_id = "#{actor.uri}/activities/#{Ecto.UUID.generate()}"
+
+ {to, cc} =
+ activity_audience(object, opts, ["https://www.w3.org/ns/activitystreams#Public"], [])
+
+ %{
+ "@context" => "https://www.w3.org/ns/activitystreams",
+ "id" => activity_id,
+ "type" => "Update",
+ "actor" => actor.uri,
+ "object" => object
+ }
+ |> maybe_put_list("to", to)
+ |> maybe_put_list("cc", cc)
+ end
+
+ @doc """
+ Builds a Flag (report) activity for reporting users or content to remote instances.
+
+ ## Parameters
+ - `user` - The user making the report
+ - `target_actor_uri` - The AP URI of the user being reported
+ - `object_uris` - List of content URIs being reported (posts, etc.)
+ - `content` - The report reason/description
+
+ ## Example
+ build_flag_activity(user, "https://remote.server/users/baduser",
+ ["https://remote.server/posts/123"], "Spam content")
+ """
+ def build_flag_activity(%User{} = user, target_actor_uri, object_uris \\ [], content \\ nil) do
+ base_url = ActivityPub.instance_url()
+ activity_id = "#{base_url}/activities/#{Ecto.UUID.generate()}"
+
+ # Object is an array containing the reported actor and optionally reported content
+ objects =
+ [target_actor_uri | List.wrap(object_uris)]
+ |> Enum.filter(& &1)
+ |> Enum.uniq()
+
+ activity = %{
+ "@context" => "https://www.w3.org/ns/activitystreams",
+ "id" => activity_id,
+ "type" => "Flag",
+ "actor" => ActivityPub.actor_uri(user, base_url),
+ "object" => objects
+ }
+
+ # Add content/reason if provided
+ if Elektrine.Strings.present?(content) do
+ Map.put(activity, "content", content)
+ else
+ activity
+ end
+ end
+
+ defp maybe_put(map, _key, nil), do: map
+ defp maybe_put(map, _key, ""), do: map
+ defp maybe_put(map, key, value), do: Map.put(map, key, value)
+
+ defp activity_audience(object, opts, default_to, default_cc) do
+ to =
+ select_audience(
+ get_builder_opt(opts, :to, object_audience(object, "to")),
+ default_to
+ )
+
+ cc =
+ select_audience(
+ get_builder_opt(opts, :cc, object_audience(object, "cc")),
+ default_cc
+ )
+
+ {to, cc}
+ end
+
+ defp object_audience(object, field) when is_map(object), do: Map.get(object, field)
+ defp object_audience(_object, _field), do: nil
+
+ defp select_audience(value, fallback) do
+ case normalize_audience(value) do
+ [] -> normalize_audience(fallback)
+ audience -> audience
+ end
+ end
+
+ defp normalize_audience(values) when is_list(values) do
+ values
+ |> Enum.map(&Elektrine.Strings.present/1)
+ |> Enum.reject(&is_nil/1)
+ |> Enum.uniq()
+ end
+
+ defp normalize_audience(value) when is_binary(value) do
+ if Elektrine.Strings.present?(value) do
+ [value]
+ else
+ []
+ end
+ end
+
+ defp normalize_audience(_value), do: []
+
+ defp maybe_put_list(map, _key, values) when values in [nil, []], do: map
+
+ defp maybe_put_list(map, key, values) when is_list(values) do
+ normalized =
+ values
+ |> Enum.map(&Elektrine.Strings.present/1)
+ |> Enum.reject(&is_nil/1)
+ |> Enum.uniq()
+
+ if normalized == [] do
+ map
+ else
+ Map.put(map, key, normalized)
+ end
+ end
+
+ defp actor_base_url_from_uri(uri) when is_binary(uri) do
+ case URI.parse(uri) do
+ %URI{scheme: scheme, host: host, port: port}
+ when scheme in ["http", "https"] and is_binary(host) ->
+ if is_nil(port) do
+ "#{scheme}://#{host}"
+ else
+ "#{scheme}://#{host}:#{port}"
+ end
+
+ _ ->
+ nil
+ end
+ end
+
+ # Walk up the reply chain to find community_actor_uri (for Lemmy posts)
+ # This ensures replies to replies in a Lemmy thread have the correct audience
+ defp get_community_uri_from_chain(message, depth \\ 0)
+
+ defp get_community_uri_from_chain(_message, depth) when depth > 10 do
+ # Prevent infinite loops - max 10 levels deep
+ nil
+ end
+
+ defp get_community_uri_from_chain(message, depth) do
+ # Check current message's metadata
+ case get_in(message.media_metadata || %{}, ["community_actor_uri"]) do
+ uri when is_binary(uri) ->
+ uri
+
+ nil ->
+ # Check parent message if this is a reply
+ if message.reply_to_id do
+ parent = Elektrine.Messaging.get_message(message.reply_to_id)
+
+ if parent do
+ get_community_uri_from_chain(parent, depth + 1)
+ else
+ nil
+ end
+ else
+ nil
+ end
+ end
+ end
+end
diff --git a/apps/elektrine/lib/elektrine/activitypub/collection_count_sync_worker.ex b/apps/elektrine/lib/elektrine/activitypub/collection_count_sync_worker.ex
new file mode 100644
index 0000000..4bafe89
--- /dev/null
+++ b/apps/elektrine/lib/elektrine/activitypub/collection_count_sync_worker.ex
@@ -0,0 +1,101 @@
+defmodule Elektrine.ActivityPub.CollectionCountSyncWorker do
+ @moduledoc """
+ Fetches collection-backed like/reply counts for a cached remote message.
+ """
+
+ use Oban.Worker,
+ queue: :federation,
+ max_attempts: 2,
+ unique: [
+ period: 300,
+ keys: [:message_id],
+ states: [:available, :scheduled, :executing, :retryable]
+ ]
+
+ import Ecto.Query
+
+ alias Elektrine.ActivityPub.CollectionFetcher
+ alias Elektrine.Repo
+ alias Elektrine.Social.{EngagementCounts, Message, MessageStats}
+
+ def enqueue(message_id, post) when is_integer(message_id) and is_map(post) do
+ %{
+ "message_id" => message_id,
+ "likes" => Map.get(post, "likes"),
+ "replies" => Map.get(post, "replies"),
+ "comments" => Map.get(post, "comments"),
+ "replies_count" => Map.get(post, "repliesCount")
+ }
+ |> new()
+ |> Elektrine.JobQueue.insert()
+ end
+
+ def enqueue(_, _), do: {:error, :invalid_collection_sync}
+
+ @impl Oban.Worker
+ def perform(%Oban.Job{args: %{"message_id" => message_id} = args}) do
+ like_count = fetch_collection_count(args["likes"])
+
+ reply_count =
+ [
+ fetch_collection_count(args["replies"]),
+ fetch_collection_count(args["comments"]),
+ parse_non_negative_count(args["replies_count"])
+ ]
+ |> Enum.max(fn -> 0 end)
+
+ if like_count > 0 || reply_count > 0 do
+ fetched_at = DateTime.utc_now() |> DateTime.truncate(:second)
+ remote_like_count = EngagementCounts.nullable_remote_count(like_count)
+ remote_reply_count = EngagementCounts.nullable_remote_count(reply_count)
+
+ Repo.update_all(
+ from(m in Message,
+ where: m.id == ^message_id,
+ update: [
+ set: [
+ like_count: fragment("GREATEST(like_count, ?)", ^like_count),
+ reply_count: fragment("GREATEST(reply_count, ?)", ^reply_count),
+ remote_like_count: ^remote_like_count,
+ remote_reply_count: ^remote_reply_count,
+ remote_counts_fetched_at: ^fetched_at
+ ]
+ ]
+ ),
+ []
+ )
+
+ MessageStats.upsert_counts(message_id, %{
+ like_count: like_count,
+ reply_count: reply_count,
+ remote_like_count: remote_like_count,
+ remote_reply_count: remote_reply_count,
+ remote_counts_fetched_at: fetched_at
+ })
+ end
+
+ :ok
+ end
+
+ defp fetch_collection_count(nil), do: 0
+
+ defp fetch_collection_count(value) when is_binary(value) or is_map(value) do
+ case CollectionFetcher.fetch_collection_count(value) do
+ {:ok, count} -> parse_non_negative_count(count)
+ _ -> 0
+ end
+ end
+
+ defp fetch_collection_count(_), do: 0
+
+ defp parse_non_negative_count(value) when is_integer(value), do: max(value, 0)
+
+ defp parse_non_negative_count(value) when is_binary(value) do
+ case Integer.parse(String.trim(value)) do
+ {parsed, _} -> max(parsed, 0)
+ :error -> 0
+ end
+ end
+
+ defp parse_non_negative_count(_), do: 0
+end
diff --git a/apps/elektrine/lib/elektrine/activitypub/collection_fetcher.ex b/apps/elektrine/lib/elektrine/activitypub/collection_fetcher.ex
new file mode 100644
index 0000000..15a3ba6
--- /dev/null
+++ b/apps/elektrine/lib/elektrine/activitypub/collection_fetcher.ex
@@ -0,0 +1,316 @@
+defmodule Elektrine.ActivityPub.CollectionFetcher do
+ @moduledoc """
+ Fetches and paginates through ActivityPub collections.
+
+ Handles various collection formats:
+ - Collection / OrderedCollection
+ - CollectionPage / OrderedCollectionPage
+ - Paginated collections with `first`, `next` links
+
+ Based on: https://www.w3.org/TR/activitystreams-core/#paging
+ """
+
+ alias Elektrine.ActivityPub.RemoteFetch
+ require Logger
+
+ @max_collection_items Application.compile_env(
+ :elektrine,
+ [:activitypub, :max_collection_items],
+ 100
+ )
+ @max_pages Application.compile_env(:elektrine, [:activitypub, :max_collection_pages], 10)
+
+ @type fetch_result :: {:ok, list(map())} | {:partial, list(map())} | {:error, any()}
+
+ @doc """
+ Fetches items from an ActivityPub collection.
+
+ Accepts either a URL string or a collection object map.
+ Automatically handles pagination up to configured limits.
+
+ ## Options
+ - `:max_items` - Maximum items to fetch (default: #{@max_collection_items})
+ - `:max_pages` - Maximum pages to traverse (default: #{@max_pages})
+
+ ## Examples
+
+ iex> fetch_collection("https://example.com/users/alice/likes")
+ {:ok, [%{"id" => "...", "type" => "Like"}, ...]}
+
+ iex> fetch_collection(%{"type" => "Collection", "items" => [...]})
+ {:ok, [...]}
+ """
+ @spec fetch_collection(String.t() | map(), keyword()) :: fetch_result()
+ def fetch_collection(collection, opts \\ [])
+
+ def fetch_collection(url, opts) when is_binary(url) do
+ case RemoteFetch.fetch_object(url, fetch_object_opts(opts)) do
+ {:ok, page} ->
+ collect_from_page(page, opts)
+
+ {:error, reason} ->
+ Logger.warning("Could not fetch collection #{url}: #{inspect(reason)}")
+ {:error, reason}
+ end
+ end
+
+ def fetch_collection(%{"type" => type} = collection, opts)
+ when type in ["Collection", "OrderedCollection", "CollectionPage", "OrderedCollectionPage"] do
+ cond do
+ collection_page_content?(collection) ->
+ collect_from_page(collection, opts)
+
+ count_only_reference?(collection) ->
+ fetch_collection(collection["id"], opts)
+
+ true ->
+ collect_from_page(collection, opts)
+ end
+ end
+
+ # Untyped collection references: use embedded content when present, otherwise
+ # follow the "id" URL (count-only refs like %{"id" => ..., "totalItems" => 74}).
+ def fetch_collection(%{} = collection, opts) do
+ cond do
+ items_from_page(collection) != [] or is_map(collection["first"]) or
+ is_binary(collection["first"]) or is_binary(collection["next"]) ->
+ collect_from_page(collection, opts)
+
+ is_binary(collection["id"]) ->
+ fetch_collection(collection["id"], opts)
+
+ true ->
+ {:ok, []}
+ end
+ end
+
+ def fetch_collection(nil, _opts), do: {:ok, []}
+ def fetch_collection(_, _opts), do: {:error, :invalid_collection}
+
+ @doc """
+ Fetches only the count from a collection without fetching items.
+
+ More efficient when you only need the total.
+ """
+ @spec fetch_collection_count(String.t() | map()) :: {:ok, non_neg_integer()} | {:error, any()}
+ def fetch_collection_count(url) when is_binary(url) do
+ case RemoteFetch.fetch_object(url) do
+ {:ok, collection} ->
+ count_collection_items(collection)
+
+ {:error, reason} ->
+ {:error, reason}
+ end
+ end
+
+ def fetch_collection_count(collection) when is_map(collection),
+ do: count_collection_items(collection)
+
+ def fetch_collection_count(_), do: {:ok, 0}
+
+ @doc """
+ Fetches actor IDs from a likes/shares collection.
+
+ Returns a list of actor AP IDs who performed the interaction.
+ """
+ @spec fetch_interaction_actors(String.t() | map(), keyword()) ::
+ {:ok, list(String.t())} | {:error, any()}
+ def fetch_interaction_actors(collection, opts \\ []) do
+ case fetch_collection(collection, opts) do
+ {:ok, items} ->
+ actors = extract_actors(items)
+ {:ok, actors}
+
+ {:partial, items} ->
+ actors = extract_actors(items)
+ {:ok, actors}
+
+ error ->
+ error
+ end
+ end
+
+ # Private functions
+
+ defp count_collection_items(%{"totalItems" => count}) when is_integer(count) do
+ {:ok, max(count, 0)}
+ end
+
+ defp count_collection_items(%{"totalItems" => count} = collection) when is_binary(count) do
+ case parse_count(count) do
+ {:ok, parsed_count} ->
+ {:ok, parsed_count}
+
+ :error ->
+ count_collection_items_without_total(collection)
+ end
+ end
+
+ defp count_collection_items(%{"items" => items}) when is_list(items), do: {:ok, length(items)}
+
+ defp count_collection_items(%{"orderedItems" => items}) when is_list(items),
+ do: {:ok, length(items)}
+
+ defp count_collection_items(collection) when is_map(collection) do
+ count_collection_items_without_total(collection)
+ end
+
+ defp count_collection_items(_), do: {:ok, 0}
+
+ defp count_collection_items_without_total(collection) do
+ case collect_from_page(collection, max_items: 1000) do
+ {:ok, items} -> {:ok, length(items)}
+ {:partial, items} -> {:ok, length(items)}
+ error -> error
+ end
+ end
+
+ defp parse_count(value) when is_binary(value) do
+ case Integer.parse(String.trim(value)) do
+ {count, ""} -> {:ok, max(count, 0)}
+ _ -> :error
+ end
+ end
+
+ defp normalize_count(value) when is_integer(value), do: max(value, 0)
+
+ defp normalize_count(value) when is_binary(value) do
+ case parse_count(value) do
+ {:ok, count} -> count
+ :error -> 0
+ end
+ end
+
+ defp normalize_count(_), do: 0
+
+ defp collect_from_page(page, opts) do
+ max_items = Keyword.get(opts, :max_items, @max_collection_items)
+ max_pages = Keyword.get(opts, :max_pages, @max_pages)
+ fetch_opts = fetch_object_opts(opts)
+
+ do_collect(page, [], max_items, max_pages, 0, fetch_opts)
+ end
+
+ defp do_collect(_page, items, max_items, _max_pages, _page_count, _fetch_opts)
+ when length(items) >= max_items do
+ {:ok, Enum.take(items, max_items)}
+ end
+
+ defp do_collect(_page, items, _max_items, max_pages, page_count, _fetch_opts)
+ when page_count >= max_pages do
+ if items == [] do
+ {:ok, []}
+ else
+ {:partial, items}
+ end
+ end
+
+ defp do_collect(page, items, max_items, max_pages, page_count, fetch_opts) do
+ current_items = items_from_page(page)
+ all_items = items ++ current_items
+
+ cond do
+ # Reached item limit
+ length(all_items) >= max_items ->
+ {:ok, Enum.take(all_items, max_items)}
+
+ # Has next page
+ next_ref = get_next_page(page) ->
+ follow_page(next_ref, all_items, max_items, max_pages, page_count, fetch_opts)
+
+ # Has first page reference (for Collection pointing to first CollectionPage)
+ first_ref = get_first_page(page) ->
+ follow_page(first_ref, all_items, max_items, max_pages, page_count, fetch_opts)
+
+ # No more pages
+ true ->
+ {:ok, all_items}
+ end
+ end
+
+ # Mastodon embeds the first CollectionPage inline (without an "id"), so
+ # embedded page maps are traversed directly instead of re-fetched by URL.
+ defp follow_page({:embedded, page}, items, max_items, max_pages, page_count, fetch_opts) do
+ do_collect(page, items, max_items, max_pages, page_count + 1, fetch_opts)
+ end
+
+ defp follow_page(url, items, max_items, max_pages, page_count, fetch_opts)
+ when is_binary(url) do
+ case RemoteFetch.fetch_object(url, fetch_opts) do
+ {:ok, page} ->
+ do_collect(page, items, max_items, max_pages, page_count + 1, fetch_opts)
+
+ {:error, _reason} ->
+ # Failed to fetch the page, return what we have
+ if items == [] do
+ {:error, :fetch_failed}
+ else
+ {:partial, items}
+ end
+ end
+ end
+
+ defp items_from_page(%{"orderedItems" => items}) when is_list(items), do: items
+ defp items_from_page(%{"items" => items}) when is_list(items), do: items
+ defp items_from_page(_), do: []
+
+ defp collection_page_content?(collection) do
+ items_from_page(collection) != [] or is_map(collection["first"]) or
+ is_binary(collection["first"]) or is_binary(collection["next"])
+ end
+
+ defp count_only_reference?(%{"id" => id} = collection) when is_binary(id) do
+ collection["totalItems"]
+ |> normalize_count()
+ |> Kernel.>(0)
+ end
+
+ defp count_only_reference?(_), do: false
+
+ defp get_next_page(%{"next" => next}) when is_binary(next), do: next
+ defp get_next_page(%{"next" => %{} = next}), do: page_ref(next)
+ defp get_next_page(_), do: nil
+
+ defp get_first_page(%{"first" => first}) when is_binary(first), do: first
+ defp get_first_page(%{"first" => %{} = first}), do: page_ref(first)
+ defp get_first_page(_), do: nil
+
+ # An embedded page with content is used as-is; a bare reference like
+ # %{"id" => url} still needs to be fetched.
+ defp page_ref(%{} = page) do
+ if items_from_page(page) != [] or get_next_page(page) != nil do
+ {:embedded, page}
+ else
+ case page["id"] do
+ id when is_binary(id) -> id
+ _ -> nil
+ end
+ end
+ end
+
+ defp fetch_object_opts(opts) do
+ opts
+ |> Keyword.take([:request_fun, :skip_cache, :sign, :validate_url, :allow_recovery])
+ |> Enum.reject(fn {_key, value} -> is_nil(value) end)
+ end
+
+ defp extract_actors(items) do
+ items
+ |> Enum.map(&extract_actor/1)
+ |> Enum.reject(&is_nil/1)
+ |> Enum.uniq()
+ end
+
+ # Like/Announce activity - actor is in "actor" field
+ defp extract_actor(%{"actor" => actor}) when is_binary(actor), do: actor
+ defp extract_actor(%{"actor" => %{"id" => id}}) when is_binary(id), do: id
+
+ # Direct actor reference (some implementations just list actor IDs)
+ defp extract_actor(actor) when is_binary(actor), do: actor
+
+ # Actor object
+ defp extract_actor(%{"id" => id, "type" => type})
+ when type in ["Person", "Service", "Application", "Group"], do: id
+
+ defp extract_actor(_), do: nil
+end
diff --git a/apps/elektrine/lib/elektrine/activitypub/community_fetcher.ex b/apps/elektrine/lib/elektrine/activitypub/community_fetcher.ex
new file mode 100644
index 0000000..bbdb9fd
--- /dev/null
+++ b/apps/elektrine/lib/elektrine/activitypub/community_fetcher.ex
@@ -0,0 +1,69 @@
+defmodule Elektrine.ActivityPub.CommunityFetcher do
+ @moduledoc false
+
+ require Logger
+
+ alias Elektrine.Social.Message
+
+ def handle_info(:fetch_community_posts, state) when is_map(state) do
+ config = Application.get_env(:elektrine, :community_fetcher, [])
+ followed_communities = Keyword.get(config, :followed_communities, fn -> [] end)
+ activitypub = Keyword.get(config, :activitypub, Elektrine.ActivityPub)
+ handler = Keyword.get(config, :handler, Elektrine.ActivityPub.Handler)
+ messaging = Keyword.get(config, :messaging, Elektrine.Messaging)
+ sleep = Keyword.get(config, :sleep, fn _ -> :ok end)
+
+ followed_communities.()
+ |> Enum.each(fn community_actor ->
+ fetch_and_store_posts(activitypub, handler, messaging, sleep, community_actor)
+ end)
+
+ {:noreply, state}
+ end
+
+ def handle_info(_message, state), do: {:noreply, state}
+
+ defp fetch_and_store_posts(activitypub, handler, messaging, sleep, community_actor) do
+ case activitypub.fetch_remote_user_timeline(community_actor.uri, []) do
+ {:ok, posts} when is_list(posts) ->
+ Enum.each(posts, fn post_object ->
+ process_post(handler, messaging, community_actor, post_object)
+ sleep.(:between_posts)
+ end)
+
+ _ ->
+ :ok
+ end
+ end
+
+ defp process_post(handler, messaging, community_actor, post_object) when is_map(post_object) do
+ actor_uri = post_object["attributedTo"] || community_actor.uri
+
+ try do
+ case handler.store_remote_post(post_object, actor_uri) do
+ {:ok, %Message{} = message} ->
+ metadata =
+ Map.merge(message.media_metadata || %{}, %{
+ "community_actor_uri" => community_actor.uri
+ })
+
+ _ = messaging.update_message(message, %{media_metadata: metadata})
+ :ok
+
+ _ ->
+ :ok
+ end
+ rescue
+ error ->
+ post_id = Map.get(post_object, "id")
+
+ Logger.warning(
+ "Community fetch ingest failed for #{inspect(post_id)}: #{Exception.message(error)}"
+ )
+
+ :ok
+ end
+ end
+
+ defp process_post(_handler, _messaging, _community_actor, _post_object), do: :ok
+end
diff --git a/apps/elektrine/lib/elektrine/activitypub/containment.ex b/apps/elektrine/lib/elektrine/activitypub/containment.ex
new file mode 100644
index 0000000..93ec7cf
--- /dev/null
+++ b/apps/elektrine/lib/elektrine/activitypub/containment.ex
@@ -0,0 +1,145 @@
+defmodule Elektrine.ActivityPub.Containment do
+ @moduledoc """
+ Origin containment checks for remote ActivityPub payloads.
+
+ These checks prevent remote servers from smuggling objects that claim to be
+ authored by another host or by this instance.
+ """
+
+ alias Elektrine.ActivityPub
+
+ @actor_types ["Person", "Group", "Application", "Service", "Organization"]
+
+ def validate_fetch(uri, object) when is_binary(uri) and is_map(object) do
+ with :ok <- contain_local_fetch(uri),
+ :ok <- contain_fetch_origin(uri, object) do
+ contain_child(object)
+ end
+ end
+
+ def validate_fetch(_uri, _object), do: :ok
+
+ def validate_activity(%{"type" => "Create", "object" => object} = activity)
+ when is_map(object) do
+ with :ok <- contain_origin(activity),
+ :ok <- contain_origin(object) do
+ contain_child(activity)
+ end
+ end
+
+ def validate_activity(%{"type" => "Update", "object" => object} = activity)
+ when is_map(object) do
+ with :ok <- contain_origin(activity),
+ :ok <- contain_origin(object) do
+ contain_child(activity)
+ end
+ end
+
+ def validate_activity(%{"type" => type} = activity) when type in @actor_types do
+ contain_origin_from_id(actor_uri(activity), activity)
+ end
+
+ def validate_activity(%{"type" => _type} = activity), do: contain_origin(activity)
+ def validate_activity(_), do: {:error, :origin_containment_failed}
+
+ def contain_local_fetch(id) when is_binary(id) do
+ with %URI{host: host} when is_binary(host) <- URI.parse(id),
+ local when is_binary(local) <- ActivityPub.instance_domain(),
+ true <- String.downcase(host) == String.downcase(local) do
+ {:error, :local_fetch_containment_failed}
+ else
+ _ -> :ok
+ end
+ end
+
+ def contain_local_fetch(_), do: :ok
+
+ def contain_origin(%{"id" => id} = object) when is_binary(id) do
+ case actor_uri(object) do
+ actor when is_binary(actor) -> same_origin(actor, id)
+ _ -> {:error, :origin_containment_failed}
+ end
+ end
+
+ def contain_origin(_), do: :ok
+
+ def contain_origin_from_id(id, %{"id" => other_id})
+ when is_binary(id) and is_binary(other_id) do
+ same_origin(id, other_id)
+ end
+
+ def contain_origin_from_id(id, %{"object" => object})
+ when is_binary(id) and is_binary(object) do
+ same_origin(id, object)
+ end
+
+ def contain_origin_from_id(_id, _object), do: :ok
+
+ def contain_fetch_origin(uri, %{"_source_url" => source_url} = object)
+ when is_binary(uri) and is_binary(source_url) do
+ if comparable_uri(uri) == comparable_uri(source_url) do
+ contain_origin(object)
+ else
+ contain_origin_from_id(uri, object)
+ end
+ end
+
+ def contain_fetch_origin(uri, object), do: contain_origin_from_id(uri, object)
+
+ def contain_child(%{"object" => %{"id" => id} = object}) when is_binary(id) do
+ contain_origin(object)
+ end
+
+ def contain_child(_), do: :ok
+
+ def actor_uri(%{"actor" => actor}), do: normalize_actor(actor)
+ def actor_uri(%{"attributedTo" => actor}), do: normalize_actor(actor)
+ def actor_uri(%{"id" => id, "type" => type}) when type in @actor_types and is_binary(id), do: id
+ def actor_uri(_), do: nil
+
+ defp normalize_actor(actor) when is_binary(actor), do: actor
+ defp normalize_actor(%{"id" => id}) when is_binary(id), do: id
+
+ defp normalize_actor([first | _]) when is_binary(first), do: first
+
+ defp normalize_actor(actors) when is_list(actors) do
+ Enum.find_value(actors, fn
+ %{"type" => type, "id" => id} when type in @actor_types and is_binary(id) -> id
+ _ -> nil
+ end)
+ end
+
+ defp normalize_actor(_), do: nil
+
+ defp same_origin(left, right) when is_binary(left) and is_binary(right) do
+ with %URI{host: left_host} when is_binary(left_host) <- URI.parse(left),
+ %URI{host: right_host} when is_binary(right_host) <- URI.parse(right),
+ true <- String.downcase(left_host) == String.downcase(right_host) do
+ :ok
+ else
+ _ -> {:error, :origin_containment_failed}
+ end
+ end
+
+ defp same_origin(_left, _right), do: {:error, :origin_containment_failed}
+
+ defp comparable_uri(uri) when is_binary(uri) do
+ uri
+ |> String.trim()
+ |> URI.parse()
+ |> case do
+ %URI{scheme: scheme, host: host} = parsed when is_binary(scheme) and is_binary(host) ->
+ path = parsed.path || "/"
+
+ parsed
+ |> Map.put(:scheme, String.downcase(scheme))
+ |> Map.put(:host, String.downcase(host))
+ |> Map.put(:path, String.trim_trailing(path, "/"))
+ |> Map.put(:fragment, nil)
+ |> URI.to_string()
+
+ _ ->
+ uri
+ end
+ end
+end
diff --git a/apps/elektrine/lib/elektrine/activitypub/delivery.ex b/apps/elektrine/lib/elektrine/activitypub/delivery.ex
new file mode 100644
index 0000000..57b010b
--- /dev/null
+++ b/apps/elektrine/lib/elektrine/activitypub/delivery.ex
@@ -0,0 +1,34 @@
+defmodule Elektrine.ActivityPub.Delivery do
+ @moduledoc false
+ use Ecto.Schema
+ import Ecto.Changeset
+
+ schema "activitypub_deliveries" do
+ field :inbox_url, :string
+ field :status, :string, default: "pending"
+ field :attempts, :integer, default: 0
+ field :last_attempt_at, :utc_datetime
+ field :next_retry_at, :utc_datetime
+ field :error_message, :string
+
+ belongs_to :activity, Elektrine.ActivityPub.Activity
+
+ timestamps()
+ end
+
+ @doc false
+ def changeset(delivery, attrs) do
+ delivery
+ |> cast(attrs, [
+ :activity_id,
+ :inbox_url,
+ :status,
+ :attempts,
+ :last_attempt_at,
+ :next_retry_at,
+ :error_message
+ ])
+ |> validate_required([:inbox_url])
+ |> validate_inclusion(:status, ["pending", "delivered", "failed"])
+ end
+end
diff --git a/apps/elektrine/lib/elektrine/activitypub/delivery_cleanup.ex b/apps/elektrine/lib/elektrine/activitypub/delivery_cleanup.ex
new file mode 100644
index 0000000..1dc2754
--- /dev/null
+++ b/apps/elektrine/lib/elektrine/activitypub/delivery_cleanup.ex
@@ -0,0 +1,44 @@
+defmodule Elektrine.ActivityPub.DeliveryCleanup do
+ @moduledoc """
+ Background job that cleans up old failed deliveries from the database.
+ Runs daily to prevent the delivery queue from growing indefinitely.
+ """
+
+ use GenServer
+ require Logger
+
+ # 24 hours
+ @cleanup_interval 24 * 60 * 60 * 1000
+
+ def start_link(_opts) do
+ GenServer.start_link(__MODULE__, %{}, name: __MODULE__)
+ end
+
+ @impl true
+ def init(state) do
+ # Schedule first cleanup after 1 hour
+ schedule_cleanup(60 * 60 * 1000)
+ {:ok, state}
+ end
+
+ @impl true
+ def handle_info(:cleanup, state) do
+ Logger.info("DeliveryCleanup: Starting cleanup of old failed deliveries...")
+
+ try do
+ count = Elektrine.ActivityPub.cleanup_old_deliveries()
+ Logger.info("DeliveryCleanup: Deleted #{count} old failed deliveries")
+ rescue
+ e ->
+ Logger.error("DeliveryCleanup: Error during cleanup: #{inspect(e)}")
+ end
+
+ # Schedule next cleanup
+ schedule_cleanup(@cleanup_interval)
+ {:noreply, state}
+ end
+
+ defp schedule_cleanup(interval) do
+ Process.send_after(self(), :cleanup, interval)
+ end
+end
diff --git a/apps/elektrine/lib/elektrine/activitypub/delivery_cleanup_worker.ex b/apps/elektrine/lib/elektrine/activitypub/delivery_cleanup_worker.ex
new file mode 100644
index 0000000..ff56851
--- /dev/null
+++ b/apps/elektrine/lib/elektrine/activitypub/delivery_cleanup_worker.ex
@@ -0,0 +1,38 @@
+defmodule Elektrine.ActivityPub.DeliveryCleanupWorker do
+ @moduledoc """
+ Oban worker for cleaning up old failed ActivityPub deliveries.
+
+ Scheduled via Oban Cron to run daily. Replaces the old DeliveryCleanup GenServer.
+ """
+
+ use Oban.Worker,
+ queue: :default,
+ max_attempts: 3
+
+ require Logger
+
+ alias Elektrine.ActivityPub
+
+ @impl Oban.Worker
+ def perform(%Oban.Job{}) do
+ Logger.info("DeliveryCleanupWorker: Starting cleanup of old failed deliveries...")
+
+ count = ActivityPub.cleanup_old_deliveries()
+ Logger.info("DeliveryCleanupWorker: Deleted #{count} old failed deliveries")
+
+ :ok
+ rescue
+ e ->
+ Logger.error("DeliveryCleanupWorker: Error during cleanup: #{inspect(e)}")
+ {:error, inspect(e)}
+ end
+
+ @doc """
+ Manually enqueue a cleanup job (for testing or manual triggering).
+ """
+ def enqueue do
+ %{}
+ |> new()
+ |> Elektrine.JobQueue.insert()
+ end
+end
diff --git a/apps/elektrine/lib/elektrine/activitypub/delivery_retry_worker.ex b/apps/elektrine/lib/elektrine/activitypub/delivery_retry_worker.ex
new file mode 100644
index 0000000..4e9b839
--- /dev/null
+++ b/apps/elektrine/lib/elektrine/activitypub/delivery_retry_worker.ex
@@ -0,0 +1,22 @@
+defmodule Elektrine.ActivityPub.DeliveryRetryWorker do
+ @moduledoc """
+ Periodically re-enqueues pending federation deliveries that are due for retry.
+ """
+
+ use Oban.Worker,
+ queue: :activitypub_delivery,
+ max_attempts: 1
+
+ alias Elektrine.ActivityPub
+
+ @impl Oban.Worker
+ def perform(%Oban.Job{}) do
+ delivery_ids = ActivityPub.get_retryable_delivery_ids(500)
+
+ if delivery_ids != [] do
+ ActivityPub.ActivityDeliveryWorker.enqueue_many(delivery_ids)
+ end
+
+ :ok
+ end
+end
diff --git a/apps/elektrine/lib/elektrine/activitypub/domain_delivery_health.ex b/apps/elektrine/lib/elektrine/activitypub/domain_delivery_health.ex
new file mode 100644
index 0000000..9b42f04
--- /dev/null
+++ b/apps/elektrine/lib/elektrine/activitypub/domain_delivery_health.ex
@@ -0,0 +1,68 @@
+defmodule Elektrine.ActivityPub.DomainDeliveryHealth do
+ @moduledoc """
+ Uses instance reachability state to gate noisy remote delivery and fetch work.
+ """
+
+ alias Elektrine.ActivityPub.Instances
+
+ def filter_deliverable_inboxes(inbox_urls) when is_list(inbox_urls) do
+ inbox_urls = Enum.uniq(inbox_urls)
+ deliverable = Enum.filter(inbox_urls, &deliverable_url?/1)
+ paused = length(inbox_urls) - length(deliverable)
+
+ if paused > 0 do
+ :telemetry.execute(
+ [:elektrine, :federation, :domain_delivery, :paused],
+ %{count: paused},
+ %{}
+ )
+ end
+
+ deliverable
+ end
+
+ def deliverable_url?(url) when is_binary(url) do
+ case domain_for_url(url) do
+ domain when is_binary(domain) -> Instances.should_retry?(domain)
+ _ -> false
+ end
+ end
+
+ def deliverable_url?(_url), do: false
+
+ def record_delivery_success(url) when is_binary(url) do
+ with domain when is_binary(domain) <- domain_for_url(url) do
+ _ = Instances.set_reachable(domain)
+ end
+
+ :ok
+ rescue
+ _ -> :ok
+ end
+
+ def record_delivery_success(_url), do: :ok
+
+ def record_delivery_failure(url, _reason) when is_binary(url) do
+ with domain when is_binary(domain) <- domain_for_url(url) do
+ _ = Instances.set_unreachable(domain)
+ end
+
+ :ok
+ rescue
+ _ -> :ok
+ end
+
+ def record_delivery_failure(_url, _reason), do: :ok
+
+ def domain_for_url(url) when is_binary(url) do
+ case URI.parse(url) do
+ %URI{host: host} when is_binary(host) ->
+ host |> String.trim() |> String.downcase()
+
+ _ ->
+ nil
+ end
+ end
+
+ def domain_for_url(_url), do: nil
+end
diff --git a/apps/elektrine/lib/elektrine/activitypub/domain_migration.ex b/apps/elektrine/lib/elektrine/activitypub/domain_migration.ex
new file mode 100644
index 0000000..99a4dfb
--- /dev/null
+++ b/apps/elektrine/lib/elektrine/activitypub/domain_migration.ex
@@ -0,0 +1,353 @@
+defmodule Elektrine.ActivityPub.DomainMigration do
+ @moduledoc """
+ Helpers for ActivityPub domain migration (Move activities).
+ """
+
+ import Ecto.Query, warn: false
+
+ alias Elektrine.Accounts
+ alias Elektrine.Accounts.User
+ alias Elektrine.ActivityPub
+ alias Elektrine.ActivityPub.{Actor, Builder, Publisher}
+ alias Elektrine.Domains
+ alias Elektrine.Repo
+
+ @doc """
+ Verifies, stores, and broadcasts a single local account move.
+ """
+ def move_account(user, target_account, opts \\ [])
+
+ def move_account(%User{} = user, target_account, opts) when is_binary(target_account) do
+ with {:ok, target_actor} <- resolve_move_target_actor(target_account, opts),
+ old_actor_uri <- ActivityPub.actor_uri(user),
+ :ok <- validate_target_alias(target_actor, old_actor_uri),
+ {:ok, updated_user} <- Accounts.update_user(user, %{moved_to: target_actor.uri}),
+ {:ok, summary} <- broadcast_account_move(updated_user, target_actor.uri, opts) do
+ {:ok, Map.put(summary, :user, updated_user)}
+ end
+ end
+
+ def move_account(%User{}, _target_account, _opts), do: {:error, :invalid_move_target}
+
+ @doc """
+ Broadcasts a Move activity for an already-verified local account move.
+ """
+ def broadcast_account_move(user, target_actor_uri, opts \\ [])
+
+ def broadcast_account_move(%User{} = user, target_actor_uri, opts)
+ when is_binary(target_actor_uri) do
+ old_actor_uri = ActivityPub.actor_uri(user)
+ move_activity = Builder.build_move_activity(user, old_actor_uri, target_actor_uri)
+ inboxes = Publisher.get_follower_inboxes(user.id) |> Enum.uniq()
+
+ if Keyword.get(opts, :dry_run, false) do
+ {:ok,
+ %{
+ activity: nil,
+ target_actor_uri: target_actor_uri,
+ old_actor_uri: old_actor_uri,
+ deliveries_queued: length(inboxes),
+ dry_run: true
+ }}
+ else
+ with {:ok, activity_record} <- Publisher.publish(move_activity, user, inboxes) do
+ {:ok,
+ %{
+ activity: activity_record,
+ target_actor_uri: target_actor_uri,
+ old_actor_uri: old_actor_uri,
+ deliveries_queued: length(inboxes),
+ dry_run: false
+ }}
+ end
+ end
+ end
+
+ def broadcast_account_move(%User{}, _target_actor_uri, _opts),
+ do: {:error, :invalid_move_target}
+
+ @doc """
+ Broadcasts Move activities to remote follower inboxes.
+
+ ## Options
+ * `:from_domain` - legacy domain to move from (defaults to `ACTIVITYPUB_MOVE_FROM_DOMAIN`)
+ * `:to_domain` - target domain to move to (defaults to current `INSTANCE_DOMAIN`)
+ * `:dry_run` - if true, only computes counts without delivering
+ * `:usernames` - optional list of usernames to migrate
+ * `:limit` - optional max number of users
+ """
+ def broadcast_moves(opts \\ []) do
+ with {:ok, from_domain} <- resolve_from_domain(opts),
+ {:ok, to_domain} <- resolve_to_domain(opts),
+ :ok <- validate_domain_pair(from_domain, to_domain) do
+ users = users_for_migration(opts)
+ dry_run = Keyword.get(opts, :dry_run, false)
+ from_base_url = ActivityPub.instance_url_for_domain(from_domain)
+ to_base_url = ActivityPub.instance_url_for_domain(to_domain)
+
+ summary =
+ Enum.reduce(users, initial_summary(from_domain, to_domain, dry_run), fn user, acc ->
+ old_actor_uri = ActivityPub.actor_uri(user, from_base_url)
+ new_actor_uri = ActivityPub.actor_uri(user, to_base_url)
+ move_activity = Builder.build_move_activity(user, old_actor_uri, new_actor_uri)
+ inboxes = Publisher.get_follower_inboxes(user.id) |> Enum.uniq()
+
+ acc = %{acc | users_processed: acc.users_processed + 1}
+
+ cond do
+ inboxes == [] ->
+ %{acc | users_without_remote_followers: acc.users_without_remote_followers + 1}
+
+ dry_run ->
+ %{acc | deliveries_attempted: acc.deliveries_attempted + length(inboxes)}
+
+ true ->
+ deliver_move(user, move_activity, inboxes, from_base_url, acc)
+ end
+ end)
+
+ {:ok, summary}
+ end
+ end
+
+ defp resolve_from_domain(opts) do
+ case normalize_domain(Keyword.get(opts, :from_domain, Domains.activitypub_move_from_domain())) do
+ nil -> {:error, :missing_from_domain}
+ domain -> {:ok, domain}
+ end
+ end
+
+ defp resolve_to_domain(opts) do
+ case normalize_domain(Keyword.get(opts, :to_domain, ActivityPub.instance_domain())) do
+ nil -> {:error, :missing_to_domain}
+ domain -> {:ok, domain}
+ end
+ end
+
+ defp validate_domain_pair(from_domain, to_domain) when from_domain == to_domain do
+ {:error, :same_domain}
+ end
+
+ defp validate_domain_pair(_, _), do: :ok
+
+ defp users_for_migration(opts) do
+ usernames =
+ opts
+ |> Keyword.get(:usernames, [])
+ |> Enum.map(&String.trim/1)
+ |> Enum.reject(&(&1 == ""))
+ |> Enum.uniq()
+
+ limit = Keyword.get(opts, :limit)
+
+ base_query =
+ from(u in User,
+ where:
+ u.activitypub_enabled == true and not is_nil(u.activitypub_private_key) and
+ not is_nil(u.activitypub_public_key),
+ order_by: [asc: u.id]
+ )
+
+ scoped_query =
+ if usernames == [] do
+ base_query
+ else
+ from(u in base_query, where: u.username in ^usernames)
+ end
+
+ limited_query =
+ case limit do
+ value when is_integer(value) and value > 0 ->
+ from(u in scoped_query, limit: ^value)
+
+ _ ->
+ scoped_query
+ end
+
+ Repo.all(limited_query)
+ end
+
+ defp deliver_move(user, move_activity, inboxes, from_base_url, acc) do
+ Enum.reduce(inboxes, acc, fn inbox_url, current ->
+ current = %{current | deliveries_attempted: current.deliveries_attempted + 1}
+
+ case Publisher.deliver(move_activity, user, inbox_url, key_id_base_url: from_base_url) do
+ {:ok, :delivered} ->
+ %{current | deliveries_succeeded: current.deliveries_succeeded + 1}
+
+ {:error, reason} ->
+ %{
+ current
+ | deliveries_failed: current.deliveries_failed + 1,
+ errors: [
+ %{
+ username: user.username,
+ inbox: inbox_url,
+ reason: inspect(reason)
+ }
+ | current.errors
+ ]
+ }
+ end
+ end)
+ end
+
+ defp initial_summary(from_domain, to_domain, dry_run) do
+ %{
+ from_domain: from_domain,
+ to_domain: to_domain,
+ dry_run: dry_run,
+ users_processed: 0,
+ users_without_remote_followers: 0,
+ deliveries_attempted: 0,
+ deliveries_succeeded: 0,
+ deliveries_failed: 0,
+ errors: []
+ }
+ end
+
+ defp normalize_domain(domain) when is_binary(domain) do
+ case domain |> String.trim() |> String.downcase() do
+ "" -> nil
+ value -> value
+ end
+ end
+
+ defp normalize_domain(_), do: nil
+
+ defp resolve_move_target_actor(target_account, opts) do
+ case normalize_target_account(target_account) do
+ nil ->
+ {:error, :invalid_move_target}
+
+ {:uri, actor_uri} ->
+ resolve_actor_uri(actor_uri, opts)
+
+ {:acct, acct} ->
+ resolve_acct_actor(acct, opts)
+ end
+ end
+
+ defp normalize_target_account(value) when is_binary(value) do
+ value = String.trim(value)
+
+ cond do
+ value == "" ->
+ nil
+
+ String.starts_with?(value, ["http://", "https://"]) ->
+ {:uri, value}
+
+ String.starts_with?(value, "acct:") ->
+ value |> String.trim_leading("acct:") |> normalize_acct_target()
+
+ String.contains?(value, "@") ->
+ normalize_acct_target(value)
+
+ true ->
+ nil
+ end
+ end
+
+ defp normalize_target_account(_), do: nil
+
+ defp normalize_acct_target(acct) do
+ acct =
+ acct
+ |> String.trim()
+ |> String.trim_leading("@")
+
+ case String.split(acct, "@", parts: 2) do
+ [username, domain] when username != "" and domain != "" -> {:acct, "#{username}@#{domain}"}
+ _ -> nil
+ end
+ end
+
+ defp resolve_actor_uri(actor_uri, _opts) do
+ case ActivityPub.get_actor_by_uri(actor_uri) do
+ %Actor{} = actor ->
+ {:ok, actor}
+
+ nil ->
+ ActivityPub.get_or_fetch_actor(actor_uri)
+ end
+ end
+
+ defp resolve_acct_actor(acct, opts) do
+ with {:ok, {username, domain}} <- split_acct(acct) do
+ case get_actor_by_handle(username, domain) do
+ %Actor{} = actor ->
+ {:ok, actor}
+
+ nil ->
+ with {:ok, actor_uri} <- ActivityPub.webfinger_lookup(acct, opts) do
+ resolve_actor_uri(actor_uri, opts)
+ end
+ end
+ end
+ end
+
+ defp split_acct(acct) when is_binary(acct) do
+ case String.split(acct, "@", parts: 2) do
+ [username, domain] when username != "" and domain != "" -> {:ok, {username, domain}}
+ _ -> {:error, :invalid_move_target}
+ end
+ end
+
+ defp get_actor_by_handle(username, domain) do
+ from(a in Actor,
+ where:
+ fragment("LOWER(?)", a.username) == ^String.downcase(username) and
+ fragment("LOWER(?)", a.domain) == ^String.downcase(domain),
+ order_by: [asc: a.inserted_at, asc: a.id],
+ limit: 1
+ )
+ |> Repo.one()
+ end
+
+ defp validate_target_alias(%Actor{} = target_actor, old_actor_uri)
+ when is_binary(old_actor_uri) do
+ aliases =
+ target_actor.metadata
+ |> extract_uri_candidates("alsoKnownAs")
+ |> Enum.map(&normalize_uri/1)
+
+ if normalize_uri(old_actor_uri) in aliases do
+ :ok
+ else
+ {:error, :move_target_not_verified}
+ end
+ end
+
+ defp validate_target_alias(_, _), do: {:error, :move_target_not_verified}
+
+ defp extract_uri_candidates(metadata, field) when is_map(metadata) do
+ metadata
+ |> Map.get(field)
+ |> expand_uri_candidates()
+ |> Enum.reject(&is_nil/1)
+ |> Enum.uniq()
+ end
+
+ defp extract_uri_candidates(_metadata, _field), do: []
+
+ defp expand_uri_candidates(value) when is_binary(value), do: [value]
+
+ defp expand_uri_candidates(values) when is_list(values),
+ do: Enum.flat_map(values, &expand_uri_candidates/1)
+
+ defp expand_uri_candidates(%{"id" => id}) when is_binary(id), do: [id]
+ defp expand_uri_candidates(%{"href" => href}) when is_binary(href), do: [href]
+ defp expand_uri_candidates(%{"url" => url}) when is_binary(url), do: [url]
+ defp expand_uri_candidates(_), do: []
+
+ defp normalize_uri(uri) when is_binary(uri),
+ do:
+ uri
+ |> String.trim()
+ |> String.split("#", parts: 2)
+ |> hd()
+ |> String.split("?", parts: 2)
+ |> hd()
+ |> String.trim_trailing("/")
+end
diff --git a/apps/elektrine/lib/elektrine/activitypub/domain_throttler.ex b/apps/elektrine/lib/elektrine/activitypub/domain_throttler.ex
new file mode 100644
index 0000000..372e8bb
--- /dev/null
+++ b/apps/elektrine/lib/elektrine/activitypub/domain_throttler.ex
@@ -0,0 +1,227 @@
+defmodule Elektrine.ActivityPub.DomainThrottler do
+ @moduledoc """
+ Per-domain throttling for ActivityPub processing, similar to Lemmy's approach.
+
+ Lemmy uses one queue per federated instance with configurable concurrent sends.
+ This module provides similar functionality by:
+ - Limiting concurrent processing per domain (default: 1)
+ - Tracking failed domains with exponential backoff
+ - Preventing one noisy instance from overwhelming the system
+ """
+
+ use GenServer
+ require Logger
+
+ # Max concurrent jobs per domain. Keep this high enough that large instances
+ # like lemmy.world don't stall thread hydration behind reaction traffic.
+ @max_concurrent_per_domain 4
+ # Base delay for exponential backoff (2 seconds)
+ @base_backoff_ms 2_000
+ # Max backoff delay (2 minutes)
+ @max_backoff_ms 120_000
+ # After this many consecutive failures, apply backoff
+ @failure_threshold 5
+
+ def start_link(opts \\ []) do
+ GenServer.start_link(__MODULE__, opts, name: __MODULE__)
+ end
+
+ @doc """
+ Attempt to acquire a processing slot for a domain.
+ Returns {:ok, :acquired} if slot available, {:error, :throttled} otherwise.
+ """
+ def acquire(domain) when is_binary(domain) do
+ GenServer.call(__MODULE__, {:acquire, domain})
+ end
+
+ @doc """
+ Release a processing slot for a domain.
+ Call this when done processing an activity from the domain.
+ """
+ def release(domain, success? \\ true) when is_binary(domain) do
+ GenServer.cast(__MODULE__, {:release, domain, success?})
+ end
+
+ @doc """
+ Check if a domain is currently in backoff due to failures.
+ """
+ def in_backoff?(domain) when is_binary(domain) do
+ GenServer.call(__MODULE__, {:in_backoff?, domain})
+ end
+
+ @doc """
+ Get the current delay for a domain (0 if no backoff).
+ """
+ def get_delay(domain) when is_binary(domain) do
+ GenServer.call(__MODULE__, {:get_delay, domain})
+ end
+
+ @doc """
+ Get stats about current throttling state.
+ """
+ def stats do
+ GenServer.call(__MODULE__, :stats)
+ end
+
+ # GenServer callbacks
+
+ @impl true
+ def init(_opts) do
+ # ETS table for concurrent counts per domain
+ :ets.new(:domain_concurrent, [:named_table, :public, :set])
+ # ETS table for failure tracking
+ :ets.new(:domain_failures, [:named_table, :public, :set])
+
+ Logger.info(
+ "Startup: domain throttler ready (max_concurrent_per_domain=#{@max_concurrent_per_domain})"
+ )
+
+ {:ok, %{}}
+ end
+
+ @impl true
+ def handle_call({:acquire, domain}, _from, state) do
+ now = System.system_time(:millisecond)
+
+ # Check if domain is in backoff
+ case :ets.lookup(:domain_failures, domain) do
+ [{^domain, failures, last_failure, _last_success}]
+ when is_integer(failures) and is_integer(last_failure) and failures >= @failure_threshold ->
+ backoff_ms = calculate_backoff(failures)
+ time_since_failure = now - last_failure
+
+ if time_since_failure < backoff_ms do
+ # Still in backoff period
+ {:reply, {:error, :backoff, backoff_ms - time_since_failure}, state}
+ else
+ # Backoff expired, try to acquire
+ try_acquire(domain, state)
+ end
+
+ _ ->
+ try_acquire(domain, state)
+ end
+ end
+
+ def handle_call({:in_backoff?, domain}, _from, state) do
+ now = System.system_time(:millisecond)
+
+ result =
+ case :ets.lookup(:domain_failures, domain) do
+ [{^domain, failures, last_failure, _}]
+ when is_integer(failures) and is_integer(last_failure) and failures >= @failure_threshold ->
+ backoff_ms = calculate_backoff(failures)
+ now - last_failure < backoff_ms
+
+ _ ->
+ false
+ end
+
+ {:reply, result, state}
+ end
+
+ def handle_call({:get_delay, domain}, _from, state) do
+ now = System.system_time(:millisecond)
+
+ delay =
+ case :ets.lookup(:domain_failures, domain) do
+ [{^domain, failures, last_failure, _}]
+ when is_integer(failures) and is_integer(last_failure) and failures >= @failure_threshold ->
+ backoff_ms = calculate_backoff(failures)
+ remaining = backoff_ms - (now - last_failure)
+ max(0, remaining)
+
+ _ ->
+ 0
+ end
+
+ {:reply, delay, state}
+ end
+
+ def handle_call(:stats, _from, state) do
+ concurrent = :ets.tab2list(:domain_concurrent)
+
+ failures =
+ :ets.tab2list(:domain_failures)
+ |> Enum.filter(fn {_domain, count, _last_fail, _last_success} ->
+ is_integer(count) and count >= @failure_threshold
+ end)
+
+ stats = %{
+ active_domains: length(concurrent),
+ domains_in_backoff: length(failures),
+ max_concurrent_per_domain: @max_concurrent_per_domain
+ }
+
+ {:reply, stats, state}
+ end
+
+ @impl true
+ def handle_cast({:release, domain, success?}, state) do
+ # Decrement concurrent count
+ case :ets.lookup(:domain_concurrent, domain) do
+ [{^domain, count}] when count > 0 ->
+ if count == 1 do
+ :ets.delete(:domain_concurrent, domain)
+ else
+ :ets.insert(:domain_concurrent, {domain, count - 1})
+ end
+
+ _ ->
+ :ok
+ end
+
+ # Update failure tracking
+ now = System.system_time(:millisecond)
+
+ if success? do
+ # Reset or decrement failure count on success
+ case :ets.lookup(:domain_failures, domain) do
+ [{^domain, failures, last_failure, _last_success}]
+ when is_integer(failures) and is_integer(last_failure) ->
+ if failures > 0 do
+ :ets.insert(:domain_failures, {domain, max(0, failures - 1), last_failure, now})
+ end
+
+ _ ->
+ :ok
+ end
+ else
+ # Increment failure count
+ case :ets.lookup(:domain_failures, domain) do
+ [{^domain, failures, _last_failure, last_success}]
+ when is_integer(failures) and is_integer(last_success) ->
+ :ets.insert(:domain_failures, {domain, failures + 1, now, last_success})
+
+ _ ->
+ :ets.insert(:domain_failures, {domain, 1, now, 0})
+ end
+ end
+
+ {:noreply, state}
+ end
+
+ # Private functions
+
+ defp try_acquire(domain, state) do
+ current =
+ case :ets.lookup(:domain_concurrent, domain) do
+ [{^domain, count}] when is_integer(count) and count > 0 -> count
+ _ -> 0
+ end
+
+ if current < @max_concurrent_per_domain do
+ :ets.insert(:domain_concurrent, {domain, current + 1})
+ {:reply, {:ok, :acquired}, state}
+ else
+ {:reply, {:error, :throttled}, state}
+ end
+ end
+
+ defp calculate_backoff(failures) do
+ # Exponential backoff: base * 2^(failures - threshold)
+ exponent = (failures - @failure_threshold) |> max(0) |> min(16)
+ delay = @base_backoff_ms * :math.pow(2, exponent)
+ min(round(delay), @max_backoff_ms)
+ end
+end
diff --git a/apps/elektrine/lib/elektrine/activitypub/federation_cleanup_worker.ex b/apps/elektrine/lib/elektrine/activitypub/federation_cleanup_worker.ex
new file mode 100644
index 0000000..f2b442a
--- /dev/null
+++ b/apps/elektrine/lib/elektrine/activitypub/federation_cleanup_worker.ex
@@ -0,0 +1,73 @@
+defmodule Elektrine.ActivityPub.FederationCleanupWorker do
+ @moduledoc """
+ Retention cleanup for federation-side cache and bookkeeping tables.
+ """
+
+ use Oban.Worker,
+ queue: :federation_metadata,
+ max_attempts: 1,
+ priority: 9
+
+ import Ecto.Query
+
+ alias Elektrine.ActivityPub.{Instances, Tombstone}
+ alias Elektrine.Repo
+ alias Elektrine.Social.{LinkPreview, Message}
+ alias Oban.Job
+
+ @impl Oban.Worker
+ def perform(%Oban.Job{}) do
+ %{
+ deliveries: Elektrine.ActivityPub.cleanup_old_deliveries(),
+ instances: Instances.cleanup_old_records(),
+ tombstones: prune_old_tombstones(),
+ link_previews: prune_orphaned_link_previews(),
+ completed_jobs: prune_old_completed_oban_jobs()
+ }
+
+ :ok
+ end
+
+ defp prune_old_tombstones do
+ cutoff = DateTime.utc_now() |> DateTime.add(-180, :day)
+
+ {count, _} =
+ from(t in Tombstone,
+ where: t.received_at < ^cutoff
+ )
+ |> Repo.delete_all()
+
+ count
+ end
+
+ defp prune_orphaned_link_previews do
+ cutoff = DateTime.utc_now() |> DateTime.add(-30, :day)
+
+ referenced_ids =
+ from(m in Message,
+ where: not is_nil(m.link_preview_id),
+ select: m.link_preview_id
+ )
+
+ {count, _} =
+ from(p in LinkPreview,
+ where: p.inserted_at < ^cutoff,
+ where: p.id not in subquery(referenced_ids)
+ )
+ |> Repo.delete_all()
+
+ count
+ end
+
+ defp prune_old_completed_oban_jobs do
+ cutoff = DateTime.utc_now() |> DateTime.add(-6, :hour)
+
+ {count, _} =
+ from(j in Job,
+ where: j.state == "completed" and j.completed_at < ^cutoff
+ )
+ |> Repo.delete_all()
+
+ count
+ end
+end
diff --git a/apps/elektrine/lib/elektrine/activitypub/federation_helpers.ex b/apps/elektrine/lib/elektrine/activitypub/federation_helpers.ex
new file mode 100644
index 0000000..428a2eb
--- /dev/null
+++ b/apps/elektrine/lib/elektrine/activitypub/federation_helpers.ex
@@ -0,0 +1,168 @@
+defmodule Elektrine.ActivityPub.FederationHelpers do
+ @moduledoc """
+ Helper functions for testing and managing ActivityPub federation.
+ """
+
+ require Logger
+ import Ecto.Query
+
+ alias Elektrine.Accounts
+ alias Elektrine.ActivityPub
+ alias Elektrine.ActivityPub.{Builder, Publisher, RemoteFetch}
+ alias Elektrine.Profiles
+ alias Elektrine.Repo
+
+ @doc """
+ Follow a remote user from a local user.
+ """
+ def follow_remote_user(local_username, remote_handle) do
+ with {:ok, local_user} <- get_local_user(local_username),
+ {:ok, local_user} <- ActivityPub.KeyManager.ensure_user_has_keys(local_user),
+ {:ok, acct} <- parse_remote_handle(remote_handle),
+ {:ok, actor_uri} <- RemoteFetch.webfinger_lookup(acct),
+ {:ok, remote_actor} <- ActivityPub.get_or_fetch_actor(actor_uri) do
+ # Check if already following (local user following remote actor)
+ existing =
+ from(f in Profiles.Follow,
+ where: f.follower_id == ^local_user.id and f.remote_actor_id == ^remote_actor.id
+ )
+ |> Repo.one()
+
+ if existing do
+ {:error, :already_following}
+ else
+ # Build Follow activity
+ follow_activity = Builder.build_follow_activity(local_user, remote_actor.uri)
+
+ # Create pending follow relationship first
+ {:ok, follow} =
+ %Profiles.Follow{}
+ |> Ecto.Changeset.change(%{
+ follower_id: local_user.id,
+ remote_actor_id: remote_actor.id,
+ activitypub_id: follow_activity["id"],
+ pending: true
+ })
+ |> Repo.insert()
+
+ # Send to remote inbox
+ Publisher.publish(follow_activity, local_user, [remote_actor.inbox_url])
+
+ Logger.info("Sent Follow request to #{remote_handle}")
+
+ {:ok,
+ %{
+ remote_actor: remote_actor,
+ follow: follow
+ }}
+ end
+ else
+ {:error, reason} -> {:error, reason}
+ end
+ end
+
+ @doc """
+ List all remote users following a local user.
+ """
+ def list_remote_followers(local_username) do
+ case get_local_user(local_username) do
+ {:ok, user} ->
+ follows =
+ from(f in Profiles.Follow,
+ where: f.followed_id == ^user.id and not is_nil(f.remote_actor_id),
+ preload: :remote_actor
+ )
+ |> Repo.all()
+
+ remote_actors = Enum.map(follows, & &1.remote_actor)
+ {:ok, remote_actors}
+
+ {:error, reason} ->
+ {:error, reason}
+ end
+ end
+
+ @doc """
+ Get federated timeline for a user.
+ Returns posts from remote users they follow.
+ """
+ def get_federated_timeline(local_username, opts \\ []) do
+ limit = Keyword.get(opts, :limit, 20)
+ offset = Keyword.get(opts, :offset, 0)
+
+ case get_local_user(local_username) do
+ {:ok, user} ->
+ # Get remote actors the user follows
+ remote_actor_ids =
+ from(f in Profiles.Follow,
+ where: f.follower_id == ^user.id and not is_nil(f.remote_actor_id),
+ select: f.remote_actor_id
+ )
+ |> Repo.all()
+
+ if remote_actor_ids == [] do
+ {:ok, []}
+ else
+ # Get messages from those remote actors
+ messages =
+ from(m in Elektrine.Social.Message,
+ where: m.federated == true and m.remote_actor_id in ^remote_actor_ids,
+ where: is_nil(m.deleted_at),
+ order_by: [desc: m.inserted_at],
+ limit: ^limit,
+ offset: ^offset,
+ preload: :remote_actor
+ )
+ |> Repo.all()
+
+ {:ok, messages}
+ end
+
+ {:error, reason} ->
+ {:error, reason}
+ end
+ end
+
+ @doc """
+ Get all public federated posts (discover feed).
+ """
+ def get_public_federated_posts(opts \\ []) do
+ limit = Keyword.get(opts, :limit, 20)
+ offset = Keyword.get(opts, :offset, 0)
+
+ messages =
+ from(m in Elektrine.Social.Message,
+ where: m.federated == true and m.visibility == "public",
+ where: is_nil(m.deleted_at),
+ order_by: [desc: m.inserted_at],
+ limit: ^limit,
+ offset: ^offset,
+ preload: :remote_actor
+ )
+ |> Repo.all()
+
+ {:ok, messages}
+ end
+
+ ## Private helpers
+
+ defp get_local_user(username) do
+ case Accounts.get_user_by_username(username) do
+ nil -> {:error, :user_not_found}
+ user -> {:ok, user}
+ end
+ end
+
+ defp parse_remote_handle(handle) do
+ # Handle formats: user@domain.com or @user@domain.com
+ handle = String.trim_leading(handle, "@")
+
+ case String.split(handle, "@") do
+ [username, domain] when username != "" and domain != "" ->
+ {:ok, "#{username}@#{domain}"}
+
+ _ ->
+ {:error, :invalid_handle}
+ end
+ end
+end
diff --git a/apps/elektrine/lib/elektrine/activitypub/federation_load_guard.ex b/apps/elektrine/lib/elektrine/activitypub/federation_load_guard.ex
new file mode 100644
index 0000000..f460e13
--- /dev/null
+++ b/apps/elektrine/lib/elektrine/activitypub/federation_load_guard.ex
@@ -0,0 +1,59 @@
+defmodule Elektrine.ActivityPub.FederationLoadGuard do
+ @moduledoc """
+ Back-pressure guard for nonessential federation jobs.
+ """
+
+ import Ecto.Query
+
+ alias Elektrine.Repo
+ alias Oban.Job
+
+ @default_threshold 50_000
+
+ def overloaded? do
+ enabled?() and queued_federation_jobs() >= threshold()
+ end
+
+ def skip_nonessential?(component \\ :unknown) do
+ skip? = overloaded?()
+
+ if skip? do
+ :telemetry.execute(
+ [:elektrine, :federation, :load_guard, :skip],
+ %{count: 1},
+ %{component: component}
+ )
+ end
+
+ skip?
+ end
+
+ def queued_federation_jobs do
+ count =
+ Repo.one(
+ from j in Job,
+ where: j.queue == "federation" and j.state in ["available", "retryable"],
+ select: count(j.id)
+ ) || 0
+
+ :telemetry.execute(
+ [:elektrine, :federation, :queue_depth],
+ %{jobs: count},
+ %{queue: :federation, states: "available,retryable"}
+ )
+
+ count
+ end
+
+ defp enabled? do
+ :elektrine
+ |> Application.get_env(:federation_load_guard, [])
+ |> Keyword.get(:enabled, true)
+ end
+
+ defp threshold do
+ :elektrine
+ |> Application.get_env(:federation_load_guard, [])
+ |> Keyword.get(:max_available_or_retryable, @default_threshold)
+ end
+end
diff --git a/apps/elektrine/lib/elektrine/activitypub/fetch_remote_poll_service.ex b/apps/elektrine/lib/elektrine/activitypub/fetch_remote_poll_service.ex
new file mode 100644
index 0000000..704fd4a
--- /dev/null
+++ b/apps/elektrine/lib/elektrine/activitypub/fetch_remote_poll_service.ex
@@ -0,0 +1,58 @@
+defmodule Elektrine.ActivityPub.FetchRemotePollService do
+ @moduledoc """
+ Refreshes a remote ActivityPub poll and syncs its local cached poll record.
+ """
+
+ require Logger
+
+ alias Elektrine.ActivityPub.Handlers.{CreateHandler, UpdateHandler}
+ alias Elektrine.ActivityPub.RemoteFetch
+ alias Elektrine.Repo
+ alias Elektrine.Social.Message
+
+ def call(%{id: _poll_id, message_id: _message_id} = poll) do
+ poll = Repo.preload(poll, message: [:remote_actor])
+
+ with %Message{} = message <- poll.message,
+ true <- message.federated,
+ true <- poll_stale?(poll),
+ activitypub_id when is_binary(activitypub_id) <- message.activitypub_id,
+ actor_uri when is_binary(actor_uri) <- message.remote_actor && message.remote_actor.uri,
+ {:ok, object} when is_map(object) <- RemoteFetch.fetch_object(activitypub_id),
+ {:ok, _} <- UpdateHandler.handle(%{"object" => object}, actor_uri, nil),
+ {:ok, refreshed_poll} <- CreateHandler.upsert_federated_poll(message.id, object) do
+ refreshed_poll
+ |> Ecto.Changeset.change(last_fetched_at: DateTime.utc_now() |> DateTime.truncate(:second))
+ |> Repo.update()
+ else
+ false ->
+ {:ok, poll}
+
+ nil ->
+ {:error, :poll_not_refreshable}
+
+ {:error, reason} ->
+ {:error, reason}
+
+ other ->
+ Logger.warning("Failed to refresh remote poll #{poll.id}: #{inspect(other)}")
+ {:error, :refresh_failed}
+ end
+ end
+
+ def call(_), do: {:error, :invalid_poll}
+
+ defp poll_stale?(%{last_fetched_at: last_fetched_at, closes_at: closes_at}) do
+ expires_after_last_fetch? =
+ is_nil(closes_at) || is_nil(last_fetched_at) ||
+ DateTime.compare(last_fetched_at, closes_at) == :lt
+
+ stale_fetch? =
+ is_nil(last_fetched_at) ||
+ DateTime.compare(last_fetched_at, DateTime.add(DateTime.utc_now(), -60, :second)) == :lt
+
+ expires_after_last_fetch? && stale_fetch?
+ end
+
+ defp poll_stale?(_poll), do: true
+end
diff --git a/apps/elektrine/lib/elektrine/activitypub/fetch_remote_poll_worker.ex b/apps/elektrine/lib/elektrine/activitypub/fetch_remote_poll_worker.ex
new file mode 100644
index 0000000..2986716
--- /dev/null
+++ b/apps/elektrine/lib/elektrine/activitypub/fetch_remote_poll_worker.ex
@@ -0,0 +1,69 @@
+defmodule Elektrine.ActivityPub.FetchRemotePollWorker do
+ @moduledoc """
+ Refreshes a cached remote poll through Oban so LiveViews don't perform
+ network fetches inline.
+ """
+
+ use Oban.Worker,
+ queue: :federation,
+ max_attempts: 3,
+ unique: [
+ period: 300,
+ keys: [:message_id],
+ states: [:available, :scheduled, :executing, :retryable]
+ ]
+
+ import Ecto.Query
+
+ alias Elektrine.ActivityPub.FetchRemotePollService
+ alias Elektrine.Messaging
+ alias Elektrine.Repo
+ alias Elektrine.Social.{Message, Poll}
+
+ def enqueue(message_id) when is_integer(message_id) and message_id > 0 do
+ %{"message_id" => message_id}
+ |> new()
+ |> Elektrine.JobQueue.insert()
+ end
+
+ def enqueue(_message_id), do: {:error, :invalid_message_id}
+
+ @impl Oban.Worker
+ def perform(%Oban.Job{args: %{"type" => "final_due"} = args}) do
+ args
+ |> Map.get("limit", 100)
+ |> enqueue_final_due_batch()
+
+ :ok
+ end
+
+ def perform(%Oban.Job{args: %{"message_id" => message_id}}) do
+ case message_id |> Messaging.get_message() |> Repo.preload(poll: [options: []]) do
+ %{post_type: "poll", poll: poll} when not is_nil(poll) ->
+ case FetchRemotePollService.call(poll) do
+ {:ok, _poll} -> :ok
+ {:error, :poll_not_refreshable} -> {:discard, :poll_not_refreshable}
+ {:error, reason} -> {:error, reason}
+ end
+
+ _ ->
+ {:discard, :message_not_poll}
+ end
+ end
+
+ defp enqueue_final_due_batch(limit) do
+ limit = max(1, min(limit || 100, 500))
+ now = DateTime.utc_now() |> DateTime.truncate(:second)
+
+ Poll
+ |> join(:inner, [p], m in Message, on: m.id == p.message_id)
+ |> where([p, m], m.federated == true)
+ |> where([p, _m], not is_nil(p.closes_at) and p.closes_at <= ^now)
+ |> where([p, _m], is_nil(p.last_fetched_at) or p.last_fetched_at < p.closes_at)
+ |> order_by([p, _m], asc: p.closes_at)
+ |> limit(^limit)
+ |> select([p, _m], p.message_id)
+ |> Repo.all()
+ |> Enum.each(&enqueue/1)
+ end
+end
diff --git a/apps/elektrine/lib/elektrine/activitypub/fetcher.ex b/apps/elektrine/lib/elektrine/activitypub/fetcher.ex
new file mode 100644
index 0000000..cee22fb
--- /dev/null
+++ b/apps/elektrine/lib/elektrine/activitypub/fetcher.ex
@@ -0,0 +1,1123 @@
+defmodule Elektrine.ActivityPub.Fetcher do
+ @moduledoc """
+ Fetches ActivityPub resources from remote instances using Finch.
+ Supports signed fetches for instances requiring authorized fetch mode.
+ """
+
+ require Logger
+
+ alias Elektrine.ActivityPub.Containment
+ alias Elektrine.ActivityPub.HTTPSignature
+ alias Elektrine.ActivityPub.Instances
+ alias Elektrine.ActivityPub.Visibility
+ alias Elektrine.HTTP.Backoff
+ alias Elektrine.Security.URLValidator
+
+ @max_activitypub_body_bytes 2 * 1024 * 1024
+ @content_object_types [
+ "Note",
+ "Article",
+ "Page",
+ "Question",
+ "Event",
+ "Audio",
+ "Video",
+ "Image"
+ ]
+
+ @doc """
+ Fetches an actor document from a remote instance.
+ Uses signed fetch if configured.
+ Also triggers a background fetch of instance metadata (nodeinfo).
+ """
+ def fetch_actor(uri, opts \\ []) do
+ with :ok <- validate_fetch_url(uri, :actor, opts) do
+ # Tests may inject a request function and don't need nodeinfo side effects.
+ unless Keyword.has_key?(opts, :request_fun) do
+ # Trigger nodeinfo fetch for this instance (async, deduplicated)
+ Instances.fetch_metadata_from_url(uri)
+ end
+
+ do_signed_fetch(uri, opts)
+ end
+ end
+
+ @doc """
+ Fetches an activity or object from a remote instance.
+ Uses signed fetch if configured.
+ Results are cached to reduce network load.
+
+ Options:
+ - `:skip_cache` - bypass the cache and always fetch fresh (default: false)
+ - `:sign` - force signed fetch (default: based on config)
+ """
+ def fetch_object(uri, opts \\ []) do
+ skip_cache = Keyword.get(opts, :skip_cache, false)
+
+ with :ok <- validate_fetch_url(uri, :object, opts) do
+ result =
+ if skip_cache do
+ fetch_and_validate_object(uri, opts)
+ else
+ Elektrine.AppCache.get_object(uri, fn ->
+ fetch_and_validate_object(uri, opts)
+ end)
+ end
+
+ enforce_fetched_object_access(uri, result, opts)
+ end
+ end
+
+ @doc """
+ Fetches an object without caching. Use when you need fresh data.
+ """
+ def fetch_object_uncached(uri, opts \\ []) do
+ with :ok <- validate_fetch_url(uri, :object, opts) do
+ # Also invalidate the cache so next regular fetch gets fresh data
+ Elektrine.AppCache.invalidate_object(uri)
+ fetch_and_validate_object(uri, opts)
+ end
+ end
+
+ defp fetch_and_validate_object(uri, opts) do
+ with {:ok, object} <- do_signed_fetch(uri, opts),
+ :ok <- Containment.validate_fetch(uri, object),
+ :ok <- validate_fetched_object_identity(uri, object, opts),
+ :ok <- validate_fetched_object_access(uri, object, opts) do
+ {:ok, object}
+ end
+ end
+
+ defp enforce_fetched_object_access(uri, {:ok, object}, opts) do
+ case validate_fetched_object_access(uri, object, opts) do
+ :ok ->
+ {:ok, object}
+
+ {:error, :unauthorized_fetch} = error ->
+ Elektrine.AppCache.invalidate_object(uri)
+ error
+ end
+ end
+
+ defp enforce_fetched_object_access(_uri, result, _opts), do: result
+
+ defp validate_fetched_object_access(uri, object, _opts),
+ do: do_validate_fetched_object_access(uri, object)
+
+ defp do_validate_fetched_object_access(_uri, object) when not is_map(object), do: :ok
+
+ defp do_validate_fetched_object_access(_uri, %{"type" => type})
+ when type in ["Collection", "OrderedCollection", "CollectionPage", "OrderedCollectionPage"] do
+ :ok
+ end
+
+ defp do_validate_fetched_object_access(_uri, %{"type" => type})
+ when type in ["Person", "Group", "Application", "Service", "Organization"] do
+ :ok
+ end
+
+ defp do_validate_fetched_object_access(uri, %{"type" => "Create"} = activity) do
+ object = Map.get(activity, "object")
+
+ if Visibility.public_or_unlisted?(activity) or
+ (is_map(object) and Visibility.public_or_unlisted?(object)) do
+ :ok
+ else
+ reject_unauthorized_object(uri, activity)
+ end
+ end
+
+ defp do_validate_fetched_object_access(uri, %{"type" => type} = object)
+ when type in @content_object_types do
+ if Visibility.public_or_unlisted?(object) do
+ :ok
+ else
+ reject_unauthorized_object(uri, object)
+ end
+ end
+
+ defp do_validate_fetched_object_access(_uri, _object), do: :ok
+
+ defp reject_unauthorized_object(uri, object) do
+ Logger.warning(
+ "Rejected fetched ActivityPub content outside public audience: requested=#{inspect(uri)} id=#{inspect(object["id"])}"
+ )
+
+ {:error, :unauthorized_fetch}
+ end
+
+ defp validate_fetched_object_identity(_uri, object, _opts) when not is_map(object), do: :ok
+
+ defp validate_fetched_object_identity(_uri, %{"type" => type}, _opts)
+ when type in ["Collection", "OrderedCollection", "CollectionPage", "OrderedCollectionPage"] do
+ :ok
+ end
+
+ defp validate_fetched_object_identity(uri, object, opts) do
+ if Keyword.get(opts, :skip_identity_check, false) do
+ :ok
+ else
+ requested_uri = comparable_object_uri(uri)
+ object_uris = object_identity_candidates(object) |> Enum.map(&comparable_object_uri/1)
+
+ if requested_uri in object_uris do
+ :ok
+ else
+ Logger.warning(
+ "Rejected fetched object due to id mismatch: requested=#{inspect(uri)} returned=#{inspect(object["id"])}"
+ )
+
+ {:error, :object_id_mismatch}
+ end
+ end
+ end
+
+ defp object_identity_candidates(%{"type" => "Create", "object" => object} = activity) do
+ expand_uri_candidates(activity) ++ expand_uri_candidates(object)
+ end
+
+ defp object_identity_candidates(object), do: expand_uri_candidates(object)
+
+ defp expand_uri_candidates(value) when is_binary(value), do: [value]
+
+ defp expand_uri_candidates(values) when is_list(values),
+ do: Enum.flat_map(values, &expand_uri_candidates/1)
+
+ defp expand_uri_candidates(value) when is_map(value) do
+ value
+ |> Map.take(["id", "url", "href", "_source_url"])
+ |> Map.values()
+ |> Enum.flat_map(&expand_uri_candidates/1)
+ end
+
+ defp expand_uri_candidates(_), do: []
+
+ defp comparable_object_uri(uri) when is_binary(uri) do
+ uri
+ |> String.trim()
+ |> case do
+ "" ->
+ nil
+
+ trimmed ->
+ case URI.parse(trimmed) do
+ %URI{scheme: scheme, host: host} = parsed
+ when is_binary(scheme) and is_binary(host) and host != "" ->
+ path = parsed.path || "/"
+
+ parsed
+ |> Map.put(:scheme, String.downcase(scheme))
+ |> Map.put(:host, String.downcase(host))
+ |> Map.put(:path, String.trim_trailing(path, "/"))
+ |> Map.put(:fragment, nil)
+ |> URI.to_string()
+
+ _ ->
+ trimmed
+ end
+ end
+ end
+
+ defp comparable_object_uri(_), do: nil
+
+ # Performs a signed or unsigned fetch based on configuration
+ defp do_signed_fetch(uri, opts) do
+ base_headers = [
+ {"accept",
+ "application/activity+json, application/ld+json; profile=\"https://www.w3.org/ns/activitystreams\""},
+ {"user-agent", "Elektrine/1.0"}
+ ]
+
+ # Check if we should sign the request
+ sign_fetches = Keyword.get(opts, :sign, signed_fetches_enabled?())
+ allow_recovery = Keyword.get(opts, :allow_recovery, true)
+
+ headers =
+ if sign_fetches do
+ case get_instance_signing_key() do
+ {:ok, {private_key, key_id}} ->
+ # Sign the GET request
+ signature_headers = HTTPSignature.sign_get(uri, private_key, key_id)
+ base_headers ++ signature_headers
+
+ {:error, _} ->
+ # No signing key available, use unsigned request
+ base_headers
+ end
+ else
+ base_headers
+ end
+
+ case request_with_backoff(uri, headers, request_opts(opts)) do
+ {:ok, %Finch.Response{status: 200, body: body}} = response ->
+ case Jason.decode(body) do
+ {:ok, data} ->
+ {:ok, data}
+
+ {:error, reason} ->
+ if allow_recovery do
+ maybe_recover_object_from_html(uri, response, reason, opts)
+ else
+ {:error, :invalid_json}
+ end
+ end
+
+ {:ok, %Finch.Response{} = response}
+ when response.status in [401, 403] and sign_fetches == false ->
+ # Instance requires signed fetches - retry with signature
+ Logger.debug("Instance #{uri} requires signed fetch, retrying...")
+ do_signed_fetch(uri, Keyword.put(opts, :sign, true))
+
+ {:ok, %Finch.Response{status: status, body: _body}} when status in [404, 410] ->
+ if allow_recovery do
+ case mastodon_status_fallback(uri, opts) do
+ {:ok, object_data} ->
+ Logger.info("Recovered status document via Mastodon API fallback for #{uri}")
+ {:ok, object_data}
+
+ _ ->
+ Logger.debug("Object not found or deleted: #{uri}, status: #{status}")
+ {:error, :not_found}
+ end
+ else
+ Logger.debug("Object not found or deleted: #{uri}, status: #{status}")
+ {:error, :not_found}
+ end
+
+ {:ok, %Finch.Response{} = response} ->
+ log_failed_fetch(uri, response)
+
+ {:error, :fetch_failed}
+
+ {:error, :backoff} ->
+ Logger.debug("Backoff active for #{uri}, deferring fetch")
+ {:error, :fetch_failed}
+
+ {:error, reason} ->
+ Logger.warning("HTTP error fetching from #{uri}: #{inspect(reason)}")
+ {:error, :http_error}
+ end
+ end
+
+ defp signed_fetches_enabled? do
+ Application.get_env(:elektrine, :activitypub, [])
+ |> Keyword.get(:sign_fetches, false)
+ end
+
+ # Get the instance actor's signing key for signed fetches
+ defp get_instance_signing_key do
+ # Use the first admin user's key, or create an instance actor
+ case get_instance_actor_key() do
+ {:ok, _} = result -> result
+ {:error, _} -> {:error, :no_signing_key}
+ end
+ end
+
+ defp get_instance_actor_key do
+ # Try to get from the first AP-enabled admin user
+ import Ecto.Query
+
+ case Elektrine.Repo.one(
+ from(u in Elektrine.Accounts.User,
+ where: u.is_admin == true and u.activitypub_enabled == true,
+ where: not is_nil(u.activitypub_private_key),
+ limit: 1
+ )
+ ) do
+ %{activitypub_private_key: private_key} = user when is_binary(private_key) ->
+ key_id = Elektrine.ActivityPub.actor_key_id(user)
+ {:ok, {private_key, key_id}}
+
+ _ ->
+ {:error, :no_admin_key}
+ end
+ end
+
+ @doc """
+ Resolves a WebFinger URI to an ActivityPub actor URI.
+ Results are cached since WebFinger data rarely changes.
+ """
+ def webfinger_lookup(acct, opts \\ []) do
+ if Keyword.get(opts, :skip_cache, false) do
+ do_webfinger_lookup(acct, opts)
+ else
+ Elektrine.AppCache.get_webfinger(acct, fn ->
+ do_webfinger_lookup(acct, opts)
+ end)
+ end
+ end
+
+ defp do_webfinger_lookup(acct, opts) do
+ case String.split(acct, "@", parts: 2) do
+ [username, domain] when username != "" and domain != "" ->
+ webfinger_url = "https://#{domain}/.well-known/webfinger?resource=acct:#{acct}"
+
+ headers = [
+ {"accept", "application/jrd+json, application/json"},
+ {"user-agent", "Elektrine/1.0"}
+ ]
+
+ with :ok <- validate_fetch_url(webfinger_url, :webfinger, opts) do
+ case request_with_backoff(webfinger_url, headers, request_opts(opts)) do
+ {:ok, %Finch.Response{status: 200, body: body}} ->
+ case Jason.decode(body) do
+ {:ok, %{"links" => links}} ->
+ # Find the self link with type application/activity+json
+ actor_link =
+ Enum.find(links, fn link ->
+ link["rel"] == "self" &&
+ (link["type"] == "application/activity+json" ||
+ link["type"] ==
+ "application/ld+json; profile=\"https://www.w3.org/ns/activitystreams\"")
+ end)
+
+ case actor_link do
+ %{"href" => href} -> {:ok, href}
+ _ -> {:error, :no_actor_link}
+ end
+
+ {:error, _} ->
+ {:error, :invalid_json}
+ end
+
+ {:ok, %Finch.Response{status: 404}} ->
+ Logger.debug("WebFinger lookup not found: #{acct}")
+ {:error, :not_found}
+
+ {:ok, %Finch.Response{status: status}} when status in [400, 410] ->
+ Logger.debug("WebFinger lookup rejected for #{acct}, status: #{status}")
+ {:error, :webfinger_failed}
+
+ {:ok, %Finch.Response{status: status}} ->
+ Logger.warning("WebFinger lookup failed for #{acct}, status: #{status}")
+ {:error, :webfinger_failed}
+
+ {:error, :backoff} ->
+ Logger.warning("WebFinger lookup deferred due to remote backoff: #{webfinger_url}")
+ {:error, :webfinger_failed}
+
+ {:error, reason} ->
+ Logger.warning("HTTP error during WebFinger: #{inspect(reason)}")
+ {:error, :http_error}
+ end
+ end
+
+ _ ->
+ {:error, :invalid_acct}
+ end
+ end
+
+ defp request_opts(opts) do
+ [recv_timeout: 10_000, timeout: 10_000, max_body_bytes: @max_activitypub_body_bytes]
+ |> Keyword.merge(Keyword.take(opts, [:request_fun]))
+ end
+
+ defp maybe_recover_object_from_html(
+ uri,
+ {:ok, %Finch.Response{body: body, headers: response_headers}},
+ reason,
+ opts
+ ) do
+ case lemmy_object_fallback(uri, body, response_headers, opts) do
+ {:ok, object_data, recovery_type} ->
+ Logger.info("Recovered #{recovery_type} document via Lemmy fallback for #{uri}")
+ {:ok, object_data}
+
+ _ ->
+ Logger.warning(
+ "Expected ActivityPub JSON from #{uri}, got non-JSON response: " <>
+ "content_type=#{inspect(response_content_type(response_headers))} " <>
+ "body_preview=#{inspect(body_preview(body))} decode_error=#{inspect(reason)}"
+ )
+
+ {:error, :invalid_json}
+ end
+ end
+
+ defp response_content_type(headers) when is_list(headers) do
+ headers
+ |> Enum.find_value(fn {key, value} ->
+ if String.downcase(to_string(key)) == "content-type", do: value
+ end)
+ end
+
+ defp response_content_type(_), do: nil
+
+ defp body_preview(body) when is_binary(body) do
+ body
+ |> String.replace(~r/\s+/, " ")
+ |> String.slice(0, 120)
+ end
+
+ defp body_preview(_), do: nil
+
+ defp mastodon_status_fallback(uri, opts) when is_binary(uri) do
+ case extract_mastodon_status_info(uri) do
+ {:ok, domain, username, status_id} ->
+ with {:ok, status} <- fetch_mastodon_status(domain, status_id, opts),
+ {:ok, object} <- normalize_mastodon_status(status, uri, username) do
+ {:ok, object}
+ else
+ _ -> {:error, :no_status_fallback}
+ end
+
+ {:search, domain, status_url} ->
+ with {:ok, status} <- fetch_mastodon_status_via_search(domain, status_url, opts),
+ {:ok, object} <- normalize_mastodon_status(status, uri, nil) do
+ {:ok, object}
+ else
+ _ -> {:error, :no_status_fallback}
+ end
+
+ _ ->
+ {:error, :no_status_fallback}
+ end
+ end
+
+ defp mastodon_status_fallback(_, _), do: {:error, :no_status_fallback}
+
+ defp extract_mastodon_status_info(url) when is_binary(url) do
+ cond do
+ match = Regex.run(~r{https?://([^/]+)/users/([^/]+)/statuses/([a-zA-Z0-9_-]+)}, url) ->
+ [_, domain, username, status_id] = match
+ {:ok, domain, username, status_id}
+
+ match = Regex.run(~r{https?://([^/]+)/@([^/]+)/([a-zA-Z0-9_-]+)}, url) ->
+ [_, domain, username, status_id] = match
+ {:ok, domain, username, status_id}
+
+ match = Regex.run(~r{https?://([^/]+)/objects/[a-f0-9-]+(?:$|[/?#])}i, url) ->
+ [_, domain] = match
+ {:search, domain, url}
+
+ true ->
+ {:error, :unsupported_status_url}
+ end
+ end
+
+ defp fetch_mastodon_status(domain, status_id, opts) do
+ api_url = "https://#{domain}/api/v1/statuses/#{status_id}"
+
+ headers = [
+ {"accept", "application/json"},
+ {"user-agent", "Elektrine/1.0"}
+ ]
+
+ case request_with_backoff(api_url, headers, request_opts(opts)) do
+ {:ok, %Finch.Response{status: 200, body: body}} -> Jason.decode(body)
+ _ -> {:error, :status_fetch_failed}
+ end
+ end
+
+ defp fetch_mastodon_status_via_search(domain, status_url, opts) do
+ api_url =
+ "https://#{domain}/api/v2/search?q=#{URI.encode_www_form(status_url)}&type=statuses&resolve=true&limit=1"
+
+ headers = [
+ {"accept", "application/json"},
+ {"user-agent", "Elektrine/1.0"}
+ ]
+
+ case request_with_backoff(api_url, headers, request_opts(opts)) do
+ {:ok, %Finch.Response{status: 200, body: body}} ->
+ case Jason.decode(body) do
+ {:ok, %{"statuses" => [%{} = status | _]}} -> {:ok, status}
+ _ -> {:error, :status_search_failed}
+ end
+
+ _ ->
+ {:error, :status_search_failed}
+ end
+ end
+
+ defp normalize_mastodon_status(%{"account" => _account} = status, requested_uri, username) do
+ visibility = Map.get(status, "visibility")
+
+ if visibility in ["public", "unlisted", nil] do
+ actor_uri = mastodon_actor_uri(status, requested_uri, username)
+
+ {:ok,
+ %{
+ "id" => Map.get(status, "uri") || requested_uri,
+ "type" => "Note",
+ "content" => Map.get(status, "content") || "",
+ "published" => Map.get(status, "created_at"),
+ "url" => Map.get(status, "url") || requested_uri,
+ "attributedTo" => actor_uri,
+ "to" => mastodon_status_to_audience(visibility),
+ "cc" => mastodon_status_cc_audience(visibility, actor_uri),
+ "indexable" => Map.get(status, "indexable"),
+ "sensitive" => Map.get(status, "sensitive") || false,
+ "summary" => Map.get(status, "spoiler_text"),
+ "attachment" => normalize_mastodon_attachments(Map.get(status, "media_attachments", [])),
+ "tag" => []
+ }}
+ else
+ {:error, :unsupported_visibility}
+ end
+ end
+
+ defp normalize_mastodon_status(_, _, _), do: {:error, :invalid_status}
+
+ defp mastodon_actor_uri(%{"account" => %{} = account}, requested_uri, username) do
+ account["uri"] || account["url"] || mastodon_actor_uri(requested_uri, username)
+ end
+
+ defp mastodon_actor_uri(_, requested_uri, username),
+ do: mastodon_actor_uri(requested_uri, username)
+
+ defp mastodon_actor_uri(requested_uri, username) do
+ case URI.parse(requested_uri) do
+ %URI{scheme: scheme, host: host} when is_binary(scheme) and is_binary(host) ->
+ if is_binary(username) && username != "" do
+ "#{scheme}://#{host}/users/#{username}"
+ else
+ requested_uri
+ end
+
+ _ ->
+ requested_uri
+ end
+ end
+
+ defp mastodon_status_to_audience("unlisted"), do: []
+ defp mastodon_status_to_audience(_), do: ["https://www.w3.org/ns/activitystreams#Public"]
+
+ defp mastodon_status_cc_audience("unlisted", actor_uri),
+ do: ["https://www.w3.org/ns/activitystreams#Public", actor_uri <> "/followers"]
+
+ defp mastodon_status_cc_audience(_, _), do: []
+
+ defp normalize_mastodon_attachments(attachments) when is_list(attachments) do
+ Enum.map(attachments, fn attachment ->
+ %{
+ "type" => "Document",
+ "mediaType" =>
+ attachment["mime_type"] || attachment["type"] || "application/octet-stream",
+ "url" => attachment["url"],
+ "name" => attachment["description"]
+ }
+ end)
+ |> Enum.filter(&(is_binary(&1["url"]) and &1["url"] != ""))
+ end
+
+ defp normalize_mastodon_attachments(_), do: []
+
+ defp lemmy_object_fallback(uri, body, response_headers, opts) do
+ if html_response?(body, response_headers) do
+ with {:ok, _resolve_url, type} <- lemmy_resolve_url(uri) do
+ case normalize_lemmy_html_object(body, uri, type) do
+ {:ok, object_data} ->
+ {:ok, object_data, type}
+
+ _ ->
+ with {:ok, resolve_url, ^type} <- lemmy_resolve_url(uri),
+ {:ok, resolved_body} <- fetch_lemmy_resolved_object(resolve_url, opts),
+ {:ok, object_data} <- normalize_lemmy_resolved_object(resolved_body, uri, type) do
+ {:ok, object_data, type}
+ else
+ _ -> {:error, :no_object_fallback}
+ end
+ end
+ end
+ else
+ {:error, :no_object_fallback}
+ end
+ end
+
+ defp html_response?(body, response_headers)
+ when is_binary(body) and is_list(response_headers) do
+ content_type =
+ response_headers
+ |> Enum.find_value(fn {key, value} ->
+ if String.downcase(key) == "content-type", do: String.downcase(value), else: nil
+ end)
+
+ is_binary(content_type) && String.contains?(content_type, "text/html") &&
+ String.contains?(String.downcase(body), "
+ {:ok, "#{scheme}://#{host}/api/v4/site", :site}
+
+ %URI{scheme: scheme, host: host, path: "/u/" <> _rest}
+ when scheme in ["http", "https"] and is_binary(host) ->
+ {:ok, "#{scheme}://#{host}/api/v4/resolve_object?q=#{URI.encode_www_form(uri)}", :person}
+
+ %URI{scheme: scheme, host: host, path: "/c/" <> _rest}
+ when scheme in ["http", "https"] and is_binary(host) ->
+ {:ok, "#{scheme}://#{host}/api/v4/resolve_object?q=#{URI.encode_www_form(uri)}", :group}
+
+ %URI{scheme: scheme, host: host, path: "/comment/" <> comment_id}
+ when scheme in ["http", "https"] and is_binary(host) ->
+ if Regex.match?(~r{^\d+(?:$|/)}, comment_id) do
+ {:ok, "#{scheme}://#{host}/api/v4/resolve_object?q=#{URI.encode_www_form(uri)}",
+ :comment}
+ else
+ {:error, :unsupported_actor_path}
+ end
+
+ %URI{scheme: scheme, host: host, path: path}
+ when scheme in ["http", "https"] and is_binary(host) ->
+ if Regex.match?(
+ ~r{^/(post/\d+|c/[^/]+/p/\d+|m/[^/]+/[pt]/\d+)(?:$|/)},
+ String.trim_leading(path, "/") |> then(&("/" <> &1))
+ ) do
+ {:ok, "#{scheme}://#{host}/api/v4/resolve_object?q=#{URI.encode_www_form(uri)}", :page}
+ else
+ {:error, :unsupported_actor_path}
+ end
+
+ _ ->
+ {:error, :unsupported_actor_path}
+ end
+ end
+
+ defp lemmy_resolve_url(_), do: {:error, :unsupported_actor_path}
+
+ defp fetch_lemmy_resolved_object(resolve_url, opts) do
+ headers = [
+ {"accept", "application/json"},
+ {"user-agent", "Elektrine/1.0"}
+ ]
+
+ case request_lemmy_resolve_with_fallback(resolve_url, headers, request_opts(opts)) do
+ {:ok, %Finch.Response{status: 200, body: body}} ->
+ Jason.decode(body)
+
+ _ ->
+ {:error, :resolve_failed}
+ end
+ end
+
+ defp request_lemmy_resolve_with_fallback(resolve_url, headers, opts) do
+ case request_with_backoff(resolve_url, headers, opts) do
+ {:ok, %Finch.Response{status: 404}} ->
+ resolve_url
+ |> fallback_lemmy_api_url()
+ |> case do
+ nil -> {:ok, %Finch.Response{status: 404, headers: [], body: ""}}
+ fallback_url -> request_with_backoff(fallback_url, headers, opts)
+ end
+
+ other ->
+ other
+ end
+ end
+
+ defp fallback_lemmy_api_url(url) when is_binary(url) do
+ if String.contains?(url, "/api/v4/") do
+ String.replace(url, "/api/v4/", "/api/v3/", global: false)
+ else
+ nil
+ end
+ end
+
+ defp fallback_lemmy_api_url(_), do: nil
+
+ defp normalize_lemmy_resolved_actor(
+ %{"site_view" => %{"site" => site}},
+ requested_uri,
+ :site
+ )
+ when is_map(site) do
+ actor_id = site["actor_id"] || requested_uri
+
+ preferred_username =
+ actor_id
+ |> URI.parse()
+ |> Map.get(:host)
+ |> case do
+ host when is_binary(host) and host != "" -> host
+ _ -> site["name"] || "instance"
+ end
+
+ {:ok,
+ %{
+ "id" => actor_id,
+ "type" => "Application",
+ "preferredUsername" => preferred_username,
+ "name" => site["name"] || preferred_username,
+ "summary" => site["description"],
+ "icon" => normalize_lemmy_image(site["icon"]),
+ "image" => normalize_lemmy_image(site["banner"]),
+ "inbox" => site["inbox_url"] || actor_id <> "/inbox",
+ "published" => site["published"],
+ "updated" => site["updated"],
+ "publicKey" => %{
+ "id" => actor_id <> "#main-key",
+ "owner" => actor_id,
+ "publicKeyPem" => site["public_key"] || unavailable_public_key_pem()
+ },
+ "endpoints" => %{"sharedInbox" => shared_inbox_for_actor(actor_id)}
+ }}
+ end
+
+ defp normalize_lemmy_resolved_actor(
+ %{"person" => %{"person" => person}},
+ requested_uri,
+ :person
+ )
+ when is_map(person) do
+ actor_id = person["actor_id"] || requested_uri
+
+ {:ok,
+ %{
+ "id" => actor_id,
+ "type" => "Person",
+ "preferredUsername" => person["name"],
+ "name" => person["display_name"],
+ "summary" => person["bio"],
+ "icon" => normalize_lemmy_image(person["avatar"]),
+ "image" => normalize_lemmy_image(person["banner"]),
+ "inbox" => actor_id <> "/inbox",
+ "outbox" => actor_id <> "/outbox",
+ "followers" => actor_id <> "/followers",
+ "following" => actor_id <> "/following",
+ "published" => person["published"],
+ "publicKey" => %{
+ "id" => actor_id <> "#main-key",
+ "owner" => actor_id,
+ "publicKeyPem" => unavailable_public_key_pem()
+ },
+ "endpoints" => %{"sharedInbox" => shared_inbox_for_actor(actor_id)}
+ }}
+ end
+
+ defp normalize_lemmy_resolved_actor(
+ %{"community" => %{"community" => community}},
+ requested_uri,
+ :group
+ )
+ when is_map(community) do
+ actor_id = community["actor_id"] || requested_uri
+
+ {:ok,
+ %{
+ "id" => actor_id,
+ "type" => "Group",
+ "preferredUsername" => community["name"],
+ "name" => community["title"] || community["name"],
+ "summary" => community["description"],
+ "icon" => normalize_lemmy_image(community["icon"]),
+ "image" => normalize_lemmy_image(community["banner"]),
+ "inbox" => actor_id <> "/inbox",
+ "outbox" => actor_id <> "/outbox",
+ "followers" => actor_id <> "/followers",
+ "published" => community["published"],
+ "updated" => community["updated"],
+ "publicKey" => %{
+ "id" => actor_id <> "#main-key",
+ "owner" => actor_id,
+ "publicKeyPem" => unavailable_public_key_pem()
+ },
+ "endpoints" => %{"sharedInbox" => shared_inbox_for_actor(actor_id)}
+ }}
+ end
+
+ defp normalize_lemmy_resolved_actor(_, _, _), do: {:error, :invalid_resolved_actor}
+
+ defp normalize_lemmy_resolved_object(resolved_body, requested_uri, :person) do
+ normalize_lemmy_resolved_actor(resolved_body, requested_uri, :person)
+ end
+
+ defp normalize_lemmy_resolved_object(resolved_body, requested_uri, :site) do
+ normalize_lemmy_resolved_actor(resolved_body, requested_uri, :site)
+ end
+
+ defp normalize_lemmy_resolved_object(resolved_body, requested_uri, :group) do
+ normalize_lemmy_resolved_actor(resolved_body, requested_uri, :group)
+ end
+
+ defp normalize_lemmy_resolved_object(%{"comment" => comment_view}, requested_uri, :comment)
+ when is_map(comment_view) do
+ comment = comment_view["comment"] || %{}
+ creator = comment_view["creator"] || %{}
+ post = comment_view["post"] || %{}
+ community = comment_view["community"] || %{}
+ counts = comment_view["counts"] || %{}
+
+ object_id = comment["ap_id"] || requested_uri
+ post_url = post["ap_id"] || post["url"] || requested_uri
+ community_actor_id = community["actor_id"]
+
+ {:ok,
+ %{
+ "id" => object_id,
+ "type" => "Note",
+ "url" => object_id,
+ "_source_url" => requested_uri,
+ "content" => comment["content"] || "",
+ "published" => comment["published"],
+ "updated" => comment["updated"],
+ "attributedTo" => creator["actor_id"],
+ "to" => ["https://www.w3.org/ns/activitystreams#Public"],
+ "cc" => Enum.reject([community_actor_id], &is_nil/1),
+ "inReplyTo" => normalize_lemmy_comment_parent_ref(comment, post_url, requested_uri),
+ "likes" => %{"totalItems" => counts["upvotes"] || 0},
+ "replies" => %{"type" => "Collection", "totalItems" => counts["child_count"] || 0},
+ "shares" => %{"totalItems" => 0},
+ "sensitive" => false,
+ "_lemmy" => %{
+ "community_actor_id" => community_actor_id,
+ "creator_name" => creator["name"],
+ "creator_avatar" => creator["avatar"],
+ "score" => counts["score"] || 0,
+ "upvotes" => counts["upvotes"] || 0,
+ "downvotes" => counts["downvotes"] || 0,
+ "child_count" => counts["child_count"] || 0
+ }
+ }}
+ end
+
+ defp normalize_lemmy_resolved_object(%{"post" => post_view}, requested_uri, :page)
+ when is_map(post_view) do
+ post = post_view["post"] || %{}
+ creator = post_view["creator"] || %{}
+ community = post_view["community"] || %{}
+ counts = post_view["counts"] || %{}
+
+ object_id = post["ap_id"] || requested_uri
+ community_actor_id = community["actor_id"]
+ external_url = normalize_external_post_url(post["url"], object_id)
+ comment_count = counts["comments"] || 0
+
+ {:ok,
+ %{
+ "id" => object_id,
+ "type" => "Page",
+ "url" => external_url || object_id,
+ "name" => post["name"],
+ "content" => post["body"],
+ "published" => post["published"],
+ "updated" => post["updated"],
+ "attributedTo" => creator["actor_id"],
+ "to" => ["https://www.w3.org/ns/activitystreams#Public"],
+ "cc" => Enum.reject([community_actor_id], &is_nil/1),
+ "sensitive" => post["nsfw"] || false,
+ "comments" => %{"type" => "Collection", "totalItems" => comment_count},
+ "replies" => %{"type" => "Collection", "totalItems" => comment_count},
+ "attachment" => normalize_lemmy_post_attachment(external_url, post["name"]),
+ "_lemmy" => %{
+ "community_actor_id" => community_actor_id,
+ "creator_name" => creator["name"],
+ "creator_avatar" => creator["avatar"],
+ "score" => counts["score"] || 0,
+ "upvotes" => counts["upvotes"] || 0,
+ "downvotes" => counts["downvotes"] || 0
+ }
+ }}
+ end
+
+ defp normalize_lemmy_resolved_object(_, _, _), do: {:error, :invalid_resolved_object}
+
+ defp normalize_lemmy_html_object(body, requested_uri, type)
+ when is_binary(body) and type in [:site, :person, :group, :comment, :page] do
+ with {:ok, iso_data} <- extract_lemmy_iso_data(body),
+ {:ok, resolved_body} <- lemmy_iso_data_to_resolved_body(iso_data, requested_uri, type),
+ {:ok, object_data} <- normalize_lemmy_resolved_object(resolved_body, requested_uri, type) do
+ {:ok, object_data}
+ else
+ _ -> {:error, :invalid_html_object}
+ end
+ end
+
+ defp normalize_lemmy_html_object(_, _, _), do: {:error, :invalid_html_object}
+
+ defp extract_lemmy_iso_data(body) when is_binary(body) do
+ case Regex.run(~r/window\.isoData\s*=\s*(\{.*?\});/s, body, capture: :all_but_first) do
+ [json] -> json |> normalize_lemmy_iso_data_json() |> Jason.decode()
+ _ -> {:error, :missing_iso_data}
+ end
+ end
+
+ defp normalize_lemmy_iso_data_json(json) when is_binary(json) do
+ Regex.replace(~r/:undefined(?=\s*[,}])/, json, ":null")
+ end
+
+ defp lemmy_iso_data_to_resolved_body(%{"site_res" => site_res}, _requested_uri, :site)
+ when is_map(site_res),
+ do: {:ok, site_res}
+
+ defp lemmy_iso_data_to_resolved_body(
+ %{"person_res" => %{"person_view" => person_view}},
+ _requested_uri,
+ :person
+ )
+ when is_map(person_view),
+ do: {:ok, %{"person" => person_view}}
+
+ defp lemmy_iso_data_to_resolved_body(
+ %{"community_res" => %{"community_view" => community_view}},
+ _requested_uri,
+ :group
+ )
+ when is_map(community_view),
+ do: {:ok, %{"community" => community_view}}
+
+ defp lemmy_iso_data_to_resolved_body(
+ %{"comment_res" => %{"comment_view" => comment_view}},
+ _requested_uri,
+ :comment
+ )
+ when is_map(comment_view),
+ do: {:ok, %{"comment" => comment_view}}
+
+ defp lemmy_iso_data_to_resolved_body(
+ %{"routeData" => %{"commentsRes" => %{"data" => %{"comments" => comments}}}},
+ requested_uri,
+ :comment
+ )
+ when is_list(comments) do
+ case find_lemmy_comment_view(comments, requested_uri) do
+ %{} = comment_view -> {:ok, %{"comment" => comment_view}}
+ nil -> {:error, :unsupported_iso_data}
+ end
+ end
+
+ defp lemmy_iso_data_to_resolved_body(
+ %{"post_res" => %{"post_view" => post_view}},
+ _requested_uri,
+ :page
+ )
+ when is_map(post_view),
+ do: {:ok, %{"post" => post_view}}
+
+ defp lemmy_iso_data_to_resolved_body(
+ %{"routeData" => %{"postRes" => %{"data" => %{"post_view" => post_view}}}},
+ _requested_uri,
+ :page
+ )
+ when is_map(post_view),
+ do: {:ok, %{"post" => post_view}}
+
+ defp lemmy_iso_data_to_resolved_body(_, _, _), do: {:error, :unsupported_iso_data}
+
+ defp find_lemmy_comment_view(comments, requested_uri) when is_list(comments) do
+ requested_comment_id = lemmy_comment_id_from_uri(requested_uri)
+
+ Enum.find(comments, fn comment_view ->
+ comment = comment_view["comment"] || %{}
+ counts = comment_view["counts"] || %{}
+
+ comment["ap_id"] == requested_uri ||
+ comment["id"] == requested_comment_id ||
+ counts["comment_id"] == requested_comment_id
+ end)
+ end
+
+ defp lemmy_comment_id_from_uri(requested_uri) when is_binary(requested_uri) do
+ case URI.parse(requested_uri) do
+ %URI{path: "/comment/" <> comment_id} ->
+ case Integer.parse(comment_id) do
+ {id, _} -> id
+ :error -> nil
+ end
+
+ _ ->
+ nil
+ end
+ end
+
+ defp lemmy_comment_id_from_uri(_), do: nil
+
+ defp normalize_external_post_url(url, object_id) when is_binary(url) and url != object_id,
+ do: url
+
+ defp normalize_external_post_url(_, _), do: nil
+
+ defp normalize_lemmy_comment_parent_ref(comment, post_url, requested_uri)
+ when is_map(comment) do
+ case comment["path"] do
+ path when is_binary(path) ->
+ case String.split(path, ".") |> Enum.reverse() do
+ [_self, parent_id | _] when parent_id not in [nil, "", "0"] ->
+ build_lemmy_comment_uri(requested_uri, parent_id)
+
+ _ ->
+ post_url
+ end
+
+ _ ->
+ post_url
+ end
+ end
+
+ defp normalize_lemmy_comment_parent_ref(_, post_url, _requested_uri), do: post_url
+
+ defp build_lemmy_comment_uri(requested_uri, comment_id) do
+ case URI.parse(requested_uri) do
+ %URI{scheme: scheme, host: host}
+ when scheme in ["http", "https"] and is_binary(host) and is_binary(comment_id) ->
+ "#{scheme}://#{host}/comment/#{comment_id}"
+
+ _ ->
+ requested_uri
+ end
+ end
+
+ defp normalize_lemmy_post_attachment(url, name) when is_binary(url) do
+ [
+ %{
+ "type" => "Link",
+ "href" => url,
+ "name" => name || url
+ }
+ ]
+ end
+
+ defp normalize_lemmy_post_attachment(_, _), do: []
+
+ defp normalize_lemmy_image(url) when is_binary(url), do: %{"type" => "Image", "url" => url}
+ defp normalize_lemmy_image(_), do: nil
+
+ defp shared_inbox_for_actor(actor_id) when is_binary(actor_id) do
+ case URI.parse(actor_id) do
+ %URI{scheme: scheme, host: host} when scheme in ["http", "https"] and is_binary(host) ->
+ "#{scheme}://#{host}/inbox"
+
+ _ ->
+ nil
+ end
+ end
+
+ defp unavailable_public_key_pem do
+ "-----BEGIN PUBLIC KEY-----\nUNAVAILABLE\n-----END PUBLIC KEY-----"
+ end
+
+ defp validate_fetch_url(uri, kind, opts) when is_binary(uri) do
+ if Keyword.get(opts, :validate_url, true) do
+ case URLValidator.validate(uri) do
+ :ok ->
+ :ok
+
+ {:error, reason} ->
+ Logger.warning(
+ "Blocked unsafe ActivityPub #{kind} URL #{inspect(uri)}: #{inspect(reason)}"
+ )
+
+ {:error, :unsafe_url}
+ end
+ else
+ :ok
+ end
+ end
+
+ defp validate_fetch_url(_uri, _kind, _opts), do: {:error, :unsafe_url}
+
+ defp request_with_backoff(url, headers, opts) do
+ request_fun = Keyword.get(opts, :request_fun, &Backoff.get/3)
+ request_fun.(url, headers, Keyword.drop(opts, [:request_fun]))
+ end
+
+ defp log_failed_fetch(uri, %Finch.Response{} = response) do
+ Logger.warning(
+ "Failed to fetch from #{uri}, status: #{response.status}, body: #{String.slice(response.body || "", 0, 200)}"
+ )
+ end
+end
diff --git a/apps/elektrine/lib/elektrine/activitypub/group_follow.ex b/apps/elektrine/lib/elektrine/activitypub/group_follow.ex
new file mode 100644
index 0000000..80a6a2b
--- /dev/null
+++ b/apps/elektrine/lib/elektrine/activitypub/group_follow.ex
@@ -0,0 +1,25 @@
+defmodule Elektrine.ActivityPub.GroupFollow do
+ @moduledoc """
+ Schema representing a remote actor following a local Group actor (community).
+ Used to track followers for community federation delivery.
+ """
+ use Ecto.Schema
+ import Ecto.Changeset
+
+ schema "group_follows" do
+ belongs_to :remote_actor, Elektrine.ActivityPub.Actor
+ belongs_to :group_actor, Elektrine.ActivityPub.Actor
+ field :activitypub_id, :string
+ field :pending, :boolean, default: false
+
+ timestamps()
+ end
+
+ @doc false
+ def changeset(group_follow, attrs) do
+ group_follow
+ |> cast(attrs, [:remote_actor_id, :group_actor_id, :activitypub_id, :pending])
+ |> validate_required([:remote_actor_id, :group_actor_id])
+ |> unique_constraint([:remote_actor_id, :group_actor_id])
+ end
+end
diff --git a/apps/elektrine/lib/elektrine/activitypub/handler.ex b/apps/elektrine/lib/elektrine/activitypub/handler.ex
new file mode 100644
index 0000000..aed30a3
--- /dev/null
+++ b/apps/elektrine/lib/elektrine/activitypub/handler.ex
@@ -0,0 +1,327 @@
+defmodule Elektrine.ActivityPub.Handler do
+ @moduledoc """
+ Handles incoming ActivityPub activities.
+
+ This module routes activities to specialized handlers:
+ - FollowHandler: Follow, Accept, Reject activities
+ - CreateHandler: Create activities for Notes, Pages, Articles, Questions
+ - LikeHandler: Like, Dislike, EmojiReact activities
+ - AnnounceHandler: Announce (boost/share) activities
+ - MoveHandler: Account migration activities
+ - DeleteHandler: Delete activities
+ - UpdateHandler: Update activities
+ - BlockHandler: Block activities
+ """
+
+ require Logger
+
+ alias Elektrine.ActivityPub
+ alias Elektrine.ActivityPub.{MRF, Normalizer, ObjectValidator, UndoResolver}
+
+ alias Elektrine.ActivityPub.Handlers.{
+ AnnounceHandler,
+ BlockHandler,
+ CreateHandler,
+ DeleteHandler,
+ FlagHandler,
+ FollowHandler,
+ LikeHandler,
+ MoveHandler,
+ UpdateHandler
+ }
+
+ @doc """
+ Stores a remote post object locally.
+ Used when we need to interact with remote posts (like, boost, reply).
+ """
+ def store_remote_post(post_object, actor_uri) do
+ actor_uri = Normalizer.actor_ref_uri(actor_uri) || Normalizer.actor_uri(post_object)
+
+ if is_binary(actor_uri) do
+ CreateHandler.handle(%{"object" => post_object}, actor_uri, nil)
+ else
+ {:error, :invalid_actor_uri}
+ end
+ end
+
+ @doc """
+ Queues an activity for async processing.
+
+ This function validates and saves the activity, then returns immediately.
+ The IncomingActivityWorker will process it in the background.
+
+ Returns {:ok, :queued} on success, allowing the inbox endpoint to return 202 Accepted.
+ """
+ def handle_activity(activity, actor_uri, _target_user) do
+ # Check if the instance is blocked
+ %URI{host: domain} = URI.parse(actor_uri)
+
+ if ActivityPub.instance_blocked?(domain) do
+ {:ok, :blocked}
+ else
+ # Validate the activity structure first
+ case ObjectValidator.validate(activity) do
+ {:error, reason} ->
+ Logger.warning("Invalid activity from #{actor_uri}: #{reason}")
+ {:ok, :invalid}
+
+ {:ok, validated_activity} ->
+ # Run MRF policies
+ case apply_mrf_policies(validated_activity, actor_uri) do
+ {:reject, _reason} ->
+ {:ok, :mrf_rejected}
+
+ {:ok, filtered_activity} ->
+ # Save the (possibly modified) activity for async processing
+ case ActivityPub.create_activity(%{
+ activity_id: filtered_activity["id"],
+ activity_type: filtered_activity["type"],
+ actor_uri: actor_uri,
+ object_id: get_object_id(filtered_activity),
+ data: filtered_activity,
+ local: false,
+ processed: false
+ }) do
+ {:ok, _record} ->
+ # Trigger the worker to process soon (non-blocking)
+ spawn(fn -> Elektrine.ActivityPub.IncomingActivityWorker.process_now() end)
+ {:ok, :queued}
+
+ {:error, %Ecto.Changeset{errors: [activity_id: {"has already been taken", _}]}} ->
+ {:ok, :already_received}
+
+ {:error, changeset} ->
+ Logger.error("Failed to save activity: #{inspect(changeset)}")
+ {:error, :failed_to_save_activity}
+ end
+ end
+ end
+ end
+ end
+
+ @doc """
+ Processes an activity asynchronously (called by IncomingActivityWorker).
+
+ This does the actual work of handling the activity, including HTTP calls
+ to fetch actors and objects from remote servers.
+ """
+ def process_activity_async(activity, actor_uri, target_user) do
+ domain = actor_uri |> URI.parse() |> Map.get(:host)
+
+ if domain && ActivityPub.instance_blocked?(domain) do
+ {:ok, :blocked}
+ else
+ case ObjectValidator.validate(activity) do
+ {:error, reason} ->
+ Logger.warning("Invalid activity from #{actor_uri}: #{reason}")
+ {:ok, :invalid}
+
+ {:ok, validated_activity} ->
+ target_user = target_user || ActivityPub.resolve_target_user(validated_activity)
+
+ # Check if user has blocked this actor
+ if target_user && ActivityPub.user_blocked?(target_user.id, actor_uri) do
+ {:ok, :blocked}
+ else
+ case apply_mrf_policies(validated_activity, actor_uri) do
+ {:reject, _reason} ->
+ {:ok, :mrf_rejected}
+
+ {:ok, filtered_activity} ->
+ route_activity(filtered_activity, actor_uri, target_user)
+ end
+ end
+ end
+ end
+ end
+
+ def retryable_error?(reason) when is_atom(reason) do
+ not non_retryable_error?(reason)
+ end
+
+ def retryable_error?({:validation_failed, _}), do: false
+ def retryable_error?({:rejected, _}), do: false
+ def retryable_error?({:key_fetch_failed, _}), do: true
+
+ def retryable_error?(_reason), do: true
+
+ def non_retryable_error?(reason)
+ when reason in [
+ :actor_mismatch,
+ :blocked,
+ :blocked_instance,
+ :domain_unreachable,
+ :handle_like_failed,
+ :handle_dislike_failed,
+ :invalid,
+ :invalid_activity,
+ :invalid_signature,
+ :local_fetch_containment_failed,
+ :mrf_rejected,
+ :origin_containment_failed,
+ :fetch_failed,
+ :http_error,
+ :not_found,
+ :object_id_mismatch,
+ :unauthorized,
+ :unauthorized_fetch,
+ :unsafe_url
+ ] do
+ true
+ end
+
+ def non_retryable_error?(_reason), do: false
+
+ # Route activity to the appropriate handler
+ defp route_activity(activity, actor_uri, target_user) do
+ case activity["type"] do
+ "Follow" ->
+ FollowHandler.handle(activity, actor_uri, target_user)
+
+ "Accept" ->
+ FollowHandler.handle_accept(activity, actor_uri, target_user)
+
+ "Reject" ->
+ FollowHandler.handle_reject(activity, actor_uri, target_user)
+
+ "Create" ->
+ CreateHandler.handle(activity, actor_uri, target_user)
+
+ "Like" ->
+ LikeHandler.handle(activity, actor_uri, target_user)
+
+ "Dislike" ->
+ LikeHandler.handle_dislike(activity, actor_uri, target_user)
+
+ "EmojiReact" ->
+ LikeHandler.handle_emoji_react(activity, actor_uri, target_user)
+
+ "Announce" ->
+ AnnounceHandler.handle(activity, actor_uri, target_user)
+
+ "Move" ->
+ MoveHandler.handle(activity, actor_uri, target_user)
+
+ "Undo" ->
+ handle_undo(activity, actor_uri, target_user)
+
+ "Delete" ->
+ DeleteHandler.handle(activity, actor_uri, target_user)
+
+ "Update" ->
+ UpdateHandler.handle(activity, actor_uri, target_user)
+
+ "Block" ->
+ BlockHandler.handle(activity, actor_uri, target_user)
+
+ "Flag" ->
+ FlagHandler.handle(activity, actor_uri, target_user)
+
+ _ ->
+ {:ok, :unhandled}
+ end
+ end
+
+ # Handle Undo activities by routing to the appropriate handler
+ defp handle_undo(%{"object" => object} = _activity, actor_uri, _target_user)
+ when is_map(object) do
+ case object["type"] do
+ "Follow" ->
+ FollowHandler.handle_undo(object, actor_uri)
+
+ "Like" ->
+ LikeHandler.handle_undo_like(object, actor_uri)
+
+ "Dislike" ->
+ LikeHandler.handle_undo_dislike(object, actor_uri)
+
+ "EmojiReact" ->
+ LikeHandler.handle_undo_emoji_react(object, actor_uri)
+
+ "Announce" ->
+ AnnounceHandler.handle_undo(object, actor_uri)
+
+ "Block" ->
+ BlockHandler.handle_undo(object, actor_uri)
+
+ _ ->
+ {:ok, :unhandled}
+ end
+ end
+
+ # Handle Undo when object is a URI string - fetch and process
+ defp handle_undo(%{"object" => object_uri} = activity, actor_uri, target_user)
+ when is_binary(object_uri) do
+ case UndoResolver.resolve(object_uri, actor_uri) do
+ {:ok, object} ->
+ handle_undo(%{activity | "object" => object}, actor_uri, target_user)
+
+ :not_found ->
+ case Elektrine.ActivityPub.RemoteFetch.fetch_object(object_uri) do
+ {:ok, object} when is_map(object) ->
+ handle_undo(%{activity | "object" => object}, actor_uri, target_user)
+
+ {:ok, _object} ->
+ Logger.warning("Undo object #{object_uri} did not resolve to an object map")
+ {:error, :undo_activity_fetch_failed}
+
+ {:error, reason} ->
+ Logger.warning("Failed to fetch Undo object #{object_uri}: #{inspect(reason)}")
+ {:error, :undo_activity_fetch_failed}
+ end
+ end
+ end
+
+ defp handle_undo(_activity, _actor_uri, _target_user), do: {:ok, :unhandled}
+
+ defp get_object_id(%{"object" => object}) when is_binary(object), do: object
+ defp get_object_id(%{"object" => %{"id" => id}}), do: id
+ defp get_object_id(_), do: nil
+
+ defp apply_mrf_policies(activity, actor_uri) do
+ case MRF.filter(activity) do
+ {:reject, reason} ->
+ Logger.info("MRF rejected activity from #{actor_uri}: #{reason}")
+ {:reject, reason}
+
+ {:ok, filtered_activity} ->
+ {:ok, filtered_activity}
+ end
+ end
+
+ # Legacy functions kept for backwards compatibility
+
+ @doc """
+ Extracts local usernames from ActivityPub Mention tags.
+ Delegated to CreateHandler for implementation.
+ """
+ def extract_local_mentions(object) do
+ # Keep for backwards compatibility - call internal implementation
+ extract_local_mentions_internal(object)
+ end
+
+ defp extract_local_mentions_internal(object) do
+ case object["tag"] do
+ tags when is_list(tags) ->
+ tags
+ |> Enum.filter(fn tag -> tag["type"] == "Mention" end)
+ |> Enum.map(fn tag ->
+ case extract_local_username_from_uri(tag["href"]) do
+ {:ok, username} -> username
+ _ -> nil
+ end
+ end)
+ |> Enum.filter(&(&1 != nil))
+ |> Enum.uniq()
+
+ _ ->
+ []
+ end
+ end
+
+ defp extract_local_username_from_uri(uri) when is_binary(uri) do
+ Elektrine.ActivityPub.local_username_from_uri(uri)
+ end
+
+ defp extract_local_username_from_uri(_), do: {:error, :invalid_uri}
+end
diff --git a/apps/elektrine/lib/elektrine/activitypub/handlers/announce_handler.ex b/apps/elektrine/lib/elektrine/activitypub/handlers/announce_handler.ex
new file mode 100644
index 0000000..bcd9981
--- /dev/null
+++ b/apps/elektrine/lib/elektrine/activitypub/handlers/announce_handler.ex
@@ -0,0 +1,445 @@
+defmodule Elektrine.ActivityPub.Handlers.AnnounceHandler do
+ @moduledoc """
+ Handles Announce (boost/share) ActivityPub activities.
+ """
+
+ require Logger
+
+ alias Elektrine.ActivityPub
+ alias Elektrine.ActivityPub.Normalizer
+ alias Elektrine.Async
+ alias Elektrine.Messaging
+
+ @doc """
+ Handles an incoming Announce activity.
+ """
+ def handle(%{"object" => object_uri} = activity, actor_uri, _target_user)
+ when is_binary(object_uri) do
+ activity_id = activity["id"]
+
+ case get_local_message_from_uri(object_uri) do
+ {:ok, message} ->
+ # Local post being boosted
+ with {:ok, remote_actor} <- ActivityPub.get_or_fetch_actor(actor_uri) do
+ case Messaging.create_federated_boost(message.id, remote_actor.id, activity_id) do
+ {:ok, _} ->
+ Async.run(fn ->
+ Elektrine.Notifications.FederationNotifications.notify_remote_announce(
+ message.id,
+ remote_actor.id
+ )
+ end)
+
+ {:ok, :announced}
+
+ {:error, reason} ->
+ Logger.error("Failed to create boost record: #{inspect(reason)}")
+ {:error, :failed_to_boost}
+ end
+ end
+
+ {:error, :message_not_found} ->
+ handle_remote_announce(object_uri, actor_uri, activity_id)
+ end
+ end
+
+ def handle(%{"object" => objects}, actor_uri, target_user) when is_list(objects) do
+ {any_ok?, any_announced?, first_error} =
+ Enum.reduce(objects, {false, false, nil}, fn object_ref,
+ {any_ok?, any_announced?, first_error} ->
+ case handle(%{"object" => object_ref}, actor_uri, target_user) do
+ {:ok, result} ->
+ announced? = result in [:announced, :unannounced]
+ {true, any_announced? || announced?, first_error}
+
+ {:error, reason} ->
+ Logger.warning("Failed to process item in Announce object list: #{inspect(reason)}")
+ {any_ok?, any_announced?, first_error || reason}
+ end
+ end)
+
+ cond do
+ any_announced? -> {:ok, :announced}
+ any_ok? -> {:ok, :ignored}
+ is_atom(first_error) -> {:error, first_error}
+ true -> {:ok, :ignored}
+ end
+ end
+
+ def handle(%{"object" => object_ref}, actor_uri, target_user) when is_map(object_ref) do
+ case extract_target_object_uri(object_ref) do
+ nil -> {:error, :invalid_object}
+ object_uri -> handle(%{"object" => object_uri}, actor_uri, target_user)
+ end
+ end
+
+ @doc """
+ Handles Undo Announce activity.
+ """
+ def handle_undo(%{"object" => object_ref}, actor_uri) do
+ object_uri = extract_target_object_uri(object_ref)
+
+ with {:ok, object_uri} when not is_nil(object_uri) <- {:ok, object_uri},
+ {:ok, remote_actor} <- ActivityPub.get_or_fetch_actor(actor_uri),
+ {:ok, message} <- get_local_message_from_uri(object_uri) do
+ Messaging.delete_federated_boost(message.id, remote_actor.id)
+ {:ok, :unannounced}
+ else
+ {:error, :message_not_found} ->
+ {:ok, :message_not_found}
+
+ error ->
+ Logger.warning("Failed to undo announce: #{inspect(error)}")
+ {:error, :undo_announce_failed}
+ end
+ end
+
+ # Private functions
+
+ defp handle_remote_announce(object_uri, actor_uri, announce_activity_id) do
+ with {:ok, booster_actor} <- ActivityPub.get_or_fetch_actor(actor_uri),
+ {:ok, object} <- Elektrine.ActivityPub.RemoteFetch.fetch_object(object_uri),
+ {:ok, {actual_object, original_actor_uri}} <- unwrap_object(object) do
+ cond do
+ is_nil(actual_object) ->
+ {:ok, :ignored}
+
+ actual_object["type"] not in ["Note", "Page", "Article"] ->
+ {:ok, :ignored}
+
+ !is_binary(original_actor_uri) ->
+ {:ok, :ignored}
+
+ original_actor_uri == actor_uri ->
+ {:ok, :ignored}
+
+ true ->
+ create_or_boost_post(
+ actual_object,
+ original_actor_uri,
+ booster_actor,
+ announce_activity_id
+ )
+ end
+ else
+ {:error, :not_found} ->
+ if remote_object_uri?(object_uri) || remote_activity_wrapper_uri?(object_uri) do
+ {:ok, :ignored}
+ else
+ {:error, :announce_object_fetch_failed}
+ end
+
+ {:error, :nested_fetch_failed} ->
+ {:error, :announce_object_fetch_failed}
+
+ {:error, reason} ->
+ if ignorable_remote_activity_wrapper_fetch_failure?(object_uri, reason) do
+ Logger.debug(
+ "Ignoring remote Announce wrapper #{object_uri} after fetch failure: #{inspect(reason)}"
+ )
+
+ {:ok, :ignored}
+ else
+ Logger.warning("Failed to fetch announced object #{object_uri}: #{inspect(reason)}")
+ {:error, :announce_object_fetch_failed}
+ end
+ end
+ end
+
+ defp unwrap_object(object, depth \\ 0)
+ defp unwrap_object(_object, depth) when depth > 4, do: {:ok, {nil, nil}}
+
+ defp unwrap_object(object, depth) do
+ case object["type"] do
+ type when type in ["Note", "Page", "Article"] ->
+ {:ok, {object, Normalizer.actor_uri(object)}}
+
+ "Create" ->
+ inner_object = object["object"]
+
+ if is_map(inner_object) do
+ inherited_object = inherit_wrapper_fields(object, inner_object)
+
+ {:ok,
+ {inherited_object,
+ Normalizer.actor_uri(inherited_object) || Normalizer.actor_ref_uri(object["actor"])}}
+ else
+ case Elektrine.ActivityPub.RemoteFetch.fetch_object(inner_object) do
+ {:ok, fetched} ->
+ inherited_object = inherit_wrapper_fields(object, fetched)
+
+ with {:ok, {resolved, actor_uri}} <- unwrap_object(inherited_object, depth + 1) do
+ {:ok,
+ {resolved,
+ actor_uri || Normalizer.actor_uri(inherited_object) ||
+ Normalizer.actor_ref_uri(object["actor"])}}
+ end
+
+ {:error, reason} ->
+ Logger.warning(
+ "Failed to fetch nested announced object #{inspect(inner_object)}: #{inspect(reason)}"
+ )
+
+ {:error, :nested_fetch_failed}
+ end
+ end
+
+ "Announce" ->
+ unwrap_announced_object(object["object"], object["actor"], depth + 1)
+
+ _ ->
+ {:ok, {nil, nil}}
+ end
+ end
+
+ defp unwrap_announced_object(object_ref, fallback_actor_uri, depth) when is_map(object_ref) do
+ with {:ok, {resolved, actor_uri}} <- unwrap_object(object_ref, depth) do
+ {:ok, {resolved, actor_uri || fallback_actor_uri}}
+ end
+ end
+
+ defp unwrap_announced_object(object_ref, fallback_actor_uri, depth)
+ when is_binary(object_ref) do
+ case Elektrine.ActivityPub.RemoteFetch.fetch_object(object_ref) do
+ {:ok, fetched} ->
+ with {:ok, {resolved, actor_uri}} <- unwrap_object(fetched, depth) do
+ {:ok, {resolved, actor_uri || fallback_actor_uri}}
+ end
+
+ {:error, reason} ->
+ Logger.warning(
+ "Failed to fetch nested announced object #{inspect(object_ref)}: #{inspect(reason)}"
+ )
+
+ {:error, :nested_fetch_failed}
+ end
+ end
+
+ defp unwrap_announced_object(_object_ref, _fallback_actor_uri, _depth), do: {:ok, {nil, nil}}
+
+ defp create_or_boost_post(
+ actual_object,
+ original_actor_uri,
+ booster_actor,
+ announce_activity_id
+ ) do
+ # Delegate to CreateHandler for the actual post creation
+ alias Elektrine.ActivityPub.Handlers.CreateHandler
+
+ create_opts =
+ if booster_actor.actor_type == "Group" do
+ [fallback_community_uri: booster_actor.uri]
+ else
+ []
+ end
+
+ with :ok <- CreateHandler.validate_object_author(actual_object, original_actor_uri),
+ false <-
+ ActivityPub.remote_tombstone_recorded?(original_actor_uri, [
+ actual_object["id"],
+ actual_object["url"]
+ ]) do
+ case CreateHandler.create_note(actual_object, original_actor_uri, create_opts) do
+ {:ok, :already_exists} ->
+ # Skip boost treatment for community distributions and relay actors
+ is_distribution = distribution_actor?(booster_actor)
+
+ unless is_distribution do
+ ap_id = actual_object["id"]
+
+ case Elektrine.Repo.get_by(Elektrine.Social.Message, activitypub_id: ap_id) do
+ %{id: message_id, deleted_at: nil} ->
+ Messaging.create_federated_boost(
+ message_id,
+ booster_actor.id,
+ announce_activity_id
+ )
+
+ nil ->
+ :ok
+
+ _deleted_message ->
+ :ok
+ end
+ end
+
+ {:ok, :announced}
+
+ {:ok, message} when is_struct(message) ->
+ # Skip boost treatment for community distributions and relay actors
+ is_distribution = distribution_actor?(booster_actor)
+
+ updated_msg =
+ if is_distribution do
+ message
+ else
+ Messaging.create_federated_boost(message.id, booster_actor.id, announce_activity_id)
+
+ case Elektrine.Social.Messages.update_message_metadata(message, %{
+ media_metadata:
+ Map.merge(message.media_metadata || %{}, %{
+ "boosted_by" => %{
+ "username" => booster_actor.username,
+ "domain" => booster_actor.domain,
+ "display_name" => booster_actor.display_name,
+ "avatar_url" => booster_actor.avatar_url
+ }
+ })
+ }) do
+ {:ok, updated} -> updated
+ {:error, _} -> message
+ end
+ end
+
+ # Reload with associations for broadcasting
+ reloaded =
+ Elektrine.Repo.preload(
+ updated_msg,
+ [:remote_actor, :sender, :link_preview, :hashtags],
+ force: true
+ )
+
+ Phoenix.PubSub.broadcast(
+ Elektrine.PubSub,
+ "timeline:public",
+ {:new_public_post, reloaded}
+ )
+
+ {:ok, :announced}
+
+ error ->
+ error
+ end
+ else
+ true ->
+ {:ok, :ignored_deleted_object}
+
+ {:error, :actor_mismatch} ->
+ Logger.warning(
+ "Rejecting Announce for #{inspect(actual_object["id"])}: object attributedTo does not match announced actor #{original_actor_uri}"
+ )
+
+ {:ok, :unauthorized}
+ end
+ end
+
+ defp extract_target_object_uri(object) when is_binary(object), do: object
+ defp extract_target_object_uri(%{"id" => id}) when is_binary(id), do: id
+
+ defp extract_target_object_uri(%{"object" => object_ref}) do
+ extract_target_object_uri(object_ref)
+ end
+
+ defp extract_target_object_uri(_), do: nil
+
+ # Check if actor is a distribution actor (relay or community group)
+ # These actors relay/distribute content, not boost it
+ defp distribution_actor?(actor) do
+ cond do
+ # Community/Group actors distribute content
+ actor.actor_type == "Group" ->
+ true
+
+ # Relay actors (Application type with relay in URI)
+ actor.actor_type == "Application" && relay_uri?(actor.uri) ->
+ true
+
+ # Service actors that are relays
+ actor.actor_type == "Service" && relay_uri?(actor.uri) ->
+ true
+
+ true ->
+ false
+ end
+ end
+
+ defp relay_uri?(uri) when is_binary(uri) do
+ uri_lower = String.downcase(uri)
+
+ String.contains?(uri_lower, "/relay") ||
+ String.contains?(uri_lower, "relay.") ||
+ (String.ends_with?(uri_lower, "/actor") && String.contains?(uri_lower, "relay"))
+ end
+
+ defp relay_uri?(_), do: false
+
+ defp remote_object_uri?(uri) when is_binary(uri) do
+ case URI.parse(uri) do
+ %URI{host: host} when is_binary(host) and host != "" ->
+ String.downcase(host) != String.downcase(ActivityPub.instance_domain())
+
+ _ ->
+ false
+ end
+ end
+
+ defp remote_object_uri?(_), do: false
+
+ defp ignorable_remote_activity_wrapper_fetch_failure?(uri, reason)
+ when reason in [:fetch_failed, :http_error, :backoff, :not_found, :unsafe_url] do
+ remote_activity_wrapper_uri?(uri)
+ end
+
+ defp ignorable_remote_activity_wrapper_fetch_failure?(_, _), do: false
+
+ defp remote_activity_wrapper_uri?(uri) when is_binary(uri) do
+ case URI.parse(uri) do
+ %URI{host: host, path: path}
+ when is_binary(host) and host != "" and is_binary(path) and path != "" ->
+ String.downcase(host) != String.downcase(ActivityPub.instance_domain()) &&
+ Regex.match?(~r{/(?:activity|activities)(?:/|$)}, String.downcase(path))
+
+ _ ->
+ false
+ end
+ end
+
+ defp remote_activity_wrapper_uri?(_), do: false
+
+ defp inherit_wrapper_fields(activity, object) when is_map(activity) and is_map(object) do
+ ["to", "cc", "audience", "published"]
+ |> Enum.reduce(object, fn field, acc ->
+ case {Map.get(acc, field), Map.get(activity, field)} do
+ {value, inherited} when value in [nil, ""] and not is_nil(inherited) ->
+ Map.put(acc, field, inherited)
+
+ {[], inherited} when not is_nil(inherited) ->
+ Map.put(acc, field, inherited)
+
+ _ ->
+ acc
+ end
+ end)
+ end
+
+ defp inherit_wrapper_fields(_activity, object), do: object
+
+ defp get_local_message_from_uri(uri) do
+ base_url = ActivityPub.instance_url()
+
+ cond do
+ String.starts_with?(uri, "#{base_url}/posts/") ->
+ id = String.replace_prefix(uri, "#{base_url}/posts/", "")
+ get_message_by_id(id)
+
+ String.match?(uri, ~r{#{base_url}/users/[^/]+/posts/}) ->
+ id = uri |> String.split("/posts/") |> List.last()
+ get_message_by_id(id)
+
+ true ->
+ case Messaging.get_message_by_activitypub_ref(uri) do
+ nil -> {:error, :message_not_found}
+ message -> {:ok, message}
+ end
+ end
+ end
+
+ defp get_message_by_id(id) do
+ case Messaging.get_message(id) do
+ nil -> {:error, :message_not_found}
+ message -> {:ok, message}
+ end
+ rescue
+ Ecto.Query.CastError -> {:error, :message_not_found}
+ end
+end
diff --git a/apps/elektrine/lib/elektrine/activitypub/handlers/block_handler.ex b/apps/elektrine/lib/elektrine/activitypub/handlers/block_handler.ex
new file mode 100644
index 0000000..ccfe9b0
--- /dev/null
+++ b/apps/elektrine/lib/elektrine/activitypub/handlers/block_handler.ex
@@ -0,0 +1,62 @@
+defmodule Elektrine.ActivityPub.Handlers.BlockHandler do
+ @moduledoc """
+ Handles Block ActivityPub activities.
+ """
+
+ require Logger
+
+ alias Elektrine.ActivityPub
+
+ @doc """
+ Handles an incoming Block activity.
+ Someone is blocking one of our users - we just acknowledge it.
+ The remote instance handles the actual blocking.
+ """
+ def handle(%{"object" => blocked_uri}, actor_uri, _target_user) do
+ with {:ok, _remote_actor} <- ActivityPub.get_or_fetch_actor(actor_uri),
+ {:ok, _blocked_user} <- get_local_user_from_uri(blocked_uri) do
+ # We don't need to do anything specific - the remote instance handles the blocking
+ # In the future, we could record this to hide our user's posts from the blocker
+ {:ok, :blocked}
+ else
+ {:error, :not_local} ->
+ {:ok, :not_our_user}
+
+ {:error, reason} ->
+ Logger.warning("Failed to handle block: #{inspect(reason)}")
+ {:error, :handle_block_failed}
+ end
+ end
+
+ @doc """
+ Handles Undo Block activity.
+ Someone is unblocking one of our users.
+ """
+ def handle_undo(%{"object" => blocked_uri}, actor_uri) do
+ with {:ok, _remote_actor} <- ActivityPub.get_or_fetch_actor(actor_uri),
+ {:ok, _blocked_user} <- get_local_user_from_uri(blocked_uri) do
+ # We don't need to do anything specific - the remote instance handles the unblocking
+ {:ok, :unblocked}
+ else
+ {:error, :not_local} ->
+ {:ok, :not_our_user}
+
+ {:error, reason} ->
+ Logger.warning("Failed to undo block: #{inspect(reason)}")
+ {:error, :undo_block_failed}
+ end
+ end
+
+ defp get_local_user_from_uri(uri) do
+ case ActivityPub.local_username_from_uri(uri) do
+ {:ok, username} ->
+ case Elektrine.Accounts.get_user_by_username(username) do
+ nil -> {:error, :not_found}
+ user -> {:ok, user}
+ end
+
+ {:error, _reason} ->
+ {:error, :not_local}
+ end
+ end
+end
diff --git a/apps/elektrine/lib/elektrine/activitypub/handlers/create_handler.ex b/apps/elektrine/lib/elektrine/activitypub/handlers/create_handler.ex
new file mode 100644
index 0000000..8b43fc9
--- /dev/null
+++ b/apps/elektrine/lib/elektrine/activitypub/handlers/create_handler.ex
@@ -0,0 +1,926 @@
+defmodule Elektrine.ActivityPub.Handlers.CreateHandler do
+ @moduledoc """
+ Handles Create ActivityPub activities for Notes, Pages, Articles, and Questions.
+ """
+
+ require Logger
+
+ import Ecto.Query
+
+ alias Elektrine.ActivityPub
+ alias Elektrine.ActivityPub.Helpers
+ alias Elektrine.ActivityPub.Normalizer
+ alias Elektrine.Async
+ alias Elektrine.Emojis
+ alias Elektrine.Messaging
+ alias Elektrine.Social
+ alias Elektrine.Social.Poll
+
+ # Bounds the ingestion-time "fetch missing reply parent" recursion so a
+ # hostile inReplyTo chain can't drive unbounded upstream fetches.
+ @max_ingestion_ancestor_depth 10
+
+ @doc """
+ Handles an incoming Create activity.
+ """
+ def handle(%{"object" => object} = activity, actor_ref, _target_user) when is_map(object) do
+ actor_uri = Normalizer.actor_ref_uri(actor_ref)
+ object = inherit_wrapper_fields(activity, object)
+ activity_id = activity["id"] || object["id"]
+ opts = ingestion_opts(activity)
+
+ if is_binary(actor_uri) do
+ case validate_object_author(object, actor_uri) do
+ :ok ->
+ case object["type"] do
+ "Note" -> create_note(object, actor_uri, opts)
+ "Page" -> create_note(object, actor_uri, opts)
+ "Article" -> create_note(object, actor_uri, opts)
+ "Question" -> create_question(object, actor_uri, opts)
+ "Video" -> create_note(object, actor_uri, opts)
+ # Akkoma/Pleroma explicitly sends Answer type for poll votes.
+ "Answer" -> handle_incoming_poll_vote(object, actor_uri, activity_id: activity_id)
+ _ -> {:ok, :unhandled}
+ end
+
+ {:error, :actor_mismatch} ->
+ Logger.warning(
+ "Rejecting Create #{inspect(activity_id)}: object attributedTo does not match verified actor #{actor_uri}"
+ )
+
+ {:ok, :unauthorized}
+ end
+ else
+ Logger.warning("Rejecting Create #{inspect(activity_id)}: missing or invalid actor URI")
+ {:error, :invalid_actor_uri}
+ end
+ end
+
+ def handle(%{"object" => object_uri} = activity, actor_ref, target_user)
+ when is_binary(object_uri) do
+ actor_uri = Normalizer.actor_ref_uri(actor_ref)
+ activity_id = activity["id"] || object_uri
+
+ if is_binary(actor_uri) do
+ case ActivityPub.RemoteFetch.fetch_object(object_uri) do
+ {:ok, object} when is_map(object) ->
+ activity
+ |> Map.put("object", Map.put_new(object, "attributedTo", actor_uri))
+ |> handle(actor_uri, target_user)
+
+ {:ok, _object} ->
+ Logger.warning(
+ "Rejecting Create #{inspect(activity_id)}: referenced object #{inspect(object_uri)} was not an object map"
+ )
+
+ {:error, :fetch_failed}
+
+ {:error, reason} ->
+ Logger.warning(
+ "Rejecting Create #{inspect(activity_id)}: failed to fetch referenced object #{inspect(object_uri)} (#{inspect(reason)})"
+ )
+
+ {:error, :create_object_fetch_failed}
+ end
+ else
+ Logger.warning("Rejecting Create #{inspect(activity_id)}: missing or invalid actor URI")
+ {:error, :invalid_actor_uri}
+ end
+ end
+
+ @doc """
+ Creates a note from an ActivityPub object.
+ Public API for use by other handlers (e.g., AnnounceHandler).
+ """
+ def create_note(object, actor_uri) do
+ create_note(object, actor_uri, [])
+ end
+
+ @doc """
+ Creates a note from an ActivityPub object with extra ingestion options.
+ Supported opts:
+ - `:fallback_community_uri` - fallback Group/community URI when object fields are incomplete
+ """
+ def create_note(object, actor_ref, opts) when is_list(opts) do
+ actor_uri = Normalizer.actor_ref_uri(actor_ref)
+
+ if instance_blocked_for_uri?(actor_uri) do
+ # Fetch paths (replies backfill, Announce/Like of unknown objects) reach
+ # here without the inbox's instance-block gate, so a defederated
+ # instance's content would otherwise be stored and rendered. Reject it
+ # at this shared choke point.
+ {:ok, :blocked}
+ else
+ with :ok <- validate_object_author(object, actor_uri) do
+ if Normalizer.poll_vote?(object) do
+ handle_incoming_poll_vote(object, actor_uri, opts)
+ else
+ create_regular_note(object, actor_uri, opts)
+ end
+ end
+ end
+ end
+
+ defp instance_blocked_for_uri?(actor_uri) when is_binary(actor_uri) do
+ case URI.parse(actor_uri) do
+ %URI{host: host} when is_binary(host) -> ActivityPub.instance_blocked?(host)
+ _ -> false
+ end
+ end
+
+ defp instance_blocked_for_uri?(_), do: false
+
+ @doc """
+ Validates that an object's attributedTo matches the verified actor.
+ """
+ def validate_object_author(object, actor_uri) when is_map(object) do
+ Normalizer.validate_object_author(object, actor_uri)
+ end
+
+ def validate_object_author(_object, _actor_uri), do: {:error, :actor_mismatch}
+
+ @doc """
+ Creates a Question (poll) from an ActivityPub object.
+ """
+ def create_question(object, actor_ref, opts \\ []) do
+ actor_uri = Normalizer.actor_ref_uri(actor_ref)
+
+ if deleted_object_recorded?(object, actor_uri) do
+ {:ok, :ignored_deleted_object}
+ else
+ with {:ok, remote_actor} <- ActivityPub.get_or_fetch_actor(actor_uri) do
+ payload = build_federated_question_payload(object, actor_uri, opts)
+ attrs = Map.put(payload.attrs, :remote_actor_id, remote_actor.id)
+
+ %URI{host: instance_domain} = URI.parse(actor_uri)
+ Async.start(fn -> Emojis.process_activitypub_tags(object["tag"], instance_domain) end)
+
+ if attrs.visibility in ["public", "unlisted"] do
+ case Messaging.create_federated_message_with_status(Map.put(attrs, :federated, true)) do
+ {:ok, message, :created} ->
+ if payload.options != [] do
+ upsert_federated_poll(message.id, object, payload.options)
+ end
+
+ enqueue_home_feed_fanout(message.id)
+
+ if payload.hashtags != [] do
+ Async.run(fn -> sync_message_hashtags(message.id, payload.hashtags) end)
+ end
+
+ broadcast_created_message(message, opts, [
+ :remote_actor,
+ :sender,
+ :conversation,
+ :link_preview,
+ :hashtags,
+ poll: [options: []]
+ ])
+
+ {:ok, message}
+
+ {:ok, _message, :existing} ->
+ {:ok, :already_exists}
+
+ {:error, reason} ->
+ Logger.error("Failed to create federated poll: #{inspect(reason)}")
+ {:error, :failed_to_create_poll}
+ end
+ else
+ {:ok, :ignored_non_public}
+ end
+ end
+ end
+ end
+
+ @doc false
+ def build_federated_message_payload(object, actor_uri, opts \\ []) when is_map(object) do
+ Normalizer.message_payload(object, actor_uri, opts)
+ end
+
+ @doc false
+ def build_federated_question_payload(object, actor_uri, opts \\ []) when is_map(object) do
+ Normalizer.question_payload(object, actor_uri, opts)
+ end
+
+ @doc false
+ def sync_message_hashtags(message_id, hashtag_names)
+ when is_integer(message_id) and is_list(hashtag_names) do
+ normalized_names =
+ hashtag_names
+ |> Enum.filter(&is_binary/1)
+ |> Enum.map(&String.trim/1)
+ |> Enum.reject(&(&1 == ""))
+ |> Enum.uniq_by(&String.downcase/1)
+
+ existing_hashtags =
+ from(
+ ph in "post_hashtags",
+ join: h in "hashtags",
+ on: h.id == ph.hashtag_id,
+ where: ph.message_id == ^message_id,
+ select: %{id: h.id, normalized_name: h.normalized_name}
+ )
+ |> Elektrine.Repo.all()
+
+ existing_names = MapSet.new(existing_hashtags, & &1.normalized_name)
+
+ hashtags =
+ normalized_names
+ |> Enum.map(&Social.get_or_create_hashtag/1)
+ |> Enum.filter(&(&1 != nil))
+ |> Enum.uniq_by(& &1.id)
+
+ new_names = MapSet.new(hashtags, & &1.normalized_name)
+
+ from(ph in "post_hashtags", where: ph.message_id == ^message_id)
+ |> Elektrine.Repo.delete_all()
+
+ removed_hashtags =
+ Enum.reject(existing_hashtags, fn hashtag ->
+ MapSet.member?(new_names, hashtag.normalized_name)
+ end)
+
+ added_hashtags =
+ Enum.reject(hashtags, fn hashtag ->
+ MapSet.member?(existing_names, hashtag.normalized_name)
+ end)
+
+ if hashtags != [] do
+ now = NaiveDateTime.utc_now() |> NaiveDateTime.truncate(:second)
+
+ associations =
+ Enum.map(hashtags, fn hashtag ->
+ %{message_id: message_id, hashtag_id: hashtag.id, inserted_at: now}
+ end)
+
+ Elektrine.Repo.insert_all("post_hashtags", associations, on_conflict: :nothing)
+ end
+
+ Enum.each(removed_hashtags, fn hashtag -> Social.decrement_hashtag_usage(hashtag.id) end)
+ Enum.each(added_hashtags, fn hashtag -> Social.increment_hashtag_usage(hashtag.id) end)
+
+ :ok
+ end
+
+ def sync_message_hashtags(_message_id, _hashtag_names), do: :ok
+
+ @doc false
+ def upsert_federated_poll(message_id, object) when is_integer(message_id) and is_map(object) do
+ upsert_federated_poll(message_id, object, Normalizer.poll_options(object))
+ end
+
+ def upsert_federated_poll(_message_id, _object), do: {:error, :invalid_poll}
+
+ @doc false
+ def upsert_federated_poll(message_id, object, options)
+ when is_integer(message_id) and is_map(object) and is_list(options) do
+ do_upsert_federated_poll(message_id, object, options)
+ end
+
+ def upsert_federated_poll(_message_id, _object, _options), do: {:error, :invalid_poll}
+
+ defp handle_incoming_poll_vote(object, actor_uri, opts) do
+ option_name = object["name"]
+ in_reply_to = object["inReplyTo"]
+
+ poll_post_uri =
+ case in_reply_to do
+ uri when is_binary(uri) -> uri
+ %{"id" => id} -> id
+ _ -> nil
+ end
+
+ if poll_post_uri do
+ with {:ok, _remote_actor} <- ActivityPub.get_or_fetch_actor(actor_uri),
+ {:ok, message} <- get_local_message_from_uri(poll_post_uri),
+ poll when not is_nil(poll) <-
+ Elektrine.Repo.get_by(Elektrine.Social.Poll, message_id: message.id) do
+ poll = Elektrine.Repo.preload(poll, :options)
+ vote_id = Keyword.get(opts, :activity_id) || object["id"]
+
+ record_remote_poll_vote(poll, message, option_name, actor_uri, vote_id)
+ else
+ nil ->
+ {:ok, :not_a_poll}
+
+ {:error, :message_not_found} ->
+ {:ok, :message_not_found}
+
+ {:error, reason} ->
+ Logger.warning("Failed to process poll vote: #{inspect(reason)}")
+ {:error, reason}
+ end
+ else
+ {:ok, :invalid_poll_vote}
+ end
+ end
+
+ defp record_remote_poll_vote(poll, message, option_name, actor_uri, vote_id) do
+ option_key =
+ option_name
+ |> to_string()
+ |> String.trim()
+ |> String.downcase()
+
+ dedupe_vote_id =
+ case vote_id do
+ value when is_binary(value) and value != "" ->
+ "poll-vote:#{value}"
+
+ _ ->
+ "poll-vote:#{actor_uri}:#{poll.id}:#{option_key}"
+ end
+
+ case persist_poll_vote_receipt(dedupe_vote_id, poll, message, actor_uri) do
+ {:ok, :already_recorded} ->
+ {:ok, :already_voted}
+
+ {:ok, :recorded} ->
+ Elektrine.Repo.transaction(fn ->
+ locked_poll =
+ from(p in Poll, where: p.id == ^poll.id, lock: "FOR UPDATE")
+ |> Elektrine.Repo.one!()
+ |> Elektrine.Repo.preload(:options)
+
+ if Poll.has_voted?(locked_poll, actor_uri) do
+ :already_voted
+ else
+ matching_option =
+ Enum.find(locked_poll.options, fn opt ->
+ String.downcase(String.trim(opt.option_text)) == option_key
+ end)
+
+ if matching_option do
+ Elektrine.Repo.update_all(
+ from(o in Elektrine.Social.PollOption, where: o.id == ^matching_option.id),
+ inc: [vote_count: 1]
+ )
+
+ Elektrine.Repo.update_all(
+ from(p in Poll, where: p.id == ^locked_poll.id),
+ inc: [total_votes: 1]
+ )
+
+ case Poll.record_voter(locked_poll, actor_uri) do
+ {:ok, _updated_poll} -> :poll_vote_recorded
+ {:error, reason} -> Elektrine.Repo.rollback(reason)
+ end
+ else
+ :option_not_found
+ end
+ end
+ end)
+ |> case do
+ {:ok, result} -> {:ok, result}
+ {:error, reason} -> {:error, reason}
+ end
+
+ {:error, reason} ->
+ {:error, reason}
+ end
+ end
+
+ defp persist_poll_vote_receipt(vote_id, poll, message, actor_uri) do
+ object_id =
+ case message.activitypub_id do
+ value when is_binary(value) and value != "" -> value
+ _ -> Integer.to_string(poll.message_id)
+ end
+
+ case ActivityPub.create_activity(%{
+ activity_id: vote_id,
+ activity_type: "PollVote",
+ actor_uri: actor_uri,
+ object_id: object_id,
+ data: %{
+ "type" => "PollVote",
+ "id" => vote_id,
+ "actor" => actor_uri
+ },
+ local: false,
+ processed: true,
+ internal_message_id: poll.message_id
+ }) do
+ {:ok, _record} ->
+ {:ok, :recorded}
+
+ {:error, %Ecto.Changeset{errors: [activity_id: {"has already been taken", _}]}} ->
+ {:ok, :already_recorded}
+
+ {:error, reason} ->
+ Logger.warning("Failed to persist poll vote receipt: #{inspect(reason)}")
+ {:error, reason}
+ end
+ end
+
+ defp create_regular_note(object, actor_uri, opts) do
+ object = Normalizer.enrich_sparse_object(object)
+
+ if deleted_object_recorded?(object, actor_uri) do
+ {:ok, :ignored_deleted_object}
+ else
+ with {:ok, remote_actor} <- ActivityPub.get_or_fetch_actor(actor_uri) do
+ payload =
+ build_federated_message_payload(
+ object,
+ actor_uri,
+ Keyword.put(opts, :enrich_sparse_object, false)
+ )
+
+ attrs = Map.merge(payload.attrs, %{federated: true, remote_actor_id: remote_actor.id})
+
+ %URI{host: instance_domain} = URI.parse(actor_uri)
+ Async.start(fn -> Emojis.process_activitypub_tags(object["tag"], instance_domain) end)
+
+ if attrs.visibility in ["public", "unlisted"] do
+ case Messaging.create_federated_message_with_status(attrs) do
+ {:ok, message, :created} ->
+ enqueue_home_feed_fanout(message.id)
+
+ handle_post_create_tasks(
+ message,
+ remote_actor,
+ payload.hashtags,
+ attrs.reply_to_id,
+ payload.mentioned_local_users,
+ opts
+ )
+
+ {:ok, message}
+
+ # Redelivery / Announce of an already-stored reply must NOT re-run
+ # new-reply side effects (reply-count increment, notifications).
+ {:ok, _message, :existing} ->
+ {:ok, :already_exists}
+
+ {:error, reason} ->
+ Logger.error("Failed to create federated message: #{inspect(reason)}")
+ {:error, :failed_to_create_message}
+ end
+ else
+ {:ok, :ignored_non_public}
+ end
+ end
+ end
+ end
+
+ defp handle_post_create_tasks(
+ message,
+ remote_actor,
+ hashtags,
+ reply_to_id,
+ mentioned_local_users,
+ opts
+ ) do
+ if hashtags != [] do
+ Async.run(fn -> link_hashtags_to_message(message.id, hashtags) end)
+ end
+
+ Async.start(fn -> generate_link_preview_for_message(message) end)
+
+ if reply_to_id do
+ Elektrine.ActivityPub.SideEffects.increment_reply_count(reply_to_id)
+
+ Async.run(fn ->
+ Elektrine.Notifications.FederationNotifications.notify_remote_reply(
+ message.id,
+ remote_actor.id
+ )
+ end)
+ else
+ Async.start(fn -> maybe_store_missing_reply_parent(message, remote_actor, opts) end)
+ end
+
+ if mentioned_local_users != [] do
+ Async.run(fn ->
+ notify_mentioned_users(mentioned_local_users, message.id, remote_actor.id)
+ end)
+ end
+
+ broadcast_created_message(message, opts, [
+ :remote_actor,
+ :sender,
+ :conversation,
+ :link_preview,
+ :hashtags
+ ])
+ end
+
+ defp ingestion_opts(activity) when is_map(activity) do
+ ancestor_depth =
+ activity["_elektrine_ancestor_depth"] ||
+ get_in(activity, ["object", "_elektrine_ancestor_depth"])
+
+ []
+ |> maybe_put_opt(:conversation_id, activity["_elektrine_target_community_id"])
+ |> maybe_put_opt(:fallback_community_uri, activity["_elektrine_target_community_uri"])
+ |> maybe_put_opt(:ancestor_depth, ancestor_depth)
+ end
+
+ defp maybe_put_opt(opts, key, value) when is_integer(value), do: Keyword.put(opts, key, value)
+
+ defp maybe_put_opt(opts, key, value) when is_binary(value) and value != "",
+ do: Keyword.put(opts, key, value)
+
+ defp maybe_put_opt(opts, _key, _value), do: opts
+
+ defp broadcast_created_message(message, opts, preloads) do
+ reloaded_message = Elektrine.Repo.preload(message, preloads, force: true)
+
+ case Keyword.get(opts, :conversation_id) do
+ conversation_id when is_integer(conversation_id) ->
+ Phoenix.PubSub.broadcast(
+ Elektrine.PubSub,
+ "conversation:#{conversation_id}",
+ {:new_message, reloaded_message}
+ )
+
+ if is_nil(reloaded_message.reply_to_id) do
+ Phoenix.PubSub.broadcast(
+ Elektrine.PubSub,
+ "discussion:#{conversation_id}",
+ {:new_message, reloaded_message}
+ )
+
+ Phoenix.PubSub.broadcast(
+ Elektrine.PubSub,
+ "discussions:all",
+ {:new_discussion_post, reloaded_message}
+ )
+ end
+
+ _ ->
+ Phoenix.PubSub.broadcast(
+ Elektrine.PubSub,
+ "timeline:public",
+ {:new_public_post, reloaded_message}
+ )
+ end
+ end
+
+ defp enqueue_home_feed_fanout(message_id) when is_integer(message_id) do
+ module = Module.concat([Elektrine.Social, HomeFeedFanoutWorker])
+ _ = module.enqueue(message_id)
+ :ok
+ rescue
+ _ -> :ok
+ end
+
+ defp maybe_store_missing_reply_parent(message, remote_actor, opts) do
+ metadata = Map.get(message, :media_metadata) || %{}
+
+ in_reply_to_ref =
+ metadata["inReplyTo"] ||
+ metadata[:inReplyTo] ||
+ metadata["in_reply_to"] ||
+ metadata[:in_reply_to]
+
+ normalized_ref = Normalizer.normalize_uri(in_reply_to_ref)
+ depth = Keyword.get(opts, :ancestor_depth, 0)
+
+ cond do
+ !is_binary(normalized_ref) or normalized_ref == "" ->
+ :ok
+
+ message.reply_to_id ->
+ :ok
+
+ # Parent already exists locally: link the orphan to it rather than
+ # leaving reply_to_id nil forever. This is the common case when the
+ # reply raced its parent (or the parent was masked by the negative
+ # ref cache at payload-build time). Linking to an existing parent is
+ # cheap and involves no fetch, so it is not depth-bounded.
+ parent = Messaging.get_message_by_activitypub_ref(normalized_ref) ->
+ maybe_link_orphan_reply_to_parent(message, parent)
+ :ok
+
+ # Bound how far ingestion walks up an unknown reply chain: a hostile
+ # server can otherwise serve an endless inReplyTo chain of fresh objects
+ # and drive unbounded signed fetches + inserts, one per hop.
+ depth >= @max_ingestion_ancestor_depth ->
+ :ok
+
+ true ->
+ case Helpers.get_or_store_remote_post(normalized_ref, remote_actor && remote_actor.uri,
+ ancestor_depth: depth + 1
+ ) do
+ {:ok, parent_message} when is_map(parent_message) ->
+ maybe_link_orphan_reply_to_parent(message, parent_message)
+
+ _ ->
+ :ok
+ end
+
+ :ok
+ end
+ end
+
+ defp maybe_link_orphan_reply_to_parent(message, parent_message)
+ when is_map(message) and is_map(parent_message) do
+ cond do
+ # Only link a still-unthreaded reply.
+ not is_nil(message.reply_to_id) ->
+ :ok
+
+ # Reject self-references and direct 2-cycles (A.inReplyTo=B, B.inReplyTo=A)
+ # so the thread tree can't be corrupted into a loop.
+ parent_message.id == message.id or parent_message.reply_to_id == message.id ->
+ :ok
+
+ true ->
+ case message
+ |> Ecto.Changeset.change(reply_to_id: parent_message.id)
+ |> Elektrine.Repo.update() do
+ {:ok, _updated} ->
+ # A late link is still a new reply for this parent — count it once.
+ Elektrine.ActivityPub.SideEffects.increment_reply_count(parent_message.id)
+ :ok
+
+ error ->
+ error
+ end
+ end
+ end
+
+ defp maybe_link_orphan_reply_to_parent(_, _), do: :ok
+
+ defp do_upsert_federated_poll(message_id, object, options)
+ when is_integer(message_id) and is_map(object) and is_list(options) do
+ poll_attrs = build_poll_record_attrs(message_id, object, options)
+
+ case Elektrine.Repo.get_by(Elektrine.Social.Poll, message_id: message_id) do
+ nil ->
+ poll_struct = :erlang.apply(Elektrine.Social.Poll, :__struct__, [])
+
+ case Elektrine.Repo.insert(Elektrine.Social.Poll.changeset(poll_struct, poll_attrs)) do
+ {:ok, poll} ->
+ replace_poll_options(poll, options)
+ {:ok, poll}
+
+ {:error, reason} ->
+ Logger.error("Failed to create federated poll: #{inspect(reason)}")
+ {:error, reason}
+ end
+
+ poll ->
+ case poll
+ |> Elektrine.Social.Poll.changeset(Map.delete(poll_attrs, :message_id))
+ |> Elektrine.Repo.update() do
+ {:ok, updated_poll} ->
+ replace_poll_options(updated_poll, options)
+ {:ok, updated_poll}
+
+ {:error, reason} ->
+ Logger.error("Failed to update federated poll: #{inspect(reason)}")
+ {:error, reason}
+ end
+ end
+ end
+
+ defp do_upsert_federated_poll(_message_id, _object, _options), do: {:error, :invalid_poll}
+
+ defp replace_poll_options(poll, options) do
+ from(o in Elektrine.Social.PollOption, where: o.poll_id == ^poll.id)
+ |> Elektrine.Repo.delete_all()
+
+ Enum.each(options, fn option ->
+ option_struct = :erlang.apply(Elektrine.Social.PollOption, :__struct__, [])
+
+ option_attrs = %{
+ poll_id: poll.id,
+ option_text: option.text,
+ vote_count: option.votes,
+ position: option[:position] || 0
+ }
+
+ option_struct
+ |> Elektrine.Social.PollOption.changeset(option_attrs)
+ |> Elektrine.Repo.insert()
+ end)
+ end
+
+ defp build_poll_record_attrs(message_id, object, options) do
+ end_time =
+ case object["endTime"] || object["closed"] do
+ nil -> nil
+ time_str -> Helpers.parse_published_date(time_str)
+ end
+
+ voters_count = extract_voters_count(object, options)
+
+ %{
+ message_id: message_id,
+ question: Normalizer.poll_question_text(object) |> String.slice(0, 300),
+ total_votes: voters_count,
+ voters_count: voters_count,
+ closes_at: end_time,
+ allow_multiple: !is_nil(object["anyOf"]),
+ hide_totals: false,
+ last_fetched_at: DateTime.utc_now() |> DateTime.truncate(:second)
+ }
+ end
+
+ defp extract_voters_count(object, options) do
+ case object["votersCount"] do
+ count when is_integer(count) and count > 0 ->
+ count
+
+ count when is_binary(count) ->
+ case Integer.parse(count) do
+ {n, _} when n > 0 -> n
+ _ -> sum_option_votes(options)
+ end
+
+ _ ->
+ sum_option_votes(options)
+ end
+ end
+
+ defp sum_option_votes(options) do
+ Enum.reduce(options, 0, fn opt, acc -> acc + (opt[:votes] || 0) end)
+ end
+
+ defp inherit_wrapper_fields(activity, object) when is_map(activity) and is_map(object) do
+ ["to", "cc", "audience", "published"]
+ |> Enum.reduce(object, fn field, acc ->
+ inherit_wrapper_field(acc, activity, field)
+ end)
+ end
+
+ defp inherit_wrapper_fields(_activity, object), do: object
+
+ defp inherit_wrapper_field(object, activity, field) do
+ case {Map.get(object, field), Map.get(activity, field)} do
+ {value, inherited} when value in [nil, ""] and not is_nil(inherited) ->
+ Map.put(object, field, inherited)
+
+ {[], inherited} when not is_nil(inherited) ->
+ Map.put(object, field, inherited)
+
+ _ ->
+ object
+ end
+ end
+
+ defp deleted_object_recorded?(object, actor_uri) when is_map(object) and is_binary(actor_uri) do
+ refs =
+ [object["id"], object["url"]]
+ |> Enum.reject(&is_nil/1)
+
+ ActivityPub.remote_delete_recorded?(actor_uri, refs)
+ end
+
+ defp deleted_object_recorded?(_object, _actor_uri), do: false
+
+ defp notify_mentioned_users(usernames, message_id, remote_actor_id) do
+ remote_actor = Elektrine.Repo.get(Elektrine.ActivityPub.Actor, remote_actor_id)
+
+ Enum.each(usernames, fn username ->
+ case Elektrine.Accounts.get_user_by_username(username) do
+ nil ->
+ :ok
+
+ user ->
+ actor_name =
+ if remote_actor do
+ "@#{remote_actor.username}@#{remote_actor.domain}"
+ else
+ "a remote user"
+ end
+
+ Elektrine.Notifications.create_notification(%{
+ user_id: user.id,
+ type: "mention",
+ title: "Mentioned in a post",
+ body: "#{actor_name} mentioned you in a post",
+ url: Elektrine.Notifications.resolve_message_notification_url(message_id, "mention"),
+ source_type: "message",
+ source_id: message_id,
+ priority: "normal"
+ })
+ end
+ end)
+ end
+
+ defp generate_link_preview_for_message(message) do
+ external_link = get_in(message.media_metadata || %{}, ["external_link"])
+
+ content_urls =
+ if Elektrine.Strings.present?(message.content) do
+ Elektrine.Social.LinkPreviewFetcher.extract_urls(message.content)
+ else
+ []
+ end
+
+ url = external_link || List.first(content_urls)
+
+ case url do
+ nil ->
+ :ok
+
+ url ->
+ try do
+ metadata = Elektrine.Social.LinkPreviewFetcher.fetch_preview_metadata(url)
+
+ case metadata do
+ %{status: "success"} ->
+ preview_struct = :erlang.apply(Social.LinkPreview, :__struct__, [])
+
+ preview_changeset =
+ Social.LinkPreview.changeset(preview_struct, %{
+ url: url,
+ title: metadata.title,
+ description: metadata.description,
+ image_url: metadata.image_url,
+ favicon_url: metadata.favicon_url,
+ site_name: metadata.site_name,
+ status: "success",
+ fetched_at: DateTime.utc_now() |> DateTime.truncate(:second)
+ })
+
+ _ =
+ Elektrine.Repo.insert(
+ preview_changeset,
+ on_conflict: :nothing,
+ conflict_target: :url
+ )
+
+ case Elektrine.Repo.get_by(Social.LinkPreview, url: url) do
+ %{id: id} when is_integer(id) ->
+ Messaging.update_message(message, %{link_preview_id: id})
+
+ _ ->
+ :ok
+ end
+
+ _ ->
+ :ok
+ end
+ rescue
+ e ->
+ Logger.warning("Failed to generate link preview: #{inspect(e)}")
+ :ok
+ end
+ end
+ end
+
+ defp link_hashtags_to_message(message_id, hashtag_names) do
+ hashtags =
+ Enum.map(hashtag_names, fn name -> Social.get_or_create_hashtag(name) end)
+ |> Enum.filter(&(&1 != nil))
+
+ if hashtags != [] do
+ now = NaiveDateTime.utc_now() |> NaiveDateTime.truncate(:second)
+
+ associations =
+ Enum.map(hashtags, fn hashtag ->
+ %{message_id: message_id, hashtag_id: hashtag.id, inserted_at: now}
+ end)
+
+ Elektrine.Repo.insert_all(Social.PostHashtag, associations, on_conflict: :nothing)
+
+ Enum.each(hashtags, fn hashtag -> Social.increment_hashtag_usage(hashtag.id) end)
+ end
+ end
+
+ defp get_local_message_from_uri(uri) do
+ base_url = ActivityPub.instance_url()
+
+ cond do
+ String.starts_with?(uri, "#{base_url}/posts/") ->
+ id = String.replace_prefix(uri, "#{base_url}/posts/", "")
+ get_message_by_id(id)
+
+ String.match?(uri, ~r{#{base_url}/users/[^/]+/posts/}) ->
+ id = uri |> String.split("/posts/") |> List.last()
+ get_message_by_id(id)
+
+ true ->
+ case Messaging.get_message_by_activitypub_ref(uri) do
+ nil -> {:error, :message_not_found}
+ message -> {:ok, message}
+ end
+ end
+ end
+
+ defp get_message_by_id(id) do
+ case Messaging.get_message(parse_message_id(id)) do
+ nil -> {:error, :message_not_found}
+ message -> {:ok, message}
+ end
+ rescue
+ Ecto.Query.CastError -> {:error, :message_not_found}
+ end
+
+ defp parse_message_id(id) when is_integer(id), do: id
+
+ defp parse_message_id(id) when is_binary(id) do
+ case Integer.parse(id) do
+ {parsed, ""} -> parsed
+ _ -> id
+ end
+ end
+
+ defp parse_message_id(id), do: id
+end
diff --git a/apps/elektrine/lib/elektrine/activitypub/handlers/delete_handler.ex b/apps/elektrine/lib/elektrine/activitypub/handlers/delete_handler.ex
new file mode 100644
index 0000000..37d16fe
--- /dev/null
+++ b/apps/elektrine/lib/elektrine/activitypub/handlers/delete_handler.ex
@@ -0,0 +1,108 @@
+defmodule Elektrine.ActivityPub.Handlers.DeleteHandler do
+ @moduledoc """
+ Handles Delete ActivityPub activities.
+ """
+
+ require Logger
+
+ alias Elektrine.ActivityPub
+ alias Elektrine.Messaging
+
+ @doc """
+ Handles an incoming Delete activity.
+ """
+ def handle(%{"object" => object} = activity, actor_uri, _target_user) do
+ object_id =
+ case object do
+ obj when is_binary(obj) -> obj
+ %{"id" => id} -> id
+ _ -> nil
+ end
+
+ if object_id do
+ case Messaging.get_message_by_activitypub_ref(object_id) do
+ nil ->
+ case ActivityPub.record_remote_delete_receipt(activity, actor_uri, object_id) do
+ {:ok, _receipt} ->
+ {:ok, :delete_receipt_recorded}
+
+ {:error, reason} ->
+ Logger.warning(
+ "Failed to record Delete receipt for #{inspect(object_id)} from #{actor_uri}: #{inspect(reason)}"
+ )
+
+ {:error, :delete_receipt_failed}
+ end
+
+ message ->
+ if message.federated && message.remote_actor_id do
+ # Capture pre-update state so a redelivered Delete (Mastodon retries)
+ # doesn't decrement the parent's reply_count more than once.
+ already_deleted? = not is_nil(message.deleted_at)
+
+ with {:ok, remote_actor} <- ActivityPub.get_or_fetch_actor(actor_uri),
+ true <- message.remote_actor_id == remote_actor.id do
+ update_result =
+ message
+ |> Ecto.Changeset.change(%{
+ deleted_at: DateTime.utc_now() |> DateTime.truncate(:second)
+ })
+ |> Elektrine.Repo.update()
+
+ case update_result do
+ {:ok, deleted_message} ->
+ # Keep the parent's reply_count from drifting upward: a
+ # deleted reply must decrement its parent exactly once.
+ if deleted_message.reply_to_id && not already_deleted? do
+ Elektrine.ActivityPub.SideEffects.decrement_reply_count(
+ deleted_message.reply_to_id
+ )
+ end
+
+ broadcast_deleted_post(deleted_message)
+
+ _ ->
+ :ok
+ end
+
+ case ActivityPub.record_remote_delete_receipt(activity, actor_uri, object_id) do
+ {:ok, _receipt} ->
+ {:ok, :deleted}
+
+ {:error, reason} ->
+ Logger.warning(
+ "Deleted message #{message.id} but failed to persist Delete receipt: #{inspect(reason)}"
+ )
+
+ {:ok, :deleted}
+ end
+ else
+ {:error, reason} ->
+ Logger.warning(
+ "Delete actor resolution failed for #{actor_uri}: #{inspect(reason)}"
+ )
+
+ {:error, :delete_actor_fetch_failed}
+
+ false ->
+ Logger.warning("Delete attempt from non-owner: #{actor_uri}")
+ {:ok, :unauthorized}
+ end
+ else
+ {:ok, :not_federated_message}
+ end
+ end
+ else
+ Logger.warning("Delete activity has no valid object ID")
+ {:ok, :invalid_delete}
+ end
+ end
+
+ defp broadcast_deleted_post(%{id: message_id}) when is_integer(message_id) do
+ Phoenix.PubSub.broadcast(Elektrine.PubSub, "timeline:public", {:post_deleted, message_id})
+ Phoenix.PubSub.broadcast(Elektrine.PubSub, "post:#{message_id}", {:post_deleted, message_id})
+ :ok
+ end
+
+ defp broadcast_deleted_post(_), do: :ok
+end
diff --git a/apps/elektrine/lib/elektrine/activitypub/handlers/flag_handler.ex b/apps/elektrine/lib/elektrine/activitypub/handlers/flag_handler.ex
new file mode 100644
index 0000000..dd15aba
--- /dev/null
+++ b/apps/elektrine/lib/elektrine/activitypub/handlers/flag_handler.ex
@@ -0,0 +1,329 @@
+defmodule Elektrine.ActivityPub.Handlers.FlagHandler do
+ @moduledoc """
+ Handles Flag (report) ActivityPub activities.
+
+ Flag activities are used to report users or content to instance administrators.
+ When we receive a Flag, we create a local report for admin review.
+
+ ## ActivityPub Flag format
+
+ {
+ "@context": "https://www.w3.org/ns/activitystreams",
+ "type": "Flag",
+ "actor": "https://remote.server/users/reporter",
+ "object": [
+ "https://our.server/users/reported_user",
+ "https://our.server/posts/123"
+ ],
+ "content": "This user is posting spam"
+ }
+ """
+
+ require Logger
+
+ alias Elektrine.Accounts
+ alias Elektrine.ActivityPub
+ alias Elektrine.Reports
+
+ @doc """
+ Handles an incoming Flag (report) activity.
+ Creates a local report for admin review.
+ """
+ def handle(%{"object" => objects, "content" => content} = _activity, actor_uri, _target_user) do
+ handle_report(objects, content, actor_uri)
+ end
+
+ def handle(%{"object" => objects} = _activity, actor_uri, _target_user) do
+ handle_report(objects, nil, actor_uri)
+ end
+
+ defp handle_report(objects, content, reporter_actor_uri) do
+ objects = List.wrap(objects)
+
+ # Separate user URIs from content URIs
+ {user_uris, content_uris} = categorize_objects(objects)
+
+ # Get or create the remote reporter actor
+ reporter_actor =
+ case ActivityPub.get_or_fetch_actor(reporter_actor_uri) do
+ {:ok, actor} -> actor
+ _ -> nil
+ end
+
+ # Find local users being reported
+ reported_users = find_local_users(user_uris)
+
+ # Find local messages being reported
+ reported_messages = find_local_messages(content_uris)
+
+ cond do
+ # Report against a local user
+ reported_users != [] ->
+ Enum.each(reported_users, fn user ->
+ create_federated_report(
+ user,
+ reported_messages,
+ content,
+ reporter_actor,
+ reporter_actor_uri
+ )
+ end)
+
+ {:ok, :report_received}
+
+ # Report against local content only (no user specified)
+ reported_messages != [] ->
+ # Get the author of the first reported message
+ first_message = hd(reported_messages)
+
+ if first_message.sender_id do
+ user = Accounts.get_user!(first_message.sender_id)
+
+ if user do
+ create_federated_report(
+ user,
+ reported_messages,
+ content,
+ reporter_actor,
+ reporter_actor_uri
+ )
+ end
+ end
+
+ {:ok, :report_received}
+
+ true ->
+ Logger.debug("Flag activity targets no local users or content, ignoring")
+ {:ok, :ignored}
+ end
+ end
+
+ defp categorize_objects(objects) do
+ Enum.reduce(objects, {[], []}, fn object_uri, {users, content} ->
+ uri = normalize_uri(object_uri)
+
+ if user_uri?(uri), do: {[uri | users], content}, else: {users, [uri | content]}
+ end)
+ end
+
+ defp normalize_uri(uri) when is_binary(uri), do: uri
+ defp normalize_uri(%{"id" => id}), do: id
+ defp normalize_uri(_), do: nil
+
+ defp user_uri?(nil), do: false
+
+ defp user_uri?(uri) do
+ match?({:ok, _username}, ActivityPub.local_username_from_uri(uri))
+ end
+
+ defp find_local_users(uris) do
+ uris
+ |> Enum.filter(& &1)
+ |> Enum.map(fn uri ->
+ case ActivityPub.local_username_from_uri(uri) do
+ {:ok, username} -> Accounts.get_user_by_username(username)
+ _ -> nil
+ end
+ end)
+ |> Enum.filter(& &1)
+ end
+
+ defp find_local_messages(uris) do
+ uris
+ |> Enum.filter(& &1)
+ |> Enum.flat_map(fn uri ->
+ local_path = local_path_if_local_domain(uri)
+
+ cond do
+ # Format: /posts/{id}
+ is_binary(local_path) and String.starts_with?(local_path, "/posts/") ->
+ id_str = String.replace_prefix(local_path, "/posts/", "")
+ get_message_by_id_string(id_str)
+
+ # Format: /users/{username}/statuses/{id}
+ is_binary(local_path) and String.match?(local_path, ~r{^/users/[^/]+/statuses/}) ->
+ id_str = local_path |> String.split("/statuses/") |> List.last()
+ get_message_by_id_string(id_str)
+
+ # Check by canonical ID or URL variant.
+ true ->
+ case Elektrine.Messaging.get_message_by_activitypub_ref(uri) do
+ nil -> []
+ msg -> [msg]
+ end
+ end
+ end)
+ end
+
+ defp local_path_if_local_domain(uri) when is_binary(uri) do
+ case URI.parse(uri) do
+ %URI{host: host, path: path} when is_binary(host) and is_binary(path) ->
+ if Elektrine.Domains.local_activitypub_domain?(String.downcase(host)) do
+ path
+ else
+ nil
+ end
+
+ _ ->
+ nil
+ end
+ end
+
+ defp local_path_if_local_domain(_), do: nil
+
+ # Safely parse ID string and fetch message
+ defp get_message_by_id_string(id_str) do
+ case Integer.parse(id_str) do
+ {id, ""} ->
+ case Elektrine.Messaging.get_message(id) do
+ nil -> []
+ msg -> [msg]
+ end
+
+ _ ->
+ # Invalid ID format, skip
+ []
+ end
+ end
+
+ defp create_federated_report(
+ reported_user,
+ reported_messages,
+ content,
+ reporter_actor,
+ reporter_uri
+ ) do
+ # Build metadata about the reporter
+ reporter_info =
+ if reporter_actor do
+ %{
+ "remote_reporter" => %{
+ "uri" => reporter_actor.uri,
+ "username" => reporter_actor.username,
+ "domain" => reporter_actor.domain,
+ "display_name" => reporter_actor.display_name
+ }
+ }
+ else
+ %{
+ "remote_reporter" => %{
+ "uri" => reporter_uri
+ }
+ }
+ end
+
+ # Add reported content URIs to metadata
+ content_info =
+ if reported_messages != [] do
+ Map.put(reporter_info, "reported_content_ids", Enum.map(reported_messages, & &1.id))
+ else
+ reporter_info
+ end
+
+ # Create the report
+ # Use a system reporter ID for federated reports (or first admin)
+ system_reporter_id = get_system_reporter_id()
+
+ report_attrs = %{
+ reporter_id: system_reporter_id,
+ reportable_type: "user",
+ reportable_id: reported_user.id,
+ reason: "other",
+ description: build_report_description(content, reporter_actor, reporter_uri),
+ status: "pending",
+ priority: "normal",
+ metadata: content_info
+ }
+
+ case Reports.create_report(report_attrs) do
+ {:ok, report} ->
+ Logger.info(
+ "Created federated report ##{report.id} against user #{reported_user.username} " <>
+ "from #{reporter_uri}"
+ )
+
+ # Notify admins about the new report
+ notify_admins_of_federated_report(report, reported_user, reporter_actor)
+
+ {:ok, report}
+
+ {:error, reason} ->
+ Logger.error("Failed to create federated report: #{inspect(reason)}")
+ {:error, reason}
+ end
+ end
+
+ defp build_report_description(content, reporter_actor, reporter_uri) do
+ reporter_name =
+ if reporter_actor do
+ "@#{reporter_actor.username}@#{reporter_actor.domain}"
+ else
+ reporter_uri
+ end
+
+ base = "[Federated Report from #{reporter_name}]"
+
+ if content && content != "" do
+ "#{base}\n\n#{content}"
+ else
+ "#{base}\n\nNo additional details provided."
+ end
+ end
+
+ defp get_system_reporter_id do
+ # Get the first admin user to use as the reporter for federated reports
+ import Ecto.Query
+
+ case Elektrine.Repo.one(
+ from(u in Elektrine.Accounts.User,
+ where: u.is_admin == true,
+ limit: 1,
+ select: u.id
+ )
+ ) do
+ nil ->
+ # Fallback: get any user (shouldn't happen in production)
+ Elektrine.Repo.one(
+ from(u in Elektrine.Accounts.User,
+ limit: 1,
+ select: u.id
+ )
+ )
+
+ admin_id ->
+ admin_id
+ end
+ end
+
+ defp notify_admins_of_federated_report(report, reported_user, reporter_actor) do
+ # Create notifications for all admins
+ import Ecto.Query
+
+ admin_ids =
+ Elektrine.Repo.all(
+ from(u in Elektrine.Accounts.User,
+ where: u.is_admin == true,
+ select: u.id
+ )
+ )
+
+ reporter_name =
+ if reporter_actor do
+ "@#{reporter_actor.username}@#{reporter_actor.domain}"
+ else
+ "a remote user"
+ end
+
+ Enum.each(admin_ids, fn admin_id ->
+ Elektrine.Notifications.create_notification(%{
+ user_id: admin_id,
+ type: "system",
+ title: "New Federated Report",
+ body: "#{reporter_name} reported @#{reported_user.username}",
+ source_type: "report",
+ source_id: report.id,
+ priority: "high"
+ })
+ end)
+ end
+end
diff --git a/apps/elektrine/lib/elektrine/activitypub/handlers/follow_handler.ex b/apps/elektrine/lib/elektrine/activitypub/handlers/follow_handler.ex
new file mode 100644
index 0000000..8ffd5dc
--- /dev/null
+++ b/apps/elektrine/lib/elektrine/activitypub/handlers/follow_handler.ex
@@ -0,0 +1,419 @@
+defmodule Elektrine.ActivityPub.Handlers.FollowHandler do
+ @moduledoc "Handles Follow, Accept, and Reject ActivityPub activities.\n"
+ require Logger
+ alias Elektrine.ActivityPub
+ alias Elektrine.ActivityPub.{Builder, Publisher, Relay}
+ alias Elektrine.Async
+ alias Elektrine.Profiles
+ alias Elektrine.RuntimeEnv
+ @doc "Handles an incoming Follow activity.\n"
+ def handle(
+ %{"id" => follow_id, "actor" => actor_uri, "object" => object_ref},
+ actor_uri,
+ _target_user
+ ) do
+ case resolve_follow_target_uri(object_ref) do
+ object_uri when is_binary(object_uri) and object_uri != "" ->
+ remote_actor_result =
+ if RuntimeEnv.environment() == :dev do
+ case ActivityPub.get_or_fetch_actor(actor_uri) do
+ {:ok, actor} ->
+ {:ok, actor}
+
+ {:error, _} ->
+ uri = URI.parse(actor_uri)
+
+ Elektrine.Repo.insert(
+ %ActivityPub.Actor{
+ uri: actor_uri,
+ username: "testuser",
+ domain: uri.host,
+ inbox_url: "#{actor_uri}/inbox",
+ public_key:
+ "-----BEGIN RSA PUBLIC KEY-----\nMOCK\n-----END RSA PUBLIC KEY-----\n",
+ last_fetched_at: DateTime.utc_now() |> DateTime.truncate(:second),
+ metadata: %{"mock" => true}
+ },
+ on_conflict: :nothing
+ )
+ end
+ else
+ ActivityPub.get_or_fetch_actor(actor_uri)
+ end
+
+ case remote_actor_result do
+ {:ok, remote_actor} ->
+ relay_url = Relay.relay_actor_id()
+
+ cond do
+ object_uri == relay_url ->
+ Logger.info("Relay #{actor_uri} wants to follow our relay")
+ {:ok, relay_actor} = Relay.get_or_create_relay_actor()
+ send_relay_accept(relay_actor, remote_actor, follow_id)
+ {:ok, :relay_follow_accepted}
+
+ group_actor = ActivityPub.get_local_group_actor_by_uri(object_uri) ->
+ handle_group_follow(remote_actor, group_actor, follow_id, actor_uri)
+
+ true ->
+ handle_user_follow(remote_actor, object_uri, follow_id, actor_uri)
+ end
+
+ error ->
+ Logger.error("Failed to handle follow: #{inspect(error)}")
+ {:error, :handle_follow_failed}
+ end
+
+ _ ->
+ Logger.warning("Invalid Follow target from #{actor_uri}: #{inspect(object_ref)}")
+ {:error, :handle_follow_failed}
+ end
+ end
+
+ @doc "Handles an incoming Accept activity for a Follow.\n"
+ def handle_accept(
+ %{"object" => %{"type" => "Follow", "id" => follow_id}} = _activity,
+ actor_uri,
+ _target_user
+ ) do
+ Logger.debug(
+ "FollowHandler: Received Accept with Follow object, id=#{follow_id}, actor=#{actor_uri}"
+ )
+
+ case Relay.handle_accept(follow_id, actor_uri) do
+ {:ok, _subscription} ->
+ {:ok, :relay_subscription_accepted}
+
+ {:error, :subscription_not_found} ->
+ case Relay.handle_accept_from_actor(actor_uri) do
+ {:ok, _subscription} -> {:ok, :relay_subscription_accepted}
+ {:error, :subscription_not_found} -> handle_user_accept(follow_id, actor_uri)
+ end
+ end
+ end
+
+ def handle_accept(
+ %{"object" => follow_id} = _activity,
+ actor_uri,
+ _target_user
+ )
+ when is_binary(follow_id) do
+ Logger.debug(
+ "FollowHandler: Received Accept with string object=#{follow_id}, actor=#{actor_uri}"
+ )
+
+ case Relay.handle_accept(follow_id, actor_uri) do
+ {:ok, _subscription} ->
+ {:ok, :relay_subscription_accepted}
+
+ {:error, :subscription_not_found} ->
+ case Relay.handle_accept_from_actor(actor_uri) do
+ {:ok, _subscription} -> {:ok, :relay_subscription_accepted}
+ {:error, :subscription_not_found} -> handle_user_accept(follow_id, actor_uri)
+ end
+ end
+ end
+
+ def handle_accept(_activity, _actor_uri, _target_user) do
+ {:ok, :unhandled}
+ end
+
+ defp handle_user_accept(follow_id, actor_uri) do
+ case ActivityPub.get_activity_by_id(follow_id) do
+ nil ->
+ Logger.warning("Received Accept for unknown Follow: #{follow_id}")
+ {:ok, :unknown_follow}
+
+ activity ->
+ cond do
+ !activity.internal_user_id ->
+ {:ok, :not_our_follow}
+
+ !follow_target_matches_actor?(activity, actor_uri) ->
+ Logger.warning(
+ "Rejecting Accept for Follow #{follow_id}: actor #{actor_uri} does not match original follow target #{activity.object_id}"
+ )
+
+ {:ok, :unauthorized}
+
+ true ->
+ Profiles.accept_follow_by_activity_id(follow_id)
+
+ Async.run(fn ->
+ Elektrine.Notifications.FederationNotifications.notify_follow_accepted(
+ activity.internal_user_id,
+ activity.object_id
+ )
+ end)
+
+ {:ok, :follow_accepted}
+ end
+ end
+ end
+
+ @doc "Handles an incoming Reject activity for a Follow.\n"
+ def handle_reject(
+ %{"object" => %{"type" => "Follow", "id" => follow_id}},
+ actor_uri,
+ _target_user
+ ) do
+ handle_reject_by_id(follow_id, actor_uri)
+ end
+
+ def handle_reject(
+ %{"object" => follow_id},
+ actor_uri,
+ _target_user
+ )
+ when is_binary(follow_id) do
+ handle_reject_by_id(follow_id, actor_uri)
+ end
+
+ def handle_reject(_activity, _actor_uri, _target_user) do
+ {:ok, :unhandled}
+ end
+
+ defp handle_reject_by_id(follow_id, actor_uri) do
+ case Relay.handle_reject(follow_id, actor_uri) do
+ {:ok, _subscription} ->
+ {:ok, :relay_subscription_rejected}
+
+ {:error, :subscription_not_found} ->
+ case ActivityPub.get_activity_by_id(follow_id) do
+ nil ->
+ {:ok, :unknown_follow}
+
+ activity ->
+ cond do
+ !activity.internal_user_id ->
+ {:ok, :not_our_follow}
+
+ !follow_target_matches_actor?(activity, actor_uri) ->
+ Logger.warning(
+ "Rejecting Reject for Follow #{follow_id}: actor #{actor_uri} does not match original follow target #{activity.object_id}"
+ )
+
+ {:ok, :unauthorized}
+
+ true ->
+ Profiles.delete_follow_by_activity_id(follow_id)
+ {:ok, :follow_rejected}
+ end
+ end
+ end
+ end
+
+ @doc "Handles Undo Follow activity.\n"
+ def handle_undo(%{"object" => followed_uri}, actor_uri) when is_binary(followed_uri) do
+ case ActivityPub.get_or_fetch_actor(actor_uri) do
+ {:ok, remote_actor} ->
+ case ActivityPub.get_local_group_actor_by_uri(followed_uri) do
+ nil ->
+ case get_local_user_from_uri(followed_uri) do
+ {:ok, followed_user} ->
+ Profiles.delete_remote_follow(remote_actor.id, followed_user.id)
+ {:ok, :unfollowed}
+
+ {:error, _} ->
+ {:ok, :target_not_found}
+ end
+
+ group_actor ->
+ ActivityPub.delete_group_follow(remote_actor.id, group_actor.id)
+ {:ok, :unfollowed}
+ end
+
+ {:error, reason} ->
+ Logger.warning("Failed to process Undo Follow from #{actor_uri}: #{inspect(reason)}")
+ {:error, :undo_follow_failed}
+ end
+ end
+
+ def handle_undo(%{"object" => followed_uri} = _follow_object, actor_uri)
+ when is_map(followed_uri) do
+ case resolve_follow_target_uri(followed_uri) do
+ uri when is_binary(uri) and uri != "" ->
+ handle_undo(%{"object" => uri}, actor_uri)
+
+ _ ->
+ Logger.warning("Invalid Undo Follow from #{actor_uri}")
+ {:ok, :invalid}
+ end
+ end
+
+ def handle_undo(_object, actor_uri) do
+ Logger.warning("Invalid Undo Follow from #{actor_uri}")
+ {:ok, :invalid}
+ end
+
+ defp follow_target_matches_actor?(%{object_id: object_id}, actor_uri)
+ when is_binary(object_id) and is_binary(actor_uri) do
+ normalize_actor_uri(object_id) == normalize_actor_uri(actor_uri)
+ end
+
+ defp follow_target_matches_actor?(_, _), do: false
+
+ defp normalize_actor_uri(uri) when is_binary(uri) do
+ uri
+ |> String.trim()
+ |> String.split("#", parts: 2)
+ |> hd()
+ |> String.split("?", parts: 2)
+ |> hd()
+ |> String.trim_trailing("/")
+ end
+
+ defp resolve_follow_target_uri(uri) when is_binary(uri), do: uri
+
+ defp resolve_follow_target_uri(%{"object" => object_ref}) do
+ resolve_follow_target_uri(object_ref)
+ end
+
+ defp resolve_follow_target_uri(%{"id" => id}) when is_binary(id), do: id
+ defp resolve_follow_target_uri(_), do: nil
+
+ defp handle_user_follow(remote_actor, object_uri, follow_id, _actor_uri) do
+ case get_local_user_from_uri(object_uri) do
+ {:ok, followed_user} ->
+ existing_follow = Profiles.get_follow_by_remote_actor(remote_actor.id, followed_user.id)
+
+ if existing_follow do
+ if existing_follow.pending do
+ {:ok, :pending}
+ else
+ send_accept(followed_user, remote_actor, follow_id, object_uri)
+ {:ok, :already_following}
+ end
+ else
+ pending =
+ followed_user.activitypub_manually_approve_followers ||
+ followed_user.profile_visibility in ["followers", "private"]
+
+ case Profiles.create_remote_follow(
+ remote_actor.id,
+ followed_user.id,
+ pending,
+ follow_id
+ ) do
+ {:ok, _follow} ->
+ if pending do
+ Async.run(fn ->
+ Elektrine.Notifications.FederationNotifications.notify_remote_follow(
+ followed_user.id,
+ remote_actor.id
+ )
+ end)
+
+ {:ok, :pending}
+ else
+ send_accept(followed_user, remote_actor, follow_id, object_uri)
+
+ Async.run(fn ->
+ Elektrine.Notifications.FederationNotifications.notify_remote_follow(
+ followed_user.id,
+ remote_actor.id
+ )
+ end)
+
+ {:ok, :accepted}
+ end
+
+ {:error, reason} ->
+ Logger.error("Failed to create follow: #{inspect(reason)}")
+ {:error, :failed_to_create_follow}
+ end
+ end
+
+ {:error, _} ->
+ Logger.error("Follow target not found: #{object_uri}")
+ {:error, :target_not_found}
+ end
+ end
+
+ defp handle_group_follow(remote_actor, group_actor, follow_id, _actor_uri) do
+ existing_follow = ActivityPub.get_group_follow(remote_actor.id, group_actor.id)
+
+ if existing_follow do
+ send_group_accept(group_actor, remote_actor, follow_id)
+ {:ok, :already_following}
+ else
+ case ActivityPub.create_group_follow(remote_actor.id, group_actor.id, follow_id, false) do
+ {:ok, _follow} ->
+ send_group_accept(group_actor, remote_actor, follow_id)
+ {:ok, :accepted}
+
+ {:error, reason} ->
+ Logger.error("Failed to create group follow: #{inspect(reason)}")
+ {:error, :failed_to_create_follow}
+ end
+ end
+ end
+
+ defp send_accept(user, remote_actor, follow_id, object_uri) when is_binary(follow_id) do
+ target_uri = object_uri || ActivityPub.actor_uri(user)
+
+ accept_activity =
+ Builder.build_accept_activity(user, %{
+ "id" => follow_id,
+ "type" => "Follow",
+ "actor" => remote_actor.uri,
+ "object" => target_uri
+ })
+
+ Publisher.publish(accept_activity, user, [remote_actor.inbox_url])
+ end
+
+ defp send_group_accept(group_actor, remote_actor, follow_id) when is_binary(follow_id) do
+ base_url = ActivityPub.instance_url()
+ activity_id = "#{base_url}/activities/#{Ecto.UUID.generate()}"
+
+ accept_activity = %{
+ "@context" => "https://www.w3.org/ns/activitystreams",
+ "id" => activity_id,
+ "type" => "Accept",
+ "actor" => group_actor.uri,
+ "object" => %{
+ "id" => follow_id,
+ "type" => "Follow",
+ "actor" => remote_actor.uri,
+ "object" => group_actor.uri
+ }
+ }
+
+ Publisher.publish(accept_activity, nil, [remote_actor.inbox_url])
+ end
+
+ defp send_relay_accept(relay_actor, remote_actor, follow_id) do
+ base_url = ActivityPub.instance_url()
+ relay_url = Relay.relay_actor_id()
+ activity_id = "#{base_url}/activities/#{Ecto.UUID.generate()}"
+
+ accept_activity = %{
+ "@context" => "https://www.w3.org/ns/activitystreams",
+ "id" => activity_id,
+ "type" => "Accept",
+ "actor" => relay_url,
+ "object" => %{
+ "id" => follow_id,
+ "type" => "Follow",
+ "actor" => remote_actor.uri,
+ "object" => relay_url
+ }
+ }
+
+ Publisher.deliver(accept_activity, relay_actor, remote_actor.inbox_url)
+ end
+
+ defp get_local_user_from_uri(uri) do
+ case ActivityPub.local_username_from_uri(uri) do
+ {:ok, username} ->
+ case Elektrine.Accounts.get_user_by_username(username) do
+ %{activitypub_enabled: true} = user -> {:ok, user}
+ _ -> {:error, :not_found}
+ end
+
+ {:error, _reason} ->
+ {:error, :not_local}
+ end
+ end
+end
diff --git a/apps/elektrine/lib/elektrine/activitypub/handlers/like_handler.ex b/apps/elektrine/lib/elektrine/activitypub/handlers/like_handler.ex
new file mode 100644
index 0000000..116cafd
--- /dev/null
+++ b/apps/elektrine/lib/elektrine/activitypub/handlers/like_handler.ex
@@ -0,0 +1,424 @@
+defmodule Elektrine.ActivityPub.Handlers.LikeHandler do
+ @moduledoc """
+ Handles Like, Dislike, and EmojiReact ActivityPub activities.
+ """
+
+ require Logger
+
+ alias Elektrine.ActivityPub
+ alias Elektrine.ActivityPub.Handlers.CreateHandler
+ alias Elektrine.ActivityPub.RemoteFetch
+ alias Elektrine.Async
+ alias Elektrine.Messaging
+
+ @doc """
+ Handles an incoming Like activity.
+ """
+ def handle(%{"object" => object_ref} = activity, actor_uri, _target_user) do
+ activity_id = activity["id"]
+ object_uri = extract_target_object_uri(object_ref)
+
+ with {:ok, object_uri} when not is_nil(object_uri) <- {:ok, object_uri},
+ {:ok, message} <- get_or_fetch_message_from_uri(object_uri),
+ {:ok, remote_actor} <- fetch_remote_actor(actor_uri, :like_actor_fetch_failed) do
+ case Messaging.create_federated_like(message.id, remote_actor.id, activity_id) do
+ {:ok, _like} ->
+ Async.run(fn ->
+ Elektrine.Notifications.FederationNotifications.notify_remote_like(
+ message.id,
+ remote_actor.id
+ )
+ end)
+
+ {:ok, :liked}
+
+ {:error, reason} ->
+ Logger.error("Failed to create like: #{inspect(reason)}")
+ {:error, :failed_to_like}
+ end
+ else
+ {:error, :message_not_found} ->
+ Logger.debug("Like target #{inspect(object_uri)} not available yet, retrying")
+ {:error, :message_not_found}
+
+ {:error, :like_actor_fetch_failed} ->
+ {:error, :like_actor_fetch_failed}
+
+ error ->
+ Logger.warning("Failed to handle like: #{inspect(error)}")
+ {:error, :handle_like_failed}
+ end
+ end
+
+ @doc """
+ Handles an incoming Dislike activity (downvote).
+ """
+ def handle_dislike(%{"object" => object_ref} = activity, actor_uri, _target_user) do
+ activity_id = activity["id"]
+ object_uri = extract_target_object_uri(object_ref)
+
+ with {:ok, object_uri} when not is_nil(object_uri) <- {:ok, object_uri},
+ {:ok, message} <- get_or_fetch_message_from_uri(object_uri),
+ {:ok, remote_actor} <- fetch_remote_actor(actor_uri, :dislike_actor_fetch_failed) do
+ case Messaging.create_federated_dislike(message.id, remote_actor.id, activity_id) do
+ {:ok, _dislike} ->
+ {:ok, :disliked}
+
+ {:error, reason} ->
+ Logger.error("Failed to create dislike: #{inspect(reason)}")
+ {:error, :failed_to_dislike}
+ end
+ else
+ {:error, :message_not_found} ->
+ Logger.debug("Dislike target #{inspect(object_uri)} not available yet, retrying")
+ {:error, :message_not_found}
+
+ {:error, :dislike_actor_fetch_failed} ->
+ {:error, :dislike_actor_fetch_failed}
+
+ error ->
+ Logger.warning("Failed to handle dislike: #{inspect(error)}")
+ {:error, :handle_dislike_failed}
+ end
+ end
+
+ @doc """
+ Handles an incoming EmojiReact activity.
+ Supports both standard Unicode emoji and custom emoji with URLs.
+ """
+ def handle_emoji_react(%{"object" => object_ref} = activity, actor_uri, _target_user) do
+ object_uri = extract_target_object_uri(object_ref)
+ emoji = extract_emoji_content(activity)
+
+ if is_binary(emoji) and emoji != "" do
+ emoji_url = extract_emoji_url(activity, emoji)
+
+ with {:ok, object_uri} when not is_nil(object_uri) <- {:ok, object_uri},
+ {:ok, remote_actor} <- ActivityPub.get_or_fetch_actor(actor_uri),
+ {:ok, message} <- get_or_fetch_message_from_uri(object_uri) do
+ case Elektrine.Social.Messages.create_federated_emoji_reaction(
+ message.id,
+ remote_actor.id,
+ emoji,
+ emoji_url
+ ) do
+ {:ok, _reaction} ->
+ Async.run(fn ->
+ Elektrine.Notifications.FederationNotifications.notify_remote_reaction(
+ message.id,
+ remote_actor.id,
+ emoji
+ )
+ end)
+
+ {:ok, :emoji_reacted}
+
+ {:error, reason} ->
+ Logger.error("Failed to create emoji reaction: #{inspect(reason)}")
+ {:error, :failed_to_react}
+ end
+ else
+ {:error, :message_not_found} ->
+ Logger.debug("EmojiReact for unknown message, ignoring")
+ {:error, :handle_emoji_react_failed}
+
+ error ->
+ Logger.warning("Failed to handle emoji react: #{inspect(error)}")
+ {:error, :handle_emoji_react_failed}
+ end
+ else
+ {:ok, :unhandled}
+ end
+ end
+
+ def handle_emoji_react(_activity, _actor_uri, _target_user), do: {:ok, :unhandled}
+
+ # Extract custom emoji URL from EmojiReact activity tags
+ # Format: {"tag": [{"type": "Emoji", "name": ":blobcat:", "icon": {"url": "https://..."}}]}
+ defp extract_emoji_url(%{"tag" => tags}, emoji) when is_list(tags) do
+ # Find matching emoji tag
+ Enum.find_value(tags, fn
+ %{"type" => "Emoji", "name" => name, "icon" => %{"url" => url}} ->
+ # Match by name (with or without colons)
+ clean_name = String.trim(name, ":")
+ clean_emoji = String.trim(emoji, ":")
+ if clean_name == clean_emoji, do: url, else: nil
+
+ _ ->
+ nil
+ end)
+ end
+
+ defp extract_emoji_url(_, _), do: nil
+
+ @doc """
+ Handles Undo Like activity.
+ """
+ def handle_undo_like(%{"object" => object_ref}, actor_uri) do
+ object_uri = resolve_target_object_uri(object_ref)
+
+ with {:ok, object_uri} when not is_nil(object_uri) <- {:ok, object_uri},
+ {:ok, remote_actor} <- ActivityPub.get_or_fetch_actor(actor_uri),
+ {:ok, message} <- get_local_message_from_uri(object_uri) do
+ Messaging.delete_federated_like(message.id, remote_actor.id)
+ {:ok, :unliked}
+ else
+ {:error, :message_not_found} ->
+ {:ok, :message_not_found}
+
+ error ->
+ Logger.warning("Failed to undo like: #{inspect(error)}")
+ {:error, :undo_like_failed}
+ end
+ end
+
+ @doc """
+ Handles Undo Dislike activity.
+ """
+ def handle_undo_dislike(%{"object" => object_ref}, actor_uri) do
+ object_uri = resolve_target_object_uri(object_ref)
+
+ with {:ok, object_uri} when not is_nil(object_uri) <- {:ok, object_uri},
+ {:ok, remote_actor} <- ActivityPub.get_or_fetch_actor(actor_uri),
+ {:ok, message} <- get_local_message_from_uri(object_uri) do
+ Messaging.delete_federated_dislike(message.id, remote_actor.id)
+ {:ok, :undisliked}
+ else
+ {:error, :message_not_found} ->
+ {:ok, :message_not_found}
+
+ error ->
+ Logger.warning("Failed to undo dislike: #{inspect(error)}")
+ {:error, :undo_dislike_failed}
+ end
+ end
+
+ @doc """
+ Handles Undo EmojiReact activity.
+ """
+ def handle_undo_emoji_react(%{"object" => object_ref, "content" => emoji}, actor_uri)
+ when is_binary(emoji) do
+ object_uri = resolve_target_object_uri(object_ref)
+
+ with {:ok, object_uri} when not is_nil(object_uri) <- {:ok, object_uri},
+ {:ok, remote_actor} <- ActivityPub.get_or_fetch_actor(actor_uri),
+ {:ok, message} <- get_local_message_from_uri(object_uri) do
+ Elektrine.Social.Messages.delete_federated_emoji_reaction(
+ message.id,
+ remote_actor.id,
+ emoji
+ )
+
+ {:ok, :emoji_unreacted}
+ else
+ error ->
+ Logger.warning("Failed to undo emoji react: #{inspect(error)}")
+ {:error, :undo_emoji_react_failed}
+ end
+ end
+
+ def handle_undo_emoji_react(%{"object" => _object_ref, "tag" => tags} = object, actor_uri)
+ when is_list(tags) do
+ # Extract emoji from tag
+ emoji =
+ Enum.find_value(tags, fn
+ %{"type" => "Emoji", "name" => name} -> name
+ _ -> nil
+ end)
+
+ if emoji do
+ handle_undo_emoji_react(Map.put(object, "content", emoji), actor_uri)
+ else
+ {:ok, :no_emoji_found}
+ end
+ end
+
+ def handle_undo_emoji_react(_object, actor_uri) do
+ Logger.warning("Invalid Undo EmojiReact from #{actor_uri}")
+ {:ok, :invalid}
+ end
+
+ # Private functions
+
+ defp extract_target_object_uri(object) when is_binary(object), do: object
+
+ defp extract_target_object_uri(%{"id" => id}) when is_binary(id), do: id
+
+ defp extract_target_object_uri(%{"object" => object_ref}) do
+ extract_target_object_uri(object_ref)
+ end
+
+ defp extract_target_object_uri(_), do: nil
+
+ defp resolve_target_object_uri(object), do: extract_target_object_uri(object)
+
+ defp extract_emoji_content(%{"content" => emoji}) when is_binary(emoji) and emoji != "",
+ do: emoji
+
+ defp extract_emoji_content(%{"contentMap" => content_map}) when is_map(content_map) do
+ content_map
+ |> Map.values()
+ |> Enum.find(&(is_binary(&1) and &1 != ""))
+ end
+
+ defp extract_emoji_content(%{"tag" => tags}) when is_list(tags) do
+ Enum.find_value(tags, fn
+ %{"type" => "Emoji", "name" => name} when is_binary(name) and name != "" -> name
+ _ -> nil
+ end)
+ end
+
+ defp extract_emoji_content(_), do: nil
+
+ defp get_local_message_from_uri(uri) do
+ base_url = ActivityPub.instance_url()
+
+ cond do
+ # Format: https://domain/posts/{id}
+ String.starts_with?(uri, "#{base_url}/posts/") ->
+ id = String.replace_prefix(uri, "#{base_url}/posts/", "")
+ get_message_by_id(id)
+
+ # Format: https://domain/users/{username}/posts/{id}
+ String.match?(uri, ~r{#{base_url}/users/[^/]+/posts/}) ->
+ id = uri |> String.split("/posts/") |> List.last()
+ get_message_by_id(id)
+
+ # Check by canonical ID or URL variant.
+ true ->
+ case Messaging.get_message_by_activitypub_ref(uri) do
+ nil -> {:error, :message_not_found}
+ message -> {:ok, message}
+ end
+ end
+ end
+
+ defp get_message_by_id(id) do
+ case Messaging.get_message(parse_message_id(id)) do
+ nil -> {:error, :message_not_found}
+ message -> {:ok, message}
+ end
+ rescue
+ Ecto.Query.CastError -> {:error, :message_not_found}
+ end
+
+ defp parse_message_id(id) when is_integer(id), do: id
+
+ defp parse_message_id(id) when is_binary(id) do
+ case Integer.parse(id) do
+ {parsed, ""} -> parsed
+ _ -> id
+ end
+ end
+
+ defp parse_message_id(id), do: id
+
+ # For remote EmojiReact races, try to fetch/cache the target object if we don't
+ # already have it locally.
+ defp get_or_fetch_message_from_uri(uri) do
+ case get_local_message_from_uri(uri) do
+ {:ok, message} ->
+ {:ok, message}
+
+ {:error, :message_not_found} ->
+ with {:ok, object, author_uri} <- fetch_reactable_object(uri),
+ :ok <- cache_reactable_object(object, author_uri),
+ {:ok, message} <- get_local_message_from_uri(object["id"] || uri) do
+ {:ok, message}
+ else
+ _ -> {:error, :message_not_found}
+ end
+ end
+ end
+
+ defp fetch_reactable_object(uri) do
+ with {:ok, object} <- RemoteFetch.fetch_object(uri) do
+ normalize_reactable_object(object)
+ end
+ end
+
+ # Handles Note/Page/Article directly and Create wrappers with embedded/linked object.
+ defp normalize_reactable_object(%{"type" => type} = object)
+ when type in ["Note", "Page", "Article"] do
+ case normalize_actor_uri(object["attributedTo"]) do
+ actor_uri when is_binary(actor_uri) -> {:ok, object, actor_uri}
+ _ -> {:error, :missing_author}
+ end
+ end
+
+ defp normalize_reactable_object(%{"type" => "Create"} = object) do
+ creator_uri = normalize_actor_uri(object["actor"])
+
+ case object["object"] do
+ %{} = inner_object ->
+ inner_with_actor =
+ if creator_uri do
+ Map.put_new(inner_object, "attributedTo", creator_uri)
+ else
+ inner_object
+ end
+ |> inherit_wrapper_fields(object)
+
+ normalize_reactable_object(inner_with_actor)
+
+ inner_uri when is_binary(inner_uri) ->
+ with {:ok, fetched_inner} <- RemoteFetch.fetch_object(inner_uri) do
+ inner_with_actor =
+ if creator_uri do
+ Map.put_new(fetched_inner, "attributedTo", creator_uri)
+ else
+ fetched_inner
+ end
+ |> inherit_wrapper_fields(object)
+
+ normalize_reactable_object(inner_with_actor)
+ end
+
+ _ ->
+ {:error, :unsupported_object}
+ end
+ end
+
+ defp normalize_reactable_object(_), do: {:error, :unsupported_object}
+
+ defp cache_reactable_object(object, author_uri) do
+ case CreateHandler.create_note(object, author_uri) do
+ {:ok, _} -> :ok
+ {:error, reason} -> {:error, reason}
+ end
+ end
+
+ defp fetch_remote_actor(actor_uri, error_reason) do
+ case ActivityPub.get_or_fetch_actor(actor_uri) do
+ {:ok, actor} ->
+ {:ok, actor}
+
+ {:error, reason} ->
+ Logger.warning("Failed to fetch remote actor #{actor_uri}: #{inspect(reason)}")
+ {:error, error_reason}
+ end
+ end
+
+ defp inherit_wrapper_fields(object, activity) when is_map(object) and is_map(activity) do
+ ["to", "cc", "audience", "published"]
+ |> Enum.reduce(object, fn field, acc ->
+ case {Map.get(acc, field), Map.get(activity, field)} do
+ {value, inherited} when value in [nil, ""] and not is_nil(inherited) ->
+ Map.put(acc, field, inherited)
+
+ {[], inherited} when not is_nil(inherited) ->
+ Map.put(acc, field, inherited)
+
+ _ ->
+ acc
+ end
+ end)
+ end
+
+ defp inherit_wrapper_fields(object, _activity), do: object
+
+ defp normalize_actor_uri(uri) when is_binary(uri), do: uri
+ defp normalize_actor_uri([head | _]), do: normalize_actor_uri(head)
+ defp normalize_actor_uri(%{"id" => id}) when is_binary(id), do: id
+ defp normalize_actor_uri(_), do: nil
+end
diff --git a/apps/elektrine/lib/elektrine/activitypub/handlers/move_handler.ex b/apps/elektrine/lib/elektrine/activitypub/handlers/move_handler.ex
new file mode 100644
index 0000000..a7cf2d6
--- /dev/null
+++ b/apps/elektrine/lib/elektrine/activitypub/handlers/move_handler.ex
@@ -0,0 +1,168 @@
+defmodule Elektrine.ActivityPub.Handlers.MoveHandler do
+ @moduledoc """
+ Handles ActivityPub Move activities for account migration.
+ """
+
+ import Ecto.Query, warn: false
+ require Logger
+
+ alias Elektrine.ActivityPub
+ alias Elektrine.ActivityPub.Actor
+ alias Elektrine.Profiles
+ alias Elektrine.Profiles.Follow
+ alias Elektrine.Repo
+
+ @doc """
+ Handles an incoming Move activity.
+ """
+ def handle(
+ %{"actor" => actor_uri, "object" => object, "target" => target},
+ _actor_uri,
+ _target_user
+ ) do
+ with {:ok, old_actor_uri} <- extract_uri(object),
+ {:ok, new_actor_uri} <- extract_uri(target),
+ :ok <- validate_actor_match(actor_uri, old_actor_uri),
+ {:ok, old_actor} <- fetch_move_actor(old_actor_uri),
+ {:ok, new_actor} <- fetch_move_actor(new_actor_uri),
+ :ok <- validate_move_aliases(old_actor, new_actor, new_actor_uri) do
+ migrate_local_remote_follows(old_actor.id, new_actor.id)
+ mark_actor_moved(old_actor, new_actor_uri)
+ {:ok, :moved}
+ else
+ {:error, :move_actor_fetch_failed} ->
+ {:error, :move_actor_fetch_failed}
+
+ {:error, reason} ->
+ Logger.warning("Ignoring Move activity: #{inspect(reason)}")
+ {:ok, :ignored}
+ end
+ end
+
+ def handle(_activity, _actor_uri, _target_user), do: {:ok, :unhandled}
+
+ defp extract_uri(uri) when is_binary(uri) do
+ trimmed = String.trim(uri)
+
+ if trimmed == "" do
+ {:error, :invalid_uri}
+ else
+ {:ok, trimmed}
+ end
+ end
+
+ defp extract_uri(%{"id" => uri}) when is_binary(uri), do: extract_uri(uri)
+ defp extract_uri(%{id: uri}) when is_binary(uri), do: extract_uri(uri)
+ defp extract_uri(_), do: {:error, :invalid_uri}
+
+ defp validate_actor_match(actor_uri, object_uri)
+ when is_binary(actor_uri) and is_binary(object_uri) do
+ if normalize_uri(actor_uri) == normalize_uri(object_uri) do
+ :ok
+ else
+ {:error, :actor_object_mismatch}
+ end
+ end
+
+ defp validate_actor_match(_, _), do: {:error, :actor_object_mismatch}
+
+ defp normalize_uri(uri) when is_binary(uri),
+ do:
+ uri
+ |> String.trim()
+ |> String.split("#", parts: 2)
+ |> hd()
+ |> String.split("?", parts: 2)
+ |> hd()
+ |> String.trim_trailing("/")
+
+ defp fetch_move_actor(uri) when is_binary(uri) do
+ case ActivityPub.get_or_fetch_actor(uri) do
+ {:ok, actor} ->
+ {:ok, actor}
+
+ {:error, reason} ->
+ Logger.warning("Failed to fetch Move actor #{uri}: #{inspect(reason)}")
+ {:error, :move_actor_fetch_failed}
+ end
+ end
+
+ defp validate_move_aliases(%Actor{} = old_actor, %Actor{} = new_actor, new_actor_uri) do
+ moved_to_uris =
+ old_actor.metadata
+ |> extract_uri_candidates("movedTo")
+ |> Enum.map(&normalize_uri/1)
+
+ known_aliases =
+ new_actor.metadata
+ |> extract_uri_candidates("alsoKnownAs")
+ |> Enum.map(&normalize_uri/1)
+
+ cond do
+ normalize_uri(new_actor_uri) not in moved_to_uris ->
+ {:error, :moved_to_mismatch}
+
+ normalize_uri(old_actor.uri) not in known_aliases ->
+ {:error, :missing_alias}
+
+ true ->
+ :ok
+ end
+ end
+
+ defp extract_uri_candidates(metadata, field) when is_map(metadata) do
+ metadata
+ |> Map.get(field)
+ |> expand_uri_candidates()
+ |> Enum.reject(&is_nil/1)
+ |> Enum.uniq()
+ end
+
+ defp extract_uri_candidates(_metadata, _field), do: []
+
+ defp expand_uri_candidates(value) when is_binary(value), do: [value]
+
+ defp expand_uri_candidates(values) when is_list(values),
+ do: Enum.flat_map(values, &expand_uri_candidates/1)
+
+ defp expand_uri_candidates(%{"id" => id}) when is_binary(id), do: [id]
+ defp expand_uri_candidates(%{"href" => href}) when is_binary(href), do: [href]
+ defp expand_uri_candidates(%{"url" => url}) when is_binary(url), do: [url]
+ defp expand_uri_candidates(_), do: []
+
+ defp migrate_local_remote_follows(old_actor_id, new_actor_id)
+ when is_integer(old_actor_id) and is_integer(new_actor_id) do
+ follows =
+ Follow
+ |> where(
+ [f],
+ f.remote_actor_id == ^old_actor_id and not is_nil(f.follower_id) and is_nil(f.followed_id)
+ )
+ |> Repo.all()
+
+ Enum.each(follows, fn follow ->
+ if Profiles.get_follow_to_remote_actor(follow.follower_id, new_actor_id) do
+ Repo.delete(follow)
+ else
+ follow
+ |> Ecto.Changeset.change(remote_actor_id: new_actor_id)
+ |> Repo.update()
+ end
+ end)
+ end
+
+ defp migrate_local_remote_follows(_, _), do: :ok
+
+ defp mark_actor_moved(%Actor{} = old_actor, new_actor_uri) do
+ metadata =
+ (old_actor.metadata || %{})
+ |> Map.put("movedTo", new_actor_uri)
+
+ old_actor
+ |> Actor.changeset(%{
+ metadata: metadata,
+ last_fetched_at: DateTime.utc_now() |> DateTime.truncate(:second)
+ })
+ |> Repo.update()
+ end
+end
diff --git a/apps/elektrine/lib/elektrine/activitypub/handlers/update_handler.ex b/apps/elektrine/lib/elektrine/activitypub/handlers/update_handler.ex
new file mode 100644
index 0000000..6548a79
--- /dev/null
+++ b/apps/elektrine/lib/elektrine/activitypub/handlers/update_handler.ex
@@ -0,0 +1,413 @@
+defmodule Elektrine.ActivityPub.Handlers.UpdateHandler do
+ @moduledoc """
+ Handles Update ActivityPub activities for actors and objects.
+ """
+
+ require Logger
+
+ alias Elektrine.ActivityPub
+ alias Elektrine.ActivityPub.Handlers.CreateHandler
+ alias Elektrine.Messaging
+ alias Elektrine.Repo
+ alias Elektrine.Social.Message
+
+ @remote_media_metadata_keys [
+ "alt_texts",
+ "original_like_count",
+ "original_reply_count",
+ "original_share_count",
+ "inReplyTo",
+ "inReplyToAuthor",
+ "inReplyToContent",
+ "external_link",
+ "community_actor_uri",
+ "emoji_reactions",
+ "quotes_count",
+ "quote",
+ "quote_id",
+ "quote_url",
+ "card",
+ "application",
+ "language",
+ "media_attachments",
+ "pleroma",
+ "misskey"
+ ]
+ @history_key "activitypub_edit_history"
+ @history_limit 20
+
+ @doc """
+ Handles an incoming Update activity.
+ """
+ def handle(%{"object" => object} = activity, actor_uri, _target_user) when is_map(object) do
+ object = inherit_wrapper_fields(activity, object)
+
+ case object["type"] do
+ "Person" ->
+ refresh_actor(actor_uri, object["id"], :profile_updated)
+
+ "Service" ->
+ refresh_actor(actor_uri, object["id"], :profile_updated)
+
+ "Group" ->
+ refresh_actor(actor_uri, object["id"], :group_updated)
+
+ type when type in ["Note", "Article", "Page", "Question"] ->
+ update_note(object, actor_uri)
+
+ _other_type ->
+ {:ok, :unhandled}
+ end
+ end
+
+ def handle(%{"object" => object_uri} = activity, actor_uri, target_user)
+ when is_binary(object_uri) do
+ case Elektrine.ActivityPub.RemoteFetch.fetch_object(object_uri) do
+ {:ok, object} when is_map(object) ->
+ handle(
+ %{activity | "object" => inherit_wrapper_fields(activity, object)},
+ actor_uri,
+ target_user
+ )
+
+ {:ok, _object} ->
+ Logger.warning(
+ "Failed to fetch Update object #{object_uri}: referenced object was not a map"
+ )
+
+ {:error, :update_object_fetch_failed}
+
+ {:error, reason} ->
+ Logger.warning("Failed to fetch Update object #{object_uri}: #{inspect(reason)}")
+ {:error, :update_object_fetch_failed}
+ end
+ end
+
+ def handle(_activity, _actor_uri, _target_user) do
+ {:ok, :unhandled}
+ end
+
+ defp refresh_actor(actor_uri, object_id, success_result) do
+ with :ok <- validate_actor_object_match(actor_uri, object_id),
+ {:ok, _actor} <- ActivityPub.fetch_and_cache_actor(actor_uri) do
+ {:ok, success_result}
+ else
+ {:error, :actor_mismatch} ->
+ Logger.warning(
+ "Rejecting Update for #{inspect(object_id)}: object id does not match verified actor #{actor_uri}"
+ )
+
+ {:ok, :unauthorized}
+
+ {:error, reason} ->
+ Logger.warning("Failed to refresh actor #{actor_uri}: #{inspect(reason)}")
+ {:error, :update_actor_fetch_failed}
+ end
+ end
+
+ defp update_note(object, actor_uri) do
+ case Messaging.get_message_by_activitypub_ref(object["id"]) do
+ nil ->
+ import_unknown_update_object(object, actor_uri)
+
+ message ->
+ with :ok <- CreateHandler.validate_object_author(object, actor_uri),
+ {:ok, remote_actor} <- fetch_message_actor(actor_uri),
+ :ok <- authorize_message_update(message, remote_actor),
+ {:ok, _updated_message} <- persist_message_update(message, object, actor_uri) do
+ {:ok, :updated}
+ else
+ {:error, :actor_mismatch} ->
+ Logger.warning(
+ "Rejecting Update for #{inspect(object["id"])}: object attributedTo does not match verified actor #{actor_uri}"
+ )
+
+ {:ok, :unauthorized}
+
+ {:error, :unauthorized} ->
+ Logger.warning(
+ "Rejecting Update for #{inspect(object["id"])} from non-owner #{actor_uri}"
+ )
+
+ {:ok, :unauthorized}
+
+ {:error, :update_actor_fetch_failed} ->
+ {:error, :update_actor_fetch_failed}
+
+ {:error, :update_failed} ->
+ {:error, :update_failed}
+ end
+ end
+ end
+
+ defp import_unknown_update_object(%{"type" => "Question"} = object, actor_uri) do
+ case CreateHandler.create_question(object, actor_uri) do
+ {:ok, message} when is_struct(message) -> {:ok, :created_from_update}
+ {:ok, result} -> {:ok, result}
+ {:error, :actor_mismatch} -> {:error, :unauthorized}
+ {:error, reason} -> {:error, reason}
+ end
+ end
+
+ defp import_unknown_update_object(object, actor_uri) do
+ case CreateHandler.create_note(object, actor_uri) do
+ {:ok, message} when is_struct(message) -> {:ok, :created_from_update}
+ {:ok, result} -> {:ok, result}
+ {:error, :actor_mismatch} -> {:error, :unauthorized}
+ {:error, reason} -> {:error, reason}
+ end
+ end
+
+ defp fetch_message_actor(actor_uri) do
+ case ActivityPub.get_or_fetch_actor(actor_uri) do
+ {:ok, remote_actor} ->
+ {:ok, remote_actor}
+
+ {:error, reason} ->
+ Logger.warning("Failed to fetch Update actor #{actor_uri}: #{inspect(reason)}")
+ {:error, :update_actor_fetch_failed}
+ end
+ end
+
+ defp authorize_message_update(message, remote_actor) do
+ if message.remote_actor_id == remote_actor.id do
+ :ok
+ else
+ {:error, :unauthorized}
+ end
+ end
+
+ defp persist_message_update(message, %{"type" => "Question"} = object, actor_uri) do
+ payload = CreateHandler.build_federated_question_payload(object, actor_uri)
+
+ attrs =
+ payload.attrs
+ |> Map.take([
+ :content,
+ :visibility,
+ :activitypub_url,
+ :media_urls,
+ :post_type,
+ :like_count,
+ :reply_count,
+ :share_count,
+ :quote_count,
+ :sensitive,
+ :content_warning,
+ :extracted_hashtags
+ ])
+ |> Map.put(
+ :media_metadata,
+ message.media_metadata
+ |> merge_remote_media_metadata(payload.attrs.media_metadata)
+ |> append_activitypub_edit_history(message, object, actor_uri)
+ )
+
+ message
+ |> Message.federated_changeset(attrs)
+ |> Ecto.Changeset.put_change(:edited_at, DateTime.utc_now() |> DateTime.truncate(:second))
+ |> Repo.update()
+ |> case do
+ {:ok, updated_message} ->
+ CreateHandler.sync_message_hashtags(updated_message.id, payload.hashtags)
+
+ case CreateHandler.upsert_federated_poll(updated_message.id, object, payload.options) do
+ {:ok, _poll} ->
+ {:ok, updated_message}
+
+ {:error, reason} ->
+ Logger.warning(
+ "Updated federated message #{updated_message.id} but failed to sync poll data: #{inspect(reason)}"
+ )
+
+ {:error, :update_failed}
+ end
+
+ {:error, changeset} ->
+ log_update_failure(message.id, changeset)
+ {:error, :update_failed}
+ end
+ end
+
+ defp persist_message_update(message, object, actor_uri) do
+ payload = CreateHandler.build_federated_message_payload(object, actor_uri)
+
+ attrs =
+ payload.attrs
+ |> Map.take([
+ :content,
+ :title,
+ :visibility,
+ :activitypub_url,
+ :primary_url,
+ :media_urls,
+ :reply_to_id,
+ :quoted_message_id,
+ :like_count,
+ :reply_count,
+ :share_count,
+ :quote_count,
+ :sensitive,
+ :content_warning,
+ :extracted_hashtags
+ ])
+ |> Map.put(
+ :media_metadata,
+ message.media_metadata
+ |> merge_remote_media_metadata(payload.attrs.media_metadata)
+ |> append_activitypub_edit_history(message, object, actor_uri)
+ )
+ |> maybe_put_primary_url_from_metadata(message)
+
+ message
+ |> Message.federated_changeset(attrs)
+ |> Ecto.Changeset.put_change(:edited_at, DateTime.utc_now() |> DateTime.truncate(:second))
+ |> Repo.update()
+ |> case do
+ {:ok, updated_message} ->
+ CreateHandler.sync_message_hashtags(updated_message.id, payload.hashtags)
+ {:ok, updated_message}
+
+ {:error, changeset} ->
+ log_update_failure(message.id, changeset)
+ {:error, :update_failed}
+ end
+ end
+
+ defp merge_remote_media_metadata(existing_metadata, fresh_metadata) do
+ existing_metadata =
+ case existing_metadata do
+ metadata when is_map(metadata) -> metadata
+ _ -> %{}
+ end
+
+ fresh_metadata =
+ case fresh_metadata do
+ metadata when is_map(metadata) -> metadata
+ _ -> %{}
+ end
+
+ existing_metadata
+ |> Map.drop(@remote_media_metadata_keys)
+ |> Map.merge(fresh_metadata)
+ end
+
+ defp append_activitypub_edit_history(metadata, message, object, actor_uri) do
+ previous =
+ %{
+ "content" => message.content,
+ "title" => message.title,
+ "summary" => message.content_warning,
+ "sensitive" => message.sensitive,
+ "visibility" => message.visibility,
+ "media_urls" => message.media_urls || [],
+ "activitypub_id" => message.activitypub_id,
+ "activitypub_url" => message.activitypub_url,
+ "edited_at" => format_datetime(message.edited_at || message.updated_at),
+ "seen_update_id" => object["id"],
+ "seen_update_actor" => actor_uri,
+ "seen_update_at" => format_datetime(DateTime.utc_now())
+ }
+ |> Enum.reject(fn {_key, value} -> is_nil(value) end)
+ |> Map.new()
+
+ history =
+ metadata
+ |> Map.get(@history_key, [])
+ |> case do
+ values when is_list(values) -> values
+ _ -> []
+ end
+
+ metadata
+ |> maybe_put_former_representations(object)
+ |> Map.put(@history_key, Enum.take([previous | history], @history_limit))
+ end
+
+ defp maybe_put_former_representations(metadata, %{"formerRepresentations" => history})
+ when is_map(history) do
+ Map.put(metadata, "formerRepresentations", trim_former_representations(history))
+ end
+
+ defp maybe_put_former_representations(metadata, _object), do: metadata
+
+ defp trim_former_representations(%{"orderedItems" => items} = history) when is_list(items) do
+ Map.put(history, "orderedItems", Enum.take(items, @history_limit))
+ end
+
+ defp trim_former_representations(%{"items" => items} = history) when is_list(items) do
+ Map.put(history, "items", Enum.take(items, @history_limit))
+ end
+
+ defp trim_former_representations(history), do: history
+
+ defp format_datetime(%DateTime{} = datetime), do: DateTime.to_iso8601(datetime)
+
+ defp format_datetime(%NaiveDateTime{} = datetime),
+ do: datetime |> DateTime.from_naive!("Etc/UTC") |> DateTime.to_iso8601()
+
+ defp format_datetime(_), do: nil
+
+ defp maybe_put_primary_url_from_metadata(attrs, message) when is_map(attrs) do
+ cond do
+ Elektrine.Strings.present?(attrs[:primary_url]) ->
+ attrs
+
+ Elektrine.Strings.present?(message.primary_url) ->
+ attrs
+
+ true ->
+ case get_in(attrs[:media_metadata] || %{}, ["external_link"]) do
+ url when is_binary(url) and url != "" -> Map.put(attrs, :primary_url, url)
+ _ -> attrs
+ end
+ end
+ end
+
+ defp log_update_failure(message_id, changeset) do
+ Logger.warning(
+ "Failed to update federated message #{message_id}: #{inspect(changeset.errors)}"
+ )
+ end
+
+ defp validate_actor_object_match(actor_uri, nil) when is_binary(actor_uri), do: :ok
+
+ defp validate_actor_object_match(actor_uri, object_id)
+ when is_binary(actor_uri) and is_binary(object_id) do
+ if normalize_uri(actor_uri) == normalize_uri(object_id) do
+ :ok
+ else
+ {:error, :actor_mismatch}
+ end
+ end
+
+ defp validate_actor_object_match(_actor_uri, _object_id), do: {:error, :actor_mismatch}
+
+ defp normalize_uri(uri) when is_binary(uri) do
+ uri
+ |> String.trim()
+ |> String.split("#", parts: 2)
+ |> hd()
+ |> String.split("?", parts: 2)
+ |> hd()
+ |> String.trim_trailing("/")
+ end
+
+ defp inherit_wrapper_fields(activity, object) when is_map(activity) and is_map(object) do
+ ["to", "cc", "audience", "published"]
+ |> Enum.reduce(object, fn field, acc ->
+ case {Map.get(acc, field), Map.get(activity, field)} do
+ {value, inherited} when value in [nil, ""] and not is_nil(inherited) ->
+ Map.put(acc, field, inherited)
+
+ {[], inherited} when not is_nil(inherited) ->
+ Map.put(acc, field, inherited)
+
+ _ ->
+ acc
+ end
+ end)
+ end
+
+ defp inherit_wrapper_fields(_activity, object), do: object
+end
diff --git a/apps/elektrine/lib/elektrine/activitypub/helpers.ex b/apps/elektrine/lib/elektrine/activitypub/helpers.ex
new file mode 100644
index 0000000..618b5fc
--- /dev/null
+++ b/apps/elektrine/lib/elektrine/activitypub/helpers.ex
@@ -0,0 +1,503 @@
+defmodule Elektrine.ActivityPub.Helpers do
+ @moduledoc """
+ Shared helper functions for ActivityPub data extraction and formatting.
+ """
+
+ import Ecto.Query
+
+ @doc """
+ Extracts totalItems from an ActivityPub collection or returns integer directly.
+
+ Handles multiple formats:
+ - `%{"totalItems" => count}` - collection object
+ - `count` - direct integer
+ - `nil` or other - returns 0
+ """
+ def get_collection_total(collection) when is_map(collection) do
+ parse_count(collection["totalItems"])
+ end
+
+ def get_collection_total(count) when is_integer(count), do: count
+ def get_collection_total(count) when is_binary(count), do: parse_count(count)
+ def get_collection_total(_), do: 0
+
+ @doc """
+ Extracts interaction count (likes, replies, shares) from an ActivityPub object.
+
+ Handles multiple formats that different platforms use:
+ - `object["likes"]["totalItems"]` - collection format
+ - `object["likes"]` = integer - direct count
+ - `object["likesCount"]` - alternative field name
+ """
+ def extract_interaction_count(object, type) when is_map(object) do
+ # Try multiple formats that different platforms use
+ cond do
+ # Format 1: Direct collection object with totalItems
+ is_map(object[type]) && is_integer(object[type]["totalItems"]) ->
+ object[type]["totalItems"]
+
+ # Format 1b: Collection with string totalItems
+ is_map(object[type]) && is_binary(object[type]["totalItems"]) ->
+ parse_count(object[type]["totalItems"])
+
+ # Format 2: Direct integer count
+ is_integer(object[type]) ->
+ object[type]
+
+ # Format 2b: Direct numeric string count
+ is_binary(object[type]) ->
+ parse_count(object[type])
+
+ # Format 3: Alternative field name (e.g., likesCount instead of likes)
+ is_integer(object["#{type}Count"]) ->
+ object["#{type}Count"]
+
+ is_binary(object["#{type}Count"]) ->
+ parse_count(object["#{type}Count"])
+
+ # Format 4: Akkoma/Pleroma style - like_count, announcement_count, repliesCount
+ type == "likes" && is_integer(object["like_count"]) ->
+ object["like_count"]
+
+ type == "likes" && is_binary(object["like_count"]) ->
+ parse_count(object["like_count"])
+
+ type == "shares" && is_integer(object["announcement_count"]) ->
+ object["announcement_count"]
+
+ type == "shares" && is_binary(object["announcement_count"]) ->
+ parse_count(object["announcement_count"])
+
+ type == "replies" && is_integer(object["repliesCount"]) ->
+ object["repliesCount"]
+
+ type == "replies" && is_binary(object["repliesCount"]) ->
+ parse_count(object["repliesCount"])
+
+ # Format 4b: Some servers expose comments instead of replies
+ type == "replies" && is_integer(object["comments"]) ->
+ object["comments"]
+
+ type == "replies" && is_binary(object["comments"]) ->
+ parse_count(object["comments"])
+
+ type == "replies" && is_map(object["comments"]) ->
+ get_collection_total(object["comments"])
+
+ # Format 5: Mastodon API style field names
+ type == "likes" && is_integer(object["favourites_count"]) ->
+ object["favourites_count"]
+
+ type == "likes" && is_binary(object["favourites_count"]) ->
+ parse_count(object["favourites_count"])
+
+ type == "shares" && is_integer(object["reblogs_count"]) ->
+ object["reblogs_count"]
+
+ type == "shares" && is_binary(object["reblogs_count"]) ->
+ parse_count(object["reblogs_count"])
+
+ type == "replies" && is_integer(object["replies_count"]) ->
+ object["replies_count"]
+
+ type == "replies" && is_binary(object["replies_count"]) ->
+ parse_count(object["replies_count"])
+
+ # Default to 0
+ true ->
+ 0
+ end
+ end
+
+ def extract_interaction_count(_, _), do: 0
+
+ @doc """
+ Extracts vote totals commonly exposed by Lemmy/PieFed/Mbin style objects.
+
+ Returns a map with `:upvotes`, `:downvotes`, and `:score` integers.
+ """
+ def extract_vote_totals(object) when is_map(object) do
+ %{
+ upvotes:
+ parse_count(object["upvotes"] || object["likes_count"] || object["positive_votes"]),
+ downvotes:
+ parse_count(object["downvotes"] || object["dislikes_count"] || object["negative_votes"]),
+ score: parse_signed_count(object["score"] || object["net_score"])
+ }
+ end
+
+ def extract_vote_totals(_), do: %{upvotes: 0, downvotes: 0, score: 0}
+
+ @doc """
+ Extracts follower count from actor metadata.
+
+ Handles various ActivityPub formats:
+ - `metadata["followers_count"]` - Mastodon extension
+ - `metadata["followersCount"]` - alternative format
+ - `metadata["followers"]["totalItems"]` - collection format
+ """
+ def get_follower_count(metadata) when is_map(metadata) do
+ [
+ parse_count(metadata["followers_count"]),
+ parse_count(metadata["followersCount"]),
+ parse_count(metadata["subscriber_count"]),
+ parse_count(metadata["subscribers_count"]),
+ parse_count(metadata["subscribersCount"]),
+ parse_count(metadata["subscribers"]),
+ parse_count(metadata["member_count"]),
+ get_collection_total(metadata["followers"]),
+ get_collection_total(metadata["subscribers"])
+ ]
+ |> Enum.max(fn -> 0 end)
+ end
+
+ def get_follower_count(_), do: 0
+
+ @doc """
+ Extracts following count from actor metadata.
+ """
+ def get_following_count(metadata) when is_map(metadata) do
+ [
+ parse_count(metadata["following_count"]),
+ parse_count(metadata["followingCount"]),
+ get_collection_total(metadata["following"])
+ ]
+ |> Enum.max(fn -> 0 end)
+ end
+
+ def get_following_count(_), do: 0
+
+ @doc """
+ Extracts post/status count from actor metadata.
+ """
+ def get_status_count(metadata) when is_map(metadata) do
+ [
+ parse_count(metadata["statuses_count"]),
+ parse_count(metadata["statusesCount"]),
+ parse_count(metadata["posts_count"]),
+ parse_count(metadata["postsCount"]),
+ parse_count(metadata["post_count"]),
+ parse_count(metadata["published_count"]),
+ get_collection_total(metadata["outbox"])
+ ]
+ |> Enum.max(fn -> 0 end)
+ end
+
+ def get_status_count(_), do: 0
+
+ @doc """
+ Formats an ActivityPub ISO8601 date string to human-readable relative time.
+ """
+ def format_activitypub_date(date_string) when is_binary(date_string) do
+ case DateTime.from_iso8601(date_string) do
+ {:ok, datetime, _} ->
+ Elektrine.Social.time_ago_in_words(datetime)
+
+ _ ->
+ date_string
+ end
+ end
+
+ def format_activitypub_date(_), do: ""
+
+ @doc """
+ Formats a join/published date to human-readable format.
+ """
+ def format_join_date(%DateTime{} = datetime) do
+ Calendar.strftime(datetime, "%B %d, %Y")
+ end
+
+ def format_join_date(%NaiveDateTime{} = ndt) do
+ ndt
+ |> DateTime.from_naive!("Etc/UTC")
+ |> format_join_date()
+ end
+
+ def format_join_date(_), do: "Unknown"
+
+ @doc """
+ Parses an ActivityPub published date string to DateTime.
+ Returns a truncated DateTime suitable for database storage.
+ """
+ def parse_published_date(date_string) when is_binary(date_string) do
+ case DateTime.from_iso8601(date_string) do
+ {:ok, datetime, _} -> DateTime.truncate(datetime, :second)
+ _ -> DateTime.utc_now() |> DateTime.truncate(:second)
+ end
+ end
+
+ def parse_published_date(_), do: DateTime.utc_now() |> DateTime.truncate(:second)
+
+ @doc """
+ Loads interaction state (likes/boosts) for ActivityPub posts for a given user.
+
+ Takes a list of ActivityPub post objects (maps with "id" field) and returns
+ a map of activitypub_id => %{liked: bool, boosted: bool, like_delta: 0, boost_delta: 0}
+ """
+ def load_post_interactions(posts, user_id) when is_list(posts) do
+ activitypub_ids = Enum.map(posts, & &1["id"]) |> Enum.filter(& &1)
+
+ if Enum.empty?(activitypub_ids) do
+ %{}
+ else
+ # Find messages that exist locally
+ local_messages = Elektrine.Messaging.get_messages_by_activitypub_ids(activitypub_ids)
+ message_id_map = Map.new(local_messages, fn msg -> {msg.activitypub_id, msg.id} end)
+ message_ids = Enum.map(local_messages, & &1.id)
+
+ # Get liked message IDs
+ liked_ids =
+ if Enum.empty?(message_ids) do
+ MapSet.new()
+ else
+ from(l in Elektrine.Social.PostLike,
+ where: l.user_id == ^user_id and l.message_id in ^message_ids,
+ select: l.message_id
+ )
+ |> Elektrine.Repo.all()
+ |> MapSet.new()
+ end
+
+ # Get boosted message IDs
+ boosted_ids =
+ if Enum.empty?(message_ids) do
+ MapSet.new()
+ else
+ from(b in Elektrine.Social.PostBoost,
+ where: b.user_id == ^user_id and b.message_id in ^message_ids,
+ select: b.message_id
+ )
+ |> Elektrine.Repo.all()
+ |> MapSet.new()
+ end
+
+ # Get user's votes (upvote/downvote) - maps message_id to vote_type ("up" or "down")
+ user_votes =
+ if Enum.empty?(message_ids) do
+ %{}
+ else
+ from(v in Elektrine.Social.MessageVote,
+ where: v.user_id == ^user_id and v.message_id in ^message_ids,
+ select: {v.message_id, v.vote_type}
+ )
+ |> Elektrine.Repo.all()
+ |> Map.new()
+ end
+
+ # Build result map
+ Map.new(activitypub_ids, fn activitypub_id ->
+ case Map.get(message_id_map, activitypub_id) do
+ nil ->
+ {activitypub_id,
+ %{
+ liked: false,
+ boosted: false,
+ like_delta: 0,
+ boost_delta: 0,
+ vote: nil,
+ vote_delta: 0
+ }}
+
+ message_id ->
+ {activitypub_id,
+ %{
+ liked: MapSet.member?(liked_ids, message_id),
+ boosted: MapSet.member?(boosted_ids, message_id),
+ like_delta: 0,
+ boost_delta: 0,
+ vote: Map.get(user_votes, message_id),
+ vote_delta: 0
+ }}
+ end
+ end)
+ end
+ end
+
+ def load_post_interactions(_, _), do: %{}
+
+ @doc """
+ Extracts username from an ActivityPub actor URI.
+
+ Handles various URI patterns:
+ - `/u/username` - Lemmy format
+ - `/users/username` - Mastodon format
+ - `/@username` - Mastodon alt format
+ """
+ def extract_username_from_uri(uri) when is_binary(uri) do
+ cond do
+ String.contains?(uri, "/u/") ->
+ uri |> String.split("/u/") |> List.last() |> String.split("/") |> List.first()
+
+ String.contains?(uri, "/users/") ->
+ uri |> String.split("/users/") |> List.last() |> String.split("/") |> List.first()
+
+ String.contains?(uri, "/@") ->
+ uri |> String.split("/@") |> List.last() |> String.split("/") |> List.first()
+
+ true ->
+ uri |> URI.parse() |> Map.get(:path, "") |> String.split("/") |> List.last()
+ end
+ end
+
+ def extract_username_from_uri(_), do: "unknown"
+
+ @doc """
+ Gets or stores a remote post locally so we can interact with it.
+
+ If the post exists locally, returns it. Otherwise fetches from the remote
+ server and stores it using the ActivityPub handler.
+
+ Two variants:
+ - `get_or_store_remote_post(activitypub_id)` - extracts actor_uri from fetched post
+ - `get_or_store_remote_post(activitypub_id, actor_uri)` - uses provided actor_uri
+ """
+ def get_or_store_remote_post(activitypub_id) do
+ case cached_remote_post(activitypub_id) do
+ nil ->
+ case Elektrine.ActivityPub.RemoteFetch.fetch_object(activitypub_id) do
+ {:ok, post_object} ->
+ actor_uri = Elektrine.ActivityPub.Normalizer.actor_uri(post_object)
+ store_or_resolve_remote_post(post_object, actor_uri)
+
+ {:error, reason} ->
+ {:error, reason}
+ end
+
+ message ->
+ {:ok, message}
+ end
+ end
+
+ def get_or_store_remote_post(activitypub_id, actor_uri, opts \\ []) do
+ case cached_remote_post(activitypub_id) do
+ nil ->
+ case Elektrine.ActivityPub.RemoteFetch.fetch_object(activitypub_id) do
+ {:ok, post_object} ->
+ preferred_actor_uri =
+ extracted_object_actor_uri(post_object) || actor_uri
+
+ post_object
+ |> tag_ancestor_depth(opts)
+ |> store_or_resolve_remote_post(preferred_actor_uri)
+
+ {:error, reason} ->
+ {:error, reason}
+ end
+
+ message ->
+ {:ok, message}
+ end
+ end
+
+ # Carries the ingestion-time ancestor-fetch depth into the stored object so
+ # CreateHandler can bound how far the "fetch missing parent" recursion walks
+ # up a reply chain (a hostile server can otherwise serve an endless
+ # inReplyTo chain and drive unbounded fetches).
+ defp tag_ancestor_depth(post_object, opts) when is_map(post_object) do
+ case Keyword.get(opts, :ancestor_depth) do
+ depth when is_integer(depth) -> Map.put(post_object, "_elektrine_ancestor_depth", depth)
+ _ -> post_object
+ end
+ end
+
+ defp tag_ancestor_depth(post_object, _opts), do: post_object
+
+ defp extracted_object_actor_uri(%{} = post_object) do
+ Elektrine.ActivityPub.Normalizer.actor_uri(post_object)
+ end
+
+ defp extracted_object_actor_uri(_), do: nil
+
+ defp cached_remote_post(activitypub_id) when is_binary(activitypub_id) do
+ Elektrine.Messaging.get_message_by_activitypub_ref(activitypub_id)
+ end
+
+ defp cached_remote_post(activitypub_id) do
+ Elektrine.Messaging.get_message_by_activitypub_id(activitypub_id)
+ end
+
+ defp store_or_resolve_remote_post(post_object, actor_uri) do
+ case store_remote_post_safely(post_object, actor_uri) do
+ {:ok, %Elektrine.Social.Message{} = message} ->
+ {:ok, message}
+
+ {:ok, status} ->
+ case resolve_cached_remote_post(post_object) do
+ {:ok, %Elektrine.Social.Message{} = message} -> {:ok, message}
+ :error -> {:error, status}
+ end
+
+ {:error, reason} ->
+ {:error, reason}
+ end
+ end
+
+ defp store_remote_post_safely(post_object, actor_uri) do
+ Elektrine.ActivityPub.Handler.store_remote_post(post_object, actor_uri)
+ rescue
+ error in Postgrex.Error ->
+ if unique_activitypub_violation?(error) do
+ {:ok, :already_exists}
+ else
+ reraise error, __STACKTRACE__
+ end
+ end
+
+ defp unique_activitypub_violation?(%Postgrex.Error{postgres: postgres}) when is_map(postgres) do
+ postgres[:code] in [:unique_violation, "unique_violation", "23505"] &&
+ to_string(postgres[:constraint] || "") == "messages_activitypub_id_index"
+ end
+
+ defp unique_activitypub_violation?(_), do: false
+
+ defp resolve_cached_remote_post(post_object) when is_map(post_object) do
+ refs =
+ [post_object["id"], post_object["url"]]
+ |> Enum.flat_map(&expand_activitypub_refs/1)
+ |> Enum.filter(&is_binary/1)
+ |> Enum.map(&String.trim/1)
+ |> Enum.reject(&(&1 == ""))
+ |> Enum.uniq()
+
+ case Enum.find_value(refs, &cached_remote_post/1) do
+ %Elektrine.Social.Message{} = message -> {:ok, message}
+ _ -> :error
+ end
+ end
+
+ defp resolve_cached_remote_post(_), do: :error
+
+ defp expand_activitypub_refs(value) when is_binary(value), do: [value]
+
+ defp expand_activitypub_refs(values) when is_list(values),
+ do: Enum.flat_map(values, &expand_activitypub_refs/1)
+
+ defp expand_activitypub_refs(%{"id" => id}), do: expand_activitypub_refs(id)
+ defp expand_activitypub_refs(%{"href" => href}), do: expand_activitypub_refs(href)
+ defp expand_activitypub_refs(%{"url" => url}), do: expand_activitypub_refs(url)
+ defp expand_activitypub_refs(_), do: []
+
+ defp parse_count(value) when is_integer(value), do: max(value, 0)
+
+ defp parse_count(value) when is_binary(value) do
+ case Integer.parse(String.trim(value)) do
+ {count, ""} -> max(count, 0)
+ _ -> 0
+ end
+ end
+
+ defp parse_count(_), do: 0
+
+ defp parse_signed_count(value) when is_integer(value), do: value
+
+ defp parse_signed_count(value) when is_binary(value) do
+ case Integer.parse(String.trim(value)) do
+ {count, ""} -> count
+ _ -> 0
+ end
+ end
+
+ defp parse_signed_count(_), do: 0
+
+ # Check if a post is from a Lemmy instance (has /post/ in activitypub_id)
+end
diff --git a/apps/elektrine/lib/elektrine/activitypub/http_signature.ex b/apps/elektrine/lib/elektrine/activitypub/http_signature.ex
new file mode 100644
index 0000000..700eb0b
--- /dev/null
+++ b/apps/elektrine/lib/elektrine/activitypub/http_signature.ex
@@ -0,0 +1,158 @@
+defmodule Elektrine.ActivityPub.HTTPSignature do
+ @moduledoc """
+ Signs outbound ActivityPub requests with HTTP Signatures.
+
+ Signing only. Inbound verification lives in
+ `Elektrine.ActivityPub.SignatureVerification`, which is the single verifier
+ for every entrance — do not add a second one here.
+ """
+ require Logger
+
+ @doc "Signs an HTTP GET request for authorized fetch mode.\nReturns headers to add to the request.\n"
+ def sign_get(url, private_key_pem, key_id) do
+ uri = URI.parse(url)
+ date = Calendar.strftime(DateTime.utc_now(), "%a, %d %b %Y %H:%M:%S GMT")
+ host = host_header_value(uri)
+
+ path_with_query =
+ if uri.query do
+ "#{uri.path || "/"}?#{uri.query}"
+ else
+ uri.path || "/"
+ end
+
+ headers_to_sign = ["(request-target)", "host", "date"]
+
+ signing_string_parts = [
+ "(request-target): get #{path_with_query}",
+ "host: #{host}",
+ "date: #{date}"
+ ]
+
+ signing_string = Enum.join(signing_string_parts, "\n")
+ signature = sign_string(signing_string, private_key_pem)
+ headers_string = Enum.join(headers_to_sign, " ")
+
+ signature_header =
+ [
+ ~s(keyId="#{key_id}"),
+ ~s(algorithm="hs2019"),
+ ~s(headers="#{headers_string}"),
+ ~s(signature="#{signature}")
+ ]
+ |> Enum.join(",")
+
+ [{"host", host}, {"date", date}, {"signature", signature_header}]
+ end
+
+ @doc "Signs an HTTP POST request for outgoing federation.\nReturns headers to add to the request.\n"
+ def sign(url, body, private_key_pem, key_id) do
+ uri = URI.parse(url)
+ date = Calendar.strftime(DateTime.utc_now(), "%a, %d %b %Y %H:%M:%S GMT")
+ host = host_header_value(uri)
+
+ digest =
+ if body do
+ "SHA-256=#{:crypto.hash(:sha256, body) |> Base.encode64()}"
+ else
+ nil
+ end
+
+ headers_to_sign =
+ if digest do
+ ["(request-target)", "host", "date", "digest"]
+ else
+ ["(request-target)", "host", "date"]
+ end
+
+ path_with_query =
+ if uri.query do
+ "#{uri.path || "/"}?#{uri.query}"
+ else
+ uri.path || "/"
+ end
+
+ signing_string_parts = [
+ "(request-target): post #{path_with_query}",
+ "host: #{host}",
+ "date: #{date}"
+ ]
+
+ signing_string_parts =
+ if digest do
+ signing_string_parts ++ ["digest: #{digest}"]
+ else
+ signing_string_parts
+ end
+
+ signing_string = Enum.join(signing_string_parts, "\n")
+ signature = sign_string(signing_string, private_key_pem)
+ headers_string = Enum.join(headers_to_sign, " ")
+
+ signature_header =
+ [
+ ~s(keyId="#{key_id}"),
+ ~s(algorithm="hs2019"),
+ ~s(headers="#{headers_string}"),
+ ~s(signature="#{signature}")
+ ]
+ |> Enum.join(",")
+
+ base_headers = [{"host", host}, {"date", date}, {"signature", signature_header}]
+
+ if digest do
+ base_headers ++ [{"digest", digest}]
+ else
+ base_headers
+ end
+ end
+
+ defp sign_string(string, private_key_pem) do
+ case decode_private_key(private_key_pem) do
+ {:ok, private_key} ->
+ signature = :public_key.sign(string, :sha256, private_key)
+ Base.encode64(signature)
+
+ {:error, _} ->
+ raise "Failed to decode private key"
+ end
+ end
+
+ defp decode_private_key(pem) do
+ [entry] = :public_key.pem_decode(pem)
+ private_key = :public_key.pem_entry_decode(entry)
+ {:ok, private_key}
+ rescue
+ e ->
+ Logger.error("Failed to decode private key: #{inspect(e)}")
+ {:error, :invalid_key}
+ end
+
+ defp host_header_value(%URI{host: host, port: port, scheme: scheme}) when is_binary(host) do
+ if is_nil(port) or default_port?(scheme, port) do
+ host
+ else
+ "#{host}:#{port}"
+ end
+ end
+
+ defp default_port?("http", 80), do: true
+ defp default_port?("https", 443), do: true
+ defp default_port?(_, _), do: false
+
+ @doc "Generates a new RSA key pair for a user.\nReturns {public_key_pem, private_key_pem}.\n\nPublic key is generated in PKCS#8 format for ActivityPub compatibility.\n"
+ def generate_key_pair do
+ private_key = :public_key.generate_key({:rsa, 2048, 65_537})
+
+ {:RSAPrivateKey, _, modulus, exponent, _, _, _, _, _, _, _} = private_key
+ public_key = {:RSAPublicKey, modulus, exponent}
+
+ private_pem =
+ :public_key.pem_encode([:public_key.pem_entry_encode(:RSAPrivateKey, private_key)])
+
+ public_pem =
+ :public_key.pem_encode([:public_key.pem_entry_encode(:SubjectPublicKeyInfo, public_key)])
+
+ {public_pem, private_pem}
+ end
+end
diff --git a/apps/elektrine/lib/elektrine/activitypub/inbound_delivery_policy.ex b/apps/elektrine/lib/elektrine/activitypub/inbound_delivery_policy.ex
new file mode 100644
index 0000000..31ab098
--- /dev/null
+++ b/apps/elektrine/lib/elektrine/activitypub/inbound_delivery_policy.ex
@@ -0,0 +1,223 @@
+defmodule Elektrine.ActivityPub.InboundDeliveryPolicy do
+ @moduledoc """
+ Decides whether an inbound content activity is addressed to anything local.
+
+ Remote servers can post any activity to any inbox. Without this gate a peer
+ can push arbitrary `Create`/`Announce` traffic into our storage by aiming it
+ at an inbox it has no relationship with, so a signed activity still has to
+ name a local audience: a targeted user or community, a thread we already
+ know, an actor somebody here follows, or an active relay.
+
+ This lives in `apps/elektrine` rather than beside the inbox controller
+ because the signature retry queue has to apply the same rule. It is the only
+ place the rule exists, and both entry points call `permit/4`.
+ """
+
+ alias Elektrine.ActivityPub
+ alias Elektrine.Messaging
+ alias Elektrine.Profiles
+
+ @doc """
+ Returns `:ok` when the activity may be processed, or
+ `{:error, :not_addressed_to_local_audience}`.
+
+ Activities that do not distribute content are always permitted; they are
+ filtered on their own terms further down the pipeline.
+ """
+ def permit(activity, actor_uri, target_user, target_community) do
+ if content_distribution_activity?(activity) do
+ cond do
+ local_user_targeted?(activity, target_user) -> :ok
+ local_community_targeted?(activity, target_community) -> :ok
+ references_known_message?(activity) -> :ok
+ locally_followed_actor?(actor_uri) -> :ok
+ ActivityPub.active_relay_actor_uri?(actor_uri) -> :ok
+ true -> {:error, :not_addressed_to_local_audience}
+ end
+ else
+ :ok
+ end
+ end
+
+ @doc """
+ Normalizes a URI for comparison: lowercased scheme and host, no trailing
+ slash, no fragment.
+ """
+ def comparable_uri(uri) when is_binary(uri) do
+ uri
+ |> String.trim()
+ |> case do
+ "" ->
+ nil
+
+ trimmed ->
+ case URI.parse(trimmed) do
+ %URI{scheme: scheme, host: host} = parsed
+ when is_binary(scheme) and is_binary(host) and host != "" ->
+ normalized_path =
+ parsed.path
+ |> Kernel.||("/")
+ |> case do
+ "/" -> "/"
+ path -> String.trim_trailing(path, "/")
+ end
+
+ parsed
+ |> Map.put(:scheme, String.downcase(scheme))
+ |> Map.put(:host, String.downcase(host))
+ |> Map.put(:path, normalized_path)
+ |> Map.put(:fragment, nil)
+ |> URI.to_string()
+
+ _ ->
+ trimmed
+ end
+ end
+ end
+
+ def comparable_uri(_), do: nil
+
+ defp content_distribution_activity?(%{"type" => type}) when type in ["Create", "Announce"],
+ do: true
+
+ defp content_distribution_activity?(_), do: false
+
+ defp local_user_targeted?(activity, nil) do
+ activity
+ |> activity_recipient_refs()
+ |> Enum.any?(&local_user_ref?/1)
+ end
+
+ defp local_user_targeted?(activity, user) do
+ target_uris = user_delivery_uris(user) |> Enum.map(&comparable_uri/1)
+
+ activity
+ |> activity_recipient_refs()
+ |> Enum.map(&comparable_uri/1)
+ |> Enum.any?(&(&1 in target_uris))
+ end
+
+ defp local_community_targeted?(_activity, nil), do: false
+
+ defp local_community_targeted?(activity, community) do
+ target_uris = community_delivery_uris(community) |> Enum.map(&comparable_uri/1)
+
+ activity
+ |> activity_recipient_refs()
+ |> Enum.map(&comparable_uri/1)
+ |> Enum.any?(&(&1 in target_uris))
+ end
+
+ defp references_known_message?(activity) do
+ activity
+ |> activity_object_refs()
+ |> Enum.any?(&known_message_ref?/1)
+ end
+
+ defp locally_followed_actor?(actor_uri) when is_binary(actor_uri) do
+ case ActivityPub.get_actor_by_uri(actor_uri) do
+ %ActivityPub.Actor{id: actor_id} -> Profiles.any_local_following_remote_actor?(actor_id)
+ _ -> false
+ end
+ end
+
+ defp locally_followed_actor?(_), do: false
+
+ defp activity_recipient_refs(activity) when is_map(activity) do
+ object = if is_map(activity["object"]), do: activity["object"], else: %{}
+
+ [
+ activity["to"],
+ activity["cc"],
+ activity["bto"],
+ activity["bcc"],
+ activity["audience"],
+ activity["target"],
+ object["to"],
+ object["cc"],
+ object["bto"],
+ object["bcc"],
+ object["audience"],
+ object["target"],
+ object["context"],
+ mention_hrefs(object)
+ ]
+ |> Enum.flat_map(&expand_uri_candidates/1)
+ |> Enum.reject(&is_nil/1)
+ |> Enum.uniq()
+ end
+
+ defp activity_recipient_refs(_), do: []
+
+ defp activity_object_refs(activity) when is_map(activity) do
+ object = Map.get(activity, "object")
+
+ [
+ object,
+ object_ref_field(object, "id"),
+ object_ref_field(object, "url"),
+ object_ref_field(object, "href"),
+ object_ref_field(object, "inReplyTo")
+ ]
+ |> Enum.flat_map(&expand_uri_candidates/1)
+ |> Enum.reject(&is_nil/1)
+ |> Enum.uniq()
+ end
+
+ defp activity_object_refs(_), do: []
+
+ defp object_ref_field(object, field) when is_map(object), do: Map.get(object, field)
+ defp object_ref_field(_, _), do: nil
+
+ defp mention_hrefs(%{"tag" => tags}) when is_list(tags) do
+ tags
+ |> Enum.filter(&(Map.get(&1, "type") == "Mention"))
+ |> Enum.map(&Map.get(&1, "href"))
+ end
+
+ defp mention_hrefs(_), do: []
+
+ defp expand_uri_candidates(value) when is_binary(value), do: [value]
+
+ defp expand_uri_candidates(values) when is_list(values),
+ do: Enum.flat_map(values, &expand_uri_candidates/1)
+
+ defp expand_uri_candidates(%{"id" => id}) when is_binary(id), do: [id]
+ defp expand_uri_candidates(%{"href" => href}) when is_binary(href), do: [href]
+ defp expand_uri_candidates(%{"url" => url}) when is_binary(url), do: [url]
+ defp expand_uri_candidates(_), do: []
+
+ defp local_user_ref?(ref) when is_binary(ref) do
+ case ActivityPub.local_username_from_uri(ref) do
+ {:ok, _username} -> true
+ _ -> false
+ end
+ end
+
+ defp local_user_ref?(_), do: false
+
+ defp known_message_ref?(ref) when is_binary(ref) do
+ match?(%{}, Messaging.get_message_by_activitypub_ref(ref))
+ end
+
+ defp known_message_ref?(_), do: false
+
+ defp user_delivery_uris(user) do
+ for identifier <- ActivityPub.actor_identifiers(user),
+ uri <- [
+ ActivityPub.actor_uri(identifier),
+ ActivityPub.user_collection_uri(identifier, "followers")
+ ],
+ is_binary(uri),
+ do: uri
+ end
+
+ defp community_delivery_uris(community) do
+ [
+ ActivityPub.community_actor_uri(community.name),
+ ActivityPub.community_followers_uri(community.name),
+ Map.get(community, :activitypub_id)
+ ]
+ |> Enum.filter(&is_binary/1)
+ end
+end
diff --git a/apps/elektrine/lib/elektrine/activitypub/inbox_queue.ex b/apps/elektrine/lib/elektrine/activitypub/inbox_queue.ex
new file mode 100644
index 0000000..1bb985e
--- /dev/null
+++ b/apps/elektrine/lib/elektrine/activitypub/inbox_queue.ex
@@ -0,0 +1,292 @@
+defmodule Elektrine.ActivityPub.InboxQueue do
+ @moduledoc "Fast in-memory queue for incoming ActivityPub activities.\n\nInstead of calling Oban.insert() for every /inbox request (which requires\na database connection), activities are queued in ETS and batch-inserted\ninto Oban periodically. This removes the database from the /inbox hot path.\n\nPerformance improvement: ~1000ms → ~5ms per /inbox request\n"
+ use GenServer
+ require Logger
+ alias Elektrine.Telemetry.Events
+ @table_name :inbox_activity_queue
+ @flush_interval 500
+ @max_batch_size 25
+ @insert_chunk_size 5
+ @max_queue_size 5000
+ def start_link(opts \\ []) do
+ GenServer.start_link(__MODULE__, opts, name: __MODULE__)
+ end
+
+ @doc "Enqueue an activity for processing. Returns immediately without hitting the database.\n"
+ def enqueue(activity, actor_uri, target_user_id) do
+ activity_id = activity["id"]
+ activity_type = activity["type"] || "unknown"
+
+ cond do
+ activity_id && already_queued?(activity_id) ->
+ Events.federation(:inbox_queue, :enqueue, :duplicate, nil, %{activity_type: activity_type})
+
+ {:ok, :duplicate}
+
+ overload_drop?(activity) ->
+ Events.federation(:inbox_queue, :enqueue, :shed, nil, %{
+ activity_type: activity_type,
+ actor_domain: actor_domain(actor_uri)
+ })
+
+ {:ok, :shed}
+
+ test_mode_bypass?() ->
+ item = %{
+ activity: activity,
+ actor_uri: actor_uri,
+ target_user_id: target_user_id,
+ activity_id: activity_id,
+ queued_at: System.system_time(:millisecond)
+ }
+
+ case Elektrine.JobQueue.insert(build_job(item)) do
+ {:ok, _job} ->
+ {:ok, :queued}
+
+ {:error, reason} ->
+ {:error, reason}
+ end
+
+ true ->
+ if activity_id do
+ mark_queued(activity_id)
+ end
+
+ item = %{
+ activity: activity,
+ actor_uri: actor_uri,
+ target_user_id: target_user_id,
+ activity_id: activity_id,
+ queued_at: System.system_time(:millisecond)
+ }
+
+ :ets.insert(@table_name, {make_ref(), item})
+
+ Events.federation(:inbox_queue, :enqueue, :queued, nil, %{
+ activity_type: activity_type,
+ has_target_user: not is_nil(target_user_id),
+ actor_domain: actor_domain(actor_uri)
+ })
+
+ {:ok, :queued}
+ end
+ end
+
+ @impl true
+ def init(_opts) do
+ :ets.new(@table_name, [:named_table, :public, :set, write_concurrency: true])
+ :ets.new(:inbox_dedup, [:named_table, :public, :set, write_concurrency: true])
+ schedule_flush()
+ Logger.info("Startup: inbox queue ready (flush_interval_ms=#{@flush_interval})")
+ {:ok, %{}}
+ end
+
+ @impl true
+ def handle_info(:flush, state) do
+ flush_queue()
+ schedule_flush()
+ {:noreply, state}
+ end
+
+ defp schedule_flush do
+ Process.send_after(self(), :flush, @flush_interval)
+ end
+
+ defp flush_queue do
+ items = take_batch(@max_batch_size)
+
+ if items != [] do
+ flush_started_at = System.monotonic_time(:millisecond)
+ flush_result = insert_in_chunks(items)
+ flush_duration = System.monotonic_time(:millisecond) - flush_started_at
+
+ case flush_result do
+ {:ok, inserted_count} ->
+ Logger.debug("InboxQueue flushed #{inserted_count} activities")
+
+ Events.federation(:inbox_queue, :flush, :success, flush_duration, %{
+ batch_size: inserted_count
+ })
+
+ {:error, reason, inserted_count, failed_items} ->
+ Logger.error(
+ "InboxQueue flush failed after #{inserted_count} inserts: #{inspect(reason)}"
+ )
+
+ Events.federation(:inbox_queue, :flush, :failure, flush_duration, %{
+ batch_size: length(items),
+ inserted_count: inserted_count,
+ requeued_count: length(failed_items),
+ reason: inspect(reason)
+ })
+
+ Enum.each(failed_items, fn item -> :ets.insert(@table_name, {make_ref(), item}) end)
+ end
+
+ cleanup_dedup()
+ end
+ end
+
+ defp take_batch(limit) when is_integer(limit) and limit > 0 do
+ do_take_batch(limit, :ets.first(@table_name), [])
+ end
+
+ defp take_batch(_) do
+ []
+ end
+
+ defp do_take_batch(0, _key, acc) do
+ Enum.reverse(acc)
+ end
+
+ defp do_take_batch(_remaining, :"$end_of_table", acc) do
+ Enum.reverse(acc)
+ end
+
+ defp do_take_batch(remaining, key, acc) do
+ next_key = :ets.next(@table_name, key)
+
+ case :ets.take(@table_name, key) do
+ [{^key, item}] -> do_take_batch(remaining - 1, next_key, [item | acc])
+ [] -> do_take_batch(remaining, next_key, acc)
+ end
+ end
+
+ defp insert_in_chunks(items) when is_list(items) do
+ chunks = Enum.chunk_every(items, @insert_chunk_size)
+ do_insert_chunks(chunks, 0)
+ end
+
+ defp do_insert_chunks([], inserted_count) do
+ {:ok, inserted_count}
+ end
+
+ defp do_insert_chunks([chunk | rest], inserted_count) do
+ jobs = Enum.map(chunk, &build_job/1)
+
+ case safe_insert_all(jobs) do
+ {:ok, inserted} ->
+ do_insert_chunks(rest, inserted_count + length(inserted))
+
+ {:error, reason} ->
+ failed_items = List.flatten([chunk | rest])
+ {:error, reason, inserted_count, failed_items}
+ end
+ end
+
+ defp safe_insert_all(jobs) do
+ {:ok, Elektrine.JobQueue.insert_all(jobs)}
+ rescue
+ e -> {:error, e}
+ end
+
+ defp build_job(item) do
+ priority = activity_priority(item.activity)
+
+ %{
+ "activity" => compact_activity(item.activity),
+ "actor_uri" => item.actor_uri,
+ "activity_id" => item.activity_id,
+ "target_user_id" => item.target_user_id
+ }
+ |> Elektrine.ActivityPub.ProcessActivityWorker.new(priority: priority)
+ end
+
+ defp activity_priority(%{"type" => type} = activity) do
+ case type do
+ "Create" -> 0
+ "Update" -> 0
+ "Delete" -> 0
+ "Follow" -> 1
+ "Accept" -> 1
+ "Reject" -> 1
+ "Undo" -> 1
+ "Block" -> 1
+ "Like" -> 2
+ "Dislike" -> 2
+ "EmojiReact" -> 2
+ "Announce" -> announce_priority(activity)
+ _ -> 1
+ end
+ end
+
+ defp announce_priority(%{"object" => object}) when is_map(object) do
+ case object["type"] do
+ type when type in ["Note", "Page", "Article", "Create", "Update", "Delete"] -> 0
+ type when type in ["Like", "Dislike", "EmojiReact"] -> 2
+ _ -> 1
+ end
+ end
+
+ defp announce_priority(_) do
+ 1
+ end
+
+ defp actor_domain(nil) do
+ "unknown"
+ end
+
+ defp actor_domain(actor_uri) when is_binary(actor_uri) do
+ case URI.parse(actor_uri) do
+ %URI{host: host} when is_binary(host) and host != "" -> host
+ _ -> "unknown"
+ end
+ end
+
+ defp actor_domain(_) do
+ "unknown"
+ end
+
+ defp test_mode_bypass? do
+ not Elektrine.Async.async_enabled?()
+ end
+
+ defp overload_drop?(activity) do
+ queue_size =
+ case :ets.whereis(@table_name) do
+ :undefined -> 0
+ _ -> :ets.info(@table_name, :size) || 0
+ end
+
+ queue_size >= @max_queue_size and low_priority_activity?(activity)
+ end
+
+ defp low_priority_activity?(%{"type" => type}) when type in ["Like", "Dislike", "EmojiReact"] do
+ true
+ end
+
+ defp low_priority_activity?(%{"type" => "Undo", "object" => %{"type" => object_type}})
+ when object_type in ["Like", "Dislike", "EmojiReact"] do
+ true
+ end
+
+ defp low_priority_activity?(_) do
+ false
+ end
+
+ defp compact_activity(activity), do: activity
+
+ defp already_queued?(activity_id) do
+ case :ets.lookup(:inbox_dedup, activity_id) do
+ [{^activity_id, timestamp}] -> System.system_time(:second) - timestamp < 60
+ _ -> false
+ end
+ end
+
+ defp mark_queued(activity_id) do
+ :ets.insert(:inbox_dedup, {activity_id, System.system_time(:second)})
+ end
+
+ defp cleanup_dedup do
+ if :rand.uniform(100) == 1 do
+ cutoff = System.system_time(:second) - 120
+
+ try do
+ :ets.select_delete(:inbox_dedup, [{{:_, :"$1"}, [{:<, :"$1", cutoff}], [true]}])
+ rescue
+ _ -> :ok
+ end
+ end
+ end
+end
diff --git a/apps/elektrine/lib/elektrine/activitypub/inbox_rate_limiter.ex b/apps/elektrine/lib/elektrine/activitypub/inbox_rate_limiter.ex
new file mode 100644
index 0000000..95c18ff
--- /dev/null
+++ b/apps/elektrine/lib/elektrine/activitypub/inbox_rate_limiter.ex
@@ -0,0 +1,144 @@
+defmodule Elektrine.ActivityPub.InboxRateLimiter do
+ @moduledoc """
+ Simple rate limiter for ActivityPub inbox to prevent resource exhaustion.
+ Uses ETS for fast in-memory tracking.
+ """
+
+ use GenServer
+ require Logger
+
+ # Default limits can be overridden at runtime via:
+ # config :elektrine, Elektrine.ActivityPub.InboxRateLimiter, ...
+ # Federation bursts are normal for relays, busy instances, and backfills.
+ @default_max_per_minute 120
+ @default_max_per_domain_per_minute 300
+ @default_max_global_per_second 30
+ # Cleanup interval
+ @cleanup_interval 60_000
+
+ def start_link(opts \\ []) do
+ GenServer.start_link(__MODULE__, opts, name: __MODULE__)
+ end
+
+ @doc """
+ Check if request should be allowed.
+ Returns {:ok, :allowed} or {:error, :rate_limited}
+ """
+ def check_rate_limit(ip_address, actor_domain \\ nil) do
+ now = System.system_time(:second)
+ minute_bucket = div(now, 60)
+
+ max_global_per_second =
+ configured_limit(:max_global_per_second, @default_max_global_per_second)
+
+ max_per_domain_per_minute =
+ configured_limit(:max_per_domain_per_minute, @default_max_per_domain_per_minute)
+
+ max_per_minute = configured_limit(:max_per_minute, @default_max_per_minute)
+
+ # Check global rate first
+ global_count =
+ :ets.update_counter(:inbox_rate_limit, {:global, now}, {2, 1}, {{:global, now}, 0})
+
+ cond do
+ global_count > max_global_per_second ->
+ {:error, :rate_limited}
+
+ # Check per-domain rate (most important - prevents one noisy instance from flooding)
+ actor_domain && domain_over_limit?(actor_domain, minute_bucket, max_per_domain_per_minute) ->
+ {:error, :rate_limited}
+
+ # Check per-IP rate
+ ip_over_limit?(ip_address, minute_bucket, max_per_minute) ->
+ {:error, :rate_limited}
+
+ true ->
+ {:ok, :allowed}
+ end
+ end
+
+ def check_domain_rate_limit(nil), do: {:ok, :allowed}
+
+ def check_domain_rate_limit(actor_domain) do
+ minute_bucket = div(System.system_time(:second), 60)
+
+ max_per_domain_per_minute =
+ configured_limit(:max_per_domain_per_minute, @default_max_per_domain_per_minute)
+
+ if domain_over_limit?(actor_domain, minute_bucket, max_per_domain_per_minute) do
+ {:error, :rate_limited}
+ else
+ {:ok, :allowed}
+ end
+ end
+
+ defp domain_over_limit?(domain, minute_bucket, max_per_domain_per_minute) do
+ count =
+ :ets.update_counter(
+ :inbox_rate_limit,
+ {:domain, domain, minute_bucket},
+ {2, 1},
+ {{:domain, domain, minute_bucket}, 0}
+ )
+
+ count > max_per_domain_per_minute
+ end
+
+ defp ip_over_limit?(ip_address, minute_bucket, max_per_minute) do
+ count =
+ :ets.update_counter(
+ :inbox_rate_limit,
+ {ip_address, minute_bucket},
+ {2, 1},
+ {{ip_address, minute_bucket}, 0}
+ )
+
+ count > max_per_minute
+ end
+
+ @impl true
+ def init(_opts) do
+ # Create ETS table for rate limiting
+ :ets.new(:inbox_rate_limit, [:named_table, :public, :set, {:write_concurrency, true}])
+
+ # Schedule cleanup
+ schedule_cleanup()
+
+ Logger.info("Startup: inbox rate limiter ready")
+ {:ok, %{}}
+ end
+
+ @impl true
+ def handle_info(:cleanup, state) do
+ # Clean up old entries
+ now = System.system_time(:second)
+ current_minute = div(now, 60)
+
+ # Delete entries older than 2 minutes (IP entries)
+ :ets.select_delete(:inbox_rate_limit, [
+ {{{:"$1", :"$2"}, :_}, [{:is_integer, :"$2"}, {:<, :"$2", current_minute - 2}], [true]},
+ # Clean up old global entries
+ {{{:global, :"$1"}, :_}, [{:<, :"$1", now - 5}], [true]},
+ # Clean up old domain entries
+ {{{:domain, :_, :"$1"}, :_}, [{:is_integer, :"$1"}, {:<, :"$1", current_minute - 2}],
+ [true]}
+ ])
+
+ schedule_cleanup()
+ {:noreply, state}
+ end
+
+ defp schedule_cleanup do
+ Process.send_after(self(), :cleanup, @cleanup_interval)
+ end
+
+ defp configured_limit(key, default) do
+ :elektrine
+ |> Application.get_env(__MODULE__, [])
+ |> Keyword.get(key, default)
+ |> normalize_limit(default)
+ end
+
+ defp normalize_limit(value, _default) when is_integer(value) and value > 0, do: value
+ defp normalize_limit(_, default), do: default
+end
diff --git a/apps/elektrine/lib/elektrine/activitypub/incoming_activity_worker.ex b/apps/elektrine/lib/elektrine/activitypub/incoming_activity_worker.ex
new file mode 100644
index 0000000..38761aa
--- /dev/null
+++ b/apps/elektrine/lib/elektrine/activitypub/incoming_activity_worker.ex
@@ -0,0 +1,160 @@
+defmodule Elektrine.ActivityPub.IncomingActivityWorker do
+ @moduledoc "Background worker for processing incoming ActivityPub activities.\n\nActivities are saved to the database immediately when received, then this\nworker processes them asynchronously to avoid blocking the inbox endpoint.\n\nBenefits:\n- Inbox endpoint returns immediately (202 Accepted)\n- Remote HTTP calls don't block web requests\n- Failed activities can be retried\n- Backpressure handling via batch size limits\n"
+ use GenServer
+ require Logger
+ alias Elektrine.ActivityPub.Activity
+ alias Elektrine.ActivityPub.Handler
+ alias Elektrine.Repo
+ import Ecto.Query
+ @tick_interval 30_000
+ @batch_size 10
+ @max_attempts 2
+ def start_link(opts \\ []) do
+ GenServer.start_link(__MODULE__, opts, name: __MODULE__)
+ end
+
+ @doc "Triggers immediate processing (useful after receiving new activities).\n"
+ def process_now do
+ GenServer.cast(__MODULE__, :process_now)
+ end
+
+ @impl true
+ def init(_opts) do
+ schedule_tick()
+ {:ok, %{processing: false}}
+ end
+
+ @impl true
+ def handle_info(:tick, state) do
+ state =
+ if state.processing do
+ state
+ else
+ try do
+ process_pending_activities()
+ rescue
+ e -> Logger.error("IncomingActivityWorker error: #{Exception.message(e)}")
+ end
+
+ state
+ end
+
+ schedule_tick()
+ {:noreply, state}
+ end
+
+ @impl true
+ def handle_cast(:process_now, state) do
+ if !state.processing do
+ try do
+ process_pending_activities()
+ rescue
+ e -> Logger.error("IncomingActivityWorker error: #{Exception.message(e)}")
+ end
+ end
+
+ {:noreply, state}
+ end
+
+ defp schedule_tick do
+ Process.send_after(self(), :tick, @tick_interval)
+ end
+
+ defp process_pending_activities do
+ activities =
+ from(a in Activity,
+ where: a.processed == false and a.local == false and a.process_attempts < @max_attempts,
+ order_by: [asc: a.inserted_at],
+ limit: ^@batch_size
+ )
+ |> Repo.all()
+
+ Enum.each(activities, fn activity ->
+ process_activity(activity)
+ Process.sleep(50)
+ end)
+ end
+
+ defp process_activity(activity) do
+ do_process_activity(activity)
+ end
+
+ defp do_process_activity(activity) do
+ activity
+ |> Activity.mark_processed_changeset(%{process_attempts: activity.process_attempts + 1})
+ |> Repo.update()
+
+ target_user = get_target_user(activity)
+
+ result =
+ try do
+ Handler.process_activity_async(activity.data, activity.actor_uri, target_user)
+ rescue
+ e ->
+ Logger.error(
+ "Error processing activity #{activity.activity_id}: #{Exception.message(e)}"
+ )
+
+ {:error, Exception.message(e)}
+ catch
+ :exit, reason ->
+ Logger.error("Activity processing exited: #{inspect(reason)}")
+ {:error, "Process exited: #{inspect(reason)}"}
+ end
+
+ case result do
+ {:ok, _} ->
+ activity
+ |> Activity.mark_processed_changeset(%{
+ processed: true,
+ processed_at: DateTime.utc_now() |> DateTime.truncate(:second),
+ process_error: nil
+ })
+ |> Repo.update()
+
+ {:error, reason} ->
+ error_msg =
+ if is_binary(reason) do
+ reason
+ else
+ inspect(reason)
+ end
+
+ retryable? = Handler.retryable_error?(reason)
+
+ attrs =
+ if retryable? do
+ %{process_error: String.slice(error_msg, 0, 255)}
+ else
+ %{
+ processed: true,
+ processed_at: DateTime.utc_now() |> DateTime.truncate(:second),
+ process_error: String.slice("discarded: " <> error_msg, 0, 255)
+ }
+ end
+
+ activity
+ |> Activity.mark_processed_changeset(attrs)
+ |> Repo.update()
+
+ if retryable? and activity.process_attempts >= @max_attempts do
+ Logger.warning(
+ "Activity #{activity.activity_id} failed after #{@max_attempts} attempts: #{error_msg}"
+ )
+ end
+ end
+ end
+
+ defp get_target_user(activity) do
+ case activity.object_id do
+ nil ->
+ nil
+
+ object_id ->
+ case Elektrine.ActivityPub.local_username_from_uri(object_id) do
+ {:ok, username} -> Elektrine.Accounts.get_user_by_username(username)
+ _ -> nil
+ end
+ end
+ end
+end
diff --git a/apps/elektrine/lib/elektrine/activitypub/instance.ex b/apps/elektrine/lib/elektrine/activitypub/instance.ex
new file mode 100644
index 0000000..4869991
--- /dev/null
+++ b/apps/elektrine/lib/elektrine/activitypub/instance.ex
@@ -0,0 +1,356 @@
+defmodule Elektrine.ActivityPub.Instance do
+ @moduledoc """
+ Represents a remote ActivityPub instance with MRF (Message Rewrite Facility) policies.
+
+ ## Policy Types
+
+ - `blocked` - Completely reject all activities from this instance (except deletes)
+ - `silenced` - Accept activities but don't show in public timelines (deprecated, use federated_timeline_removal)
+ - `media_removal` - Strip media attachments from posts
+ - `media_nsfw` - Mark all media as sensitive/NSFW
+ - `federated_timeline_removal` - Hide posts from federated timeline (but visible to followers)
+ - `followers_only` - Force all posts to be followers-only visibility
+ - `report_removal` - Reject Flag (report) activities
+ - `avatar_removal` - Strip avatar images from actor profiles
+ - `banner_removal` - Strip banner/header images from actor profiles
+ - `reject_deletes` - Reject Delete activities (prevents removing content)
+
+ ## Wildcard Domains
+
+ Domains can include wildcards: `*.example.com` matches all subdomains of example.com.
+ """
+ use Ecto.Schema
+ import Ecto.Changeset
+
+ schema "activitypub_instances" do
+ field(:domain, :string)
+ field(:reason, :string)
+ field(:notes, :string)
+ field(:blocked_at, :utc_datetime)
+ field(:policy_applied_at, :utc_datetime)
+
+ # Core blocking policies
+ field(:blocked, :boolean, default: false)
+ field(:silenced, :boolean, default: false)
+
+ # Granular MRF policies
+ field(:media_removal, :boolean, default: false)
+ field(:media_nsfw, :boolean, default: false)
+ field(:federated_timeline_removal, :boolean, default: false)
+ field(:followers_only, :boolean, default: false)
+ field(:report_removal, :boolean, default: false)
+ field(:avatar_removal, :boolean, default: false)
+ field(:banner_removal, :boolean, default: false)
+ field(:reject_deletes, :boolean, default: false)
+
+ # Reachability tracking - when non-nil, instance is considered unreachable
+ field(:unreachable_since, :utc_datetime)
+ # Count of consecutive failures (for exponential backoff)
+ field(:failure_count, :integer, default: 0)
+
+ # Instance metadata (fetched via NodeInfo)
+ field(:nodeinfo, :map, default: %{})
+ field(:favicon, :string)
+ field(:metadata_updated_at, :utc_datetime)
+
+ belongs_to(:blocked_by, Elektrine.Accounts.User)
+ belongs_to(:policy_applied_by, Elektrine.Accounts.User)
+
+ timestamps()
+ end
+
+ @policy_fields [
+ :blocked,
+ :silenced,
+ :media_removal,
+ :media_nsfw,
+ :federated_timeline_removal,
+ :followers_only,
+ :report_removal,
+ :avatar_removal,
+ :banner_removal,
+ :reject_deletes
+ ]
+
+ @doc """
+ Returns the list of policy field names.
+ """
+ def policy_fields, do: @policy_fields
+
+ @doc false
+ def changeset(instance, attrs) do
+ instance
+ |> cast(attrs, [
+ :domain,
+ :reason,
+ :notes,
+ :blocked_at,
+ :policy_applied_at,
+ :blocked_by_id,
+ :policy_applied_by_id
+ | @policy_fields
+ ])
+ |> normalize_domain()
+ |> validate_required([:domain])
+ |> validate_domain()
+ |> unique_constraint(:domain, name: :activitypub_instances_domain_ci_unique)
+ |> unique_constraint(:domain, name: :activitypub_instances_domain_index)
+ end
+
+ @doc """
+ Creates a changeset for applying MRF policies to an instance.
+ """
+ def policy_changeset(instance, attrs, admin_user_id) do
+ instance
+ |> cast(attrs, [:reason, :notes | @policy_fields])
+ |> put_change(:policy_applied_at, DateTime.utc_now() |> DateTime.truncate(:second))
+ |> put_change(:policy_applied_by_id, admin_user_id)
+ |> maybe_set_blocked_at(attrs)
+ end
+
+ defp maybe_set_blocked_at(changeset, attrs) do
+ if attrs[:blocked] || attrs["blocked"] do
+ put_change(changeset, :blocked_at, DateTime.utc_now() |> DateTime.truncate(:second))
+ else
+ changeset
+ end
+ end
+
+ defp validate_domain(changeset) do
+ validate_change(changeset, :domain, fn :domain, domain ->
+ # Allow wildcards like *.example.com
+ domain = String.trim(domain) |> String.downcase()
+
+ cond do
+ domain == "" ->
+ [domain: "cannot be blank"]
+
+ String.contains?(domain, " ") ->
+ [domain: "cannot contain spaces"]
+
+ String.starts_with?(domain, ".") && !String.starts_with?(domain, "*.") ->
+ [domain: "invalid format"]
+
+ # Check for valid domain format (with optional wildcard prefix)
+ !Regex.match?(
+ ~r/^(\*\.)?[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$/,
+ domain
+ ) ->
+ [domain: "invalid domain format"]
+
+ true ->
+ []
+ end
+ end)
+ end
+
+ defp normalize_domain(changeset) do
+ update_change(changeset, :domain, fn domain ->
+ domain
+ |> String.trim()
+ |> String.downcase()
+ |> String.replace(~r/^https?:\/\//, "")
+ end)
+ end
+
+ @doc """
+ Checks if a given hostname matches this instance's domain pattern.
+ Supports wildcard patterns like *.example.com
+ """
+ def matches_domain?(%__MODULE__{domain: pattern}, hostname) when is_binary(hostname) do
+ matches_domain_pattern?(pattern, hostname)
+ end
+
+ @doc """
+ Checks if a hostname matches a domain pattern.
+ """
+ def matches_domain_pattern?(pattern, hostname)
+ when is_binary(pattern) and is_binary(hostname) do
+ pattern = String.downcase(pattern)
+ hostname = String.downcase(hostname)
+
+ cond do
+ # Exact match
+ pattern == hostname ->
+ true
+
+ # Wildcard pattern: *.example.com matches sub.example.com but not example.com
+ String.starts_with?(pattern, "*.") ->
+ suffix = String.replace_prefix(pattern, "*", "")
+ String.ends_with?(hostname, suffix) && hostname != String.replace_prefix(suffix, ".", "")
+
+ true ->
+ false
+ end
+ end
+
+ @doc """
+ Returns a human-readable summary of active policies.
+ """
+ def policy_summary(%__MODULE__{} = instance) do
+ @policy_fields
+ |> Enum.filter(fn field -> Map.get(instance, field, false) end)
+ |> Enum.map(&policy_label/1)
+ end
+
+ defp policy_label(:blocked), do: "Blocked"
+ defp policy_label(:silenced), do: "Silenced"
+ defp policy_label(:media_removal), do: "Media Removed"
+ defp policy_label(:media_nsfw), do: "Media NSFW"
+ defp policy_label(:federated_timeline_removal), do: "FTL Removed"
+ defp policy_label(:followers_only), do: "Followers Only"
+ defp policy_label(:report_removal), do: "Reports Rejected"
+ defp policy_label(:avatar_removal), do: "Avatars Stripped"
+ defp policy_label(:banner_removal), do: "Banners Stripped"
+ defp policy_label(:reject_deletes), do: "Deletes Rejected"
+
+ @doc """
+ Checks if any policy is active for this instance.
+ """
+ def has_any_policy?(%__MODULE__{} = instance) do
+ Enum.any?(@policy_fields, fn field -> Map.get(instance, field, false) end)
+ end
+
+ @doc """
+ Returns the severity level based on active policies.
+ """
+ def severity_level(%__MODULE__{} = instance) do
+ cond do
+ instance.blocked -> :blocked
+ instance.silenced || instance.followers_only -> :restricted
+ instance.federated_timeline_removal -> :limited
+ has_any_policy?(instance) -> :modified
+ true -> :none
+ end
+ end
+
+ # Reachability tracking functions
+
+ @doc """
+ Checks if an instance is currently considered reachable.
+ An instance is unreachable if unreachable_since is set and within the timeout period.
+ """
+ def reachable?(%__MODULE__{unreachable_since: nil}), do: true
+
+ def reachable?(%__MODULE__{unreachable_since: unreachable_since}) do
+ timeout_days = Application.get_env(:elektrine, :federation_reachability_timeout_days, 7)
+ cutoff = DateTime.add(DateTime.utc_now(), -timeout_days * 24 * 60 * 60, :second)
+ DateTime.compare(unreachable_since, cutoff) == :gt
+ end
+
+ @doc """
+ Creates a changeset to mark an instance as unreachable.
+ """
+ def set_unreachable_changeset(instance) do
+ now = DateTime.utc_now() |> DateTime.truncate(:second)
+ failure_count = (instance.failure_count || 0) + 1
+
+ instance
+ |> change(%{
+ unreachable_since: instance.unreachable_since || now,
+ failure_count: failure_count
+ })
+ end
+
+ @doc """
+ Creates a changeset to mark an instance as reachable (clears unreachable status).
+ """
+ def set_reachable_changeset(instance) do
+ instance
+ |> change(%{
+ unreachable_since: nil,
+ failure_count: 0
+ })
+ end
+
+ @doc """
+ Returns the backoff duration in seconds based on failure count.
+ Uses exponential backoff: 1min, 2min, 4min, 8min, ... up to 1 day max.
+ """
+ def backoff_duration(%__MODULE__{failure_count: count}) when is_integer(count) and count > 0 do
+ # 1 minute
+ base = 60
+ # 1 day
+ max = 86_400
+ min((base * :math.pow(2, count - 1)) |> round(), max)
+ end
+
+ def backoff_duration(_), do: 0
+
+ # NodeInfo / Metadata functions
+
+ @doc """
+ Creates a changeset for updating instance metadata (nodeinfo, favicon).
+ """
+ def metadata_changeset(instance, attrs) do
+ instance
+ |> cast(attrs, [:nodeinfo, :favicon, :metadata_updated_at])
+ end
+
+ @doc """
+ Checks if the instance metadata needs to be refreshed.
+ Returns true if never fetched or older than 24 hours.
+ """
+ def needs_metadata_update?(%__MODULE__{metadata_updated_at: nil}), do: true
+
+ def needs_metadata_update?(%__MODULE__{metadata_updated_at: updated_at}) do
+ # Refresh if older than 24 hours
+ cutoff = DateTime.add(DateTime.utc_now(), -24 * 60 * 60, :second)
+ DateTime.compare(updated_at, cutoff) == :lt
+ end
+
+ def needs_metadata_update?(nil), do: true
+
+ @doc """
+ Returns the software name from nodeinfo, if available.
+ """
+ def software_name(%__MODULE__{nodeinfo: %{"software" => %{"name" => name}}})
+ when is_binary(name) do
+ String.downcase(name)
+ end
+
+ def software_name(_), do: nil
+
+ @doc """
+ Returns the software version from nodeinfo, if available.
+ """
+ def software_version(%__MODULE__{nodeinfo: %{"software" => %{"version" => version}}})
+ when is_binary(version) do
+ version
+ end
+
+ def software_version(_), do: nil
+
+ @doc """
+ Returns user statistics from nodeinfo, if available.
+ """
+ def user_stats(%__MODULE__{nodeinfo: %{"usage" => %{"users" => users}}}) when is_map(users) do
+ %{
+ total: users["total"],
+ active_month: users["activeMonth"],
+ active_half_year: users["activeHalfyear"]
+ }
+ end
+
+ def user_stats(_), do: nil
+
+ @doc """
+ Returns local post count from nodeinfo, if available.
+ """
+ def local_posts(%__MODULE__{nodeinfo: %{"usage" => %{"localPosts" => count}}})
+ when is_integer(count) do
+ count
+ end
+
+ def local_posts(_), do: nil
+
+ @doc """
+ Checks if the instance has open registrations.
+ """
+ def open_registrations?(%__MODULE__{nodeinfo: %{"openRegistrations" => open}})
+ when is_boolean(open) do
+ open
+ end
+
+ def open_registrations?(_), do: nil
+end
diff --git a/apps/elektrine/lib/elektrine/activitypub/instances.ex b/apps/elektrine/lib/elektrine/activitypub/instances.ex
new file mode 100644
index 0000000..09dad51
--- /dev/null
+++ b/apps/elektrine/lib/elektrine/activitypub/instances.ex
@@ -0,0 +1,235 @@
+defmodule Elektrine.ActivityPub.Instances do
+ @moduledoc """
+ Context for managing ActivityPub instance reachability and federation status.
+
+ Tracks which remote instances are reachable and implements smart delivery
+ filtering to avoid wasting resources on dead instances.
+ """
+
+ import Ecto.Query
+ alias Elektrine.ActivityPub.Instance
+ alias Elektrine.ActivityPub.NodeInfoFetcherWorker
+ alias Elektrine.Repo
+
+ @doc """
+ Checks if a domain is reachable.
+ Returns true if no unreachable record exists or if the instance has recovered.
+ """
+ def reachable?(domain) when is_binary(domain) do
+ domain = normalize_domain(domain)
+
+ case get_instance(domain) do
+ nil -> true
+ instance -> Instance.reachable?(instance)
+ end
+ end
+
+ @doc """
+ Marks an instance as unreachable after a delivery failure.
+ Creates the instance record if it doesn't exist.
+ """
+ def set_unreachable(domain) when is_binary(domain) do
+ domain = normalize_domain(domain)
+
+ case get_or_create_instance(domain) do
+ {:ok, instance} ->
+ instance
+ |> Instance.set_unreachable_changeset()
+ |> Repo.update()
+
+ error ->
+ error
+ end
+ end
+
+ @doc """
+ Marks an instance as reachable after a successful delivery.
+ Only updates if the instance record exists.
+ """
+ def set_reachable(domain) when is_binary(domain) do
+ domain = normalize_domain(domain)
+
+ case get_instance(domain) do
+ nil ->
+ {:ok, :no_record}
+
+ instance ->
+ if instance.unreachable_since do
+ instance
+ |> Instance.set_reachable_changeset()
+ |> Repo.update()
+ else
+ {:ok, instance}
+ end
+ end
+ end
+
+ @doc """
+ Gets an instance record by domain.
+ """
+ def get_instance(domain) when is_binary(domain) do
+ domain = normalize_domain(domain)
+
+ Instance
+ |> where([i], i.domain == ^domain)
+ |> order_by([i], desc: i.metadata_updated_at, desc: i.updated_at, desc: i.id)
+ |> limit(1)
+ |> Repo.one()
+ end
+
+ @doc """
+ Gets or creates an instance record for a domain.
+ """
+ def get_or_create_instance(domain) when is_binary(domain) do
+ domain = normalize_domain(domain)
+
+ case get_instance(domain) do
+ nil ->
+ case upsert_instance(domain) do
+ {:ok, %Instance{id: id} = instance} when is_integer(id) ->
+ {:ok, instance}
+
+ {:ok, _instance} ->
+ {:ok, get_instance(domain)}
+
+ {:error, changeset} ->
+ if Keyword.has_key?(changeset.errors, :domain) do
+ {:ok, get_instance(domain)}
+ else
+ {:error, changeset}
+ end
+ end
+
+ instance ->
+ {:ok, instance}
+ end
+ end
+
+ defp upsert_instance(domain) do
+ %Instance{}
+ |> Instance.changeset(%{domain: domain})
+ |> Repo.insert(
+ on_conflict: :nothing,
+ conflict_target: {:unsafe_fragment, "(lower(domain))"}
+ )
+ end
+
+ @doc """
+ Checks if we should attempt delivery to a domain based on backoff.
+ """
+ def should_retry?(domain) when is_binary(domain) do
+ domain = normalize_domain(domain)
+
+ case get_instance(domain) do
+ nil ->
+ true
+
+ %{unreachable_since: nil} ->
+ true
+
+ instance ->
+ backoff_seconds = Instance.backoff_duration(instance)
+ backoff_until = DateTime.add(instance.unreachable_since, backoff_seconds, :second)
+ DateTime.compare(DateTime.utc_now(), backoff_until) == :gt
+ end
+ end
+
+ @doc """
+ Cleans up old unreachable records that have exceeded the timeout period.
+ Called periodically to prevent unbounded growth.
+ """
+ def cleanup_old_records do
+ timeout_days = Application.get_env(:elektrine, :federation_reachability_timeout_days, 7)
+ # Keep records for 3x the timeout period before cleanup
+ cleanup_cutoff = DateTime.add(DateTime.utc_now(), -timeout_days * 3 * 24 * 60 * 60, :second)
+
+ # Only clean up instances that have no MRF policies and are just reachability records
+ from(i in Instance,
+ where: not is_nil(i.unreachable_since),
+ where: i.unreachable_since < ^cleanup_cutoff,
+ where: i.blocked == false,
+ where: i.silenced == false,
+ where: i.media_removal == false,
+ where: i.media_nsfw == false,
+ where: i.federated_timeline_removal == false,
+ where: i.followers_only == false,
+ where: i.report_removal == false,
+ where: i.avatar_removal == false,
+ where: i.banner_removal == false,
+ where: i.reject_deletes == false
+ )
+ |> Repo.delete_all()
+ end
+
+ defp normalize_domain(domain) do
+ domain
+ |> String.trim()
+ |> String.downcase()
+ end
+
+ # NodeInfo / Metadata functions
+
+ @doc """
+ Enqueues a background job to fetch nodeinfo for a domain.
+ Safe to call frequently - jobs are deduplicated and metadata is only
+ refreshed if older than 24 hours.
+ """
+ def fetch_metadata(domain) when is_binary(domain) do
+ NodeInfoFetcherWorker.enqueue(domain)
+ end
+
+ @doc """
+ Enqueues a background job to fetch nodeinfo for a domain extracted from a URL.
+ """
+ def fetch_metadata_from_url(url) when is_binary(url) do
+ NodeInfoFetcherWorker.enqueue_from_url(url)
+ end
+
+ @doc """
+ Gets instance with nodeinfo for a domain.
+ Optionally triggers a background fetch if metadata is stale.
+ """
+ def get_instance_with_metadata(domain, opts \\ []) when is_binary(domain) do
+ domain = normalize_domain(domain)
+ instance = get_instance(domain)
+
+ if opts[:fetch_if_stale] && Instance.needs_metadata_update?(instance) do
+ fetch_metadata(domain)
+ end
+
+ instance
+ end
+
+ @doc """
+ Gets software name for a domain from stored nodeinfo.
+ Returns nil if not known.
+ """
+ def get_software(domain) when is_binary(domain) do
+ domain = normalize_domain(domain)
+
+ case get_instance(domain) do
+ %Instance{} = instance -> Instance.software_name(instance)
+ nil -> nil
+ end
+ end
+
+ @doc """
+ Gets software info for multiple domains in a single query.
+ Returns a map of domain => software_name.
+ """
+ def get_software_batch(domains) when is_list(domains) do
+ domains = Enum.map(domains, &normalize_domain/1) |> Enum.uniq()
+
+ Instance
+ |> where([i], i.domain in ^domains)
+ |> where([i], not is_nil(i.nodeinfo))
+ |> select([i], {i.domain, i.nodeinfo})
+ |> Repo.all()
+ |> Enum.reduce(%{}, fn {domain, nodeinfo}, acc ->
+ case get_in(nodeinfo, ["software", "name"]) do
+ name when is_binary(name) -> Map.put(acc, domain, String.downcase(name))
+ _ -> acc
+ end
+ end)
+ end
+end
diff --git a/apps/elektrine/lib/elektrine/activitypub/key_manager.ex b/apps/elektrine/lib/elektrine/activitypub/key_manager.ex
new file mode 100644
index 0000000..10ebdc5
--- /dev/null
+++ b/apps/elektrine/lib/elektrine/activitypub/key_manager.ex
@@ -0,0 +1,83 @@
+defmodule Elektrine.ActivityPub.KeyManager do
+ @moduledoc """
+ Manages ActivityPub RSA keys for users and actors.
+ Generates keys lazily when first needed.
+ """
+
+ require Logger
+
+ alias Elektrine.Accounts
+ alias Elektrine.ActivityPub
+ alias Elektrine.ActivityPub.HTTPSignature
+ alias Elektrine.Repo
+
+ @doc """
+ Ensures a user or actor has ActivityPub keys, generating them if needed.
+ Returns the entity with keys loaded.
+
+ Accepts a User struct, Actor struct, or user ID.
+ """
+ def ensure_user_has_keys(%ActivityPub.Actor{} = actor) do
+ private_key = ActivityPub.Actor.metadata_private_key(actor)
+
+ if actor.public_key && private_key do
+ # Actor already has keys
+ {:ok, actor}
+ else
+ # Generate keys for actor (local actors like relay)
+ generate_keys_for_actor(actor)
+ end
+ end
+
+ def ensure_user_has_keys(%Accounts.User{} = user) do
+ if user.activitypub_public_key && user.activitypub_private_key do
+ # Keys already exist
+ {:ok, user}
+ else
+ # Generate keys
+ generate_keys_for_user(user)
+ end
+ end
+
+ def ensure_user_has_keys(user_id) when is_integer(user_id) do
+ user = Accounts.get_user!(user_id)
+ ensure_user_has_keys(user)
+ end
+
+ defp generate_keys_for_user(user) do
+ Logger.info("Generating ActivityPub keys for user: #{user.username}")
+
+ {public_key, private_key} = HTTPSignature.generate_key_pair()
+
+ user
+ |> Ecto.Changeset.change(%{
+ activitypub_public_key: public_key,
+ activitypub_private_key: private_key
+ })
+ |> Repo.update()
+ rescue
+ e ->
+ Logger.error("Failed to generate keys for user #{user.username}: #{inspect(e)}")
+ {:error, :key_generation_failed}
+ end
+
+ defp generate_keys_for_actor(actor) do
+ Logger.info("Generating ActivityPub keys for actor: #{actor.uri}")
+
+ {public_key, private_key} = HTTPSignature.generate_key_pair()
+
+ # Store private key in metadata, public key in dedicated field
+ updated_metadata = ActivityPub.Actor.put_metadata_private_key(actor.metadata, private_key)
+
+ actor
+ |> Ecto.Changeset.change(%{
+ public_key: public_key,
+ metadata: updated_metadata
+ })
+ |> Repo.update()
+ rescue
+ e ->
+ Logger.error("Failed to generate keys for actor #{actor.uri}: #{inspect(e)}")
+ {:error, :key_generation_failed}
+ end
+end
diff --git a/apps/elektrine/lib/elektrine/activitypub/lemmy_api.ex b/apps/elektrine/lib/elektrine/activitypub/lemmy_api.ex
new file mode 100644
index 0000000..874038d
--- /dev/null
+++ b/apps/elektrine/lib/elektrine/activitypub/lemmy_api.ex
@@ -0,0 +1,721 @@
+defmodule Elektrine.ActivityPub.LemmyApi do
+ @moduledoc """
+ Helper module for fetching data from Lemmy instances via their JSON API.
+ """
+
+ alias Elektrine.HTTP.SafeFetch
+
+ @comment_page_size 100
+ @comment_max_pages 50
+
+ @doc """
+ Fetch post counts (upvotes, downvotes, score, comments) from a Lemmy instance.
+ Returns nil if the post is not from a Lemmy instance or if the fetch fails.
+ """
+ def fetch_post_counts(post_url) when is_binary(post_url) do
+ case resolve_post_reference(post_url) do
+ {:ok, domain, post_id} ->
+ api_url = "https://#{domain}/api/v4/post?id=#{post_id}"
+ headers = [{"Accept", "application/json"}, {"User-Agent", "Elektrine/1.0"}]
+
+ case safe_request_lemmy_api(:get, api_url, headers, nil, receive_timeout: 5_000) do
+ {:ok, %Finch.Response{status: 200, body: body}} ->
+ case Jason.decode(body) do
+ {:ok, %{"post_view" => %{"counts" => counts}}} ->
+ %{
+ upvotes: counts["upvotes"] || 0,
+ downvotes: counts["downvotes"] || 0,
+ score: counts["score"] || 0,
+ comments: counts["comments"] || 0
+ }
+
+ _ ->
+ fetch_post_counts_from_html(post_url)
+ end
+
+ {:ok, %Finch.Response{status: _status}} ->
+ fetch_post_counts_from_html(post_url)
+
+ {:error, _reason} ->
+ fetch_post_counts_from_html(post_url)
+ end
+
+ :error ->
+ fetch_post_counts_from_html(post_url)
+ end
+ end
+
+ def fetch_post_counts(_), do: nil
+
+ @doc """
+ Fetch member and post counts for a Lemmy community.
+
+ Returns `%{members: count, posts: count}` on success, or `nil` if the
+ community is not found or the instance is not Lemmy-compatible.
+ """
+ def fetch_community_counts(domain, community_name)
+ when is_binary(domain) and is_binary(community_name) do
+ api_url = "https://#{domain}/api/v4/community?name=#{URI.encode_www_form(community_name)}"
+ headers = [{"Accept", "application/json"}, {"User-Agent", "Elektrine/1.0"}]
+
+ case safe_request_lemmy_api(:get, api_url, headers, nil, receive_timeout: 5_000) do
+ {:ok, %Finch.Response{status: 200, body: body}} ->
+ case Jason.decode(body) do
+ {:ok, %{"community_view" => %{"counts" => counts}}} ->
+ %{
+ members: parse_count(counts["subscribers"]),
+ posts: parse_count(counts["posts"])
+ }
+
+ _ ->
+ nil
+ end
+
+ {:ok, %Finch.Response{status: _status}} ->
+ nil
+
+ {:error, _reason} ->
+ nil
+ end
+ end
+
+ def fetch_community_counts(_, _), do: nil
+
+ @doc """
+ Fetch counts for multiple Lemmy posts in parallel.
+ Returns a map of activitypub_id => counts.
+ Uses yield_many to avoid blocking on slow/failed requests.
+ """
+ def fetch_posts_counts(posts) when is_list(posts) do
+ tasks =
+ posts
+ |> Enum.filter(&lemmy_post?/1)
+ |> Enum.map(fn post ->
+ Task.async(fn ->
+ activitypub_id = get_activitypub_id(post)
+ post_url = activitypub_id || get_activitypub_url(post)
+ counts = fetch_post_counts(post_url)
+ {activitypub_id, counts}
+ end)
+ end)
+
+ # Use a slightly longer timeout for busy Lemmy instances.
+ results = Task.yield_many(tasks, timeout: 6_000)
+
+ # Shutdown any tasks that didn't complete
+ Enum.each(results, fn {task, result} ->
+ if result == nil, do: Task.shutdown(task, :brutal_kill)
+ end)
+
+ # Collect successful results
+ results
+ |> Enum.flat_map(fn
+ {_task, {:ok, {id, counts}}} when counts != nil -> [{id, counts}]
+ _ -> []
+ end)
+ |> Map.new()
+ end
+
+ @doc """
+ Fetch comment counts for a Lemmy post.
+ Returns a map of comment_ap_id => %{score, upvotes, downvotes, child_count}
+ """
+ def fetch_comment_counts(post_url) when is_binary(post_url) do
+ case resolve_post_reference(post_url) do
+ {:ok, domain, post_id} ->
+ domain
+ |> fetch_comment_counts_pages(post_id, 100, 1, %{})
+ |> case do
+ %{} = counts when map_size(counts) > 0 -> counts
+ _ -> fetch_comment_counts_from_post_comments(post_url)
+ end
+
+ :error ->
+ fetch_comment_counts_from_post_comments(post_url)
+ end
+ end
+
+ def fetch_comment_counts(_), do: %{}
+
+ defp fetch_comment_counts_pages(_domain, _post_id, _page_size, page, acc) when page > 10,
+ do: acc
+
+ defp fetch_comment_counts_pages(domain, post_id, page_size, page, acc) do
+ api_url =
+ "https://#{domain}/api/v4/comment/list?post_id=#{post_id}&limit=#{page_size}&page=#{page}&max_depth=10"
+
+ headers = [{"Accept", "application/json"}, {"User-Agent", "Elektrine/1.0"}]
+
+ case safe_request_lemmy_api(:get, api_url, headers, nil, receive_timeout: 10_000) do
+ {:ok, %Finch.Response{status: 200, body: body}} ->
+ case Jason.decode(body) do
+ {:ok, %{"comments" => comments}} when is_list(comments) ->
+ updated_acc =
+ Enum.reduce(comments, acc, fn comment_view, current_acc ->
+ ap_id = get_in(comment_view, ["comment", "ap_id"])
+ counts = comment_view["counts"] || %{}
+
+ if is_binary(ap_id) do
+ Map.put(current_acc, ap_id, %{
+ score: counts["score"] || 0,
+ upvotes: counts["upvotes"] || 0,
+ downvotes: counts["downvotes"] || 0,
+ child_count: counts["child_count"] || 0
+ })
+ else
+ current_acc
+ end
+ end)
+
+ cond do
+ comments == [] ->
+ updated_acc
+
+ length(comments) < page_size ->
+ updated_acc
+
+ true ->
+ fetch_comment_counts_pages(domain, post_id, page_size, page + 1, updated_acc)
+ end
+
+ _ ->
+ acc
+ end
+
+ _ ->
+ acc
+ end
+ end
+
+ defp fetch_comment_counts_from_post_comments(post_url) when is_binary(post_url) do
+ post_url
+ |> fetch_post_comments(500)
+ |> Enum.reduce(%{}, fn
+ %{"id" => ap_id} = comment, acc when is_binary(ap_id) ->
+ Map.put(acc, ap_id, %{
+ score: parse_count(comment["score"]),
+ upvotes: parse_count(comment["upvotes"]),
+ downvotes: parse_count(comment["downvotes"]),
+ child_count: extract_collection_total(comment["replies"])
+ })
+
+ _, acc ->
+ acc
+ end)
+ end
+
+ @doc """
+ Fetch top comments for a Lemmy post with content.
+ Returns a list of comment maps with :author, :content, :score, :ap_id
+ Sorted by score (top comments first), limited to top 3.
+ """
+ def fetch_top_comments(post_url, limit \\ 3)
+
+ def fetch_top_comments(post_url, limit) when is_binary(post_url) do
+ case resolve_post_reference(post_url) do
+ {:ok, domain, post_id} ->
+ # Sort by Top to get highest scored comments, only get top-level (parent_id not set)
+ api_url =
+ "https://#{domain}/api/v4/comment/list?post_id=#{post_id}&sort=Top&limit=#{limit}&max_depth=1"
+
+ headers = [{"Accept", "application/json"}, {"User-Agent", "Elektrine/1.0"}]
+
+ case safe_request_lemmy_api(:get, api_url, headers, nil, receive_timeout: 5_000) do
+ {:ok, %Finch.Response{status: 200, body: body}} ->
+ case Jason.decode(body) do
+ {:ok, %{"comments" => comments}} ->
+ comments
+ |> Enum.reject(&lemmy_comment_hidden?/1)
+ |> Enum.take(limit)
+ |> Enum.map(fn comment_view ->
+ comment = comment_view["comment"] || %{}
+ creator = comment_view["creator"] || %{}
+ counts = comment_view["counts"] || %{}
+
+ %{
+ ap_id: comment["ap_id"],
+ content: comment["content"] || "",
+ author: creator["name"] || creator["display_name"] || "unknown",
+ author_domain: extract_domain(creator["actor_id"]),
+ actor_id: creator["actor_id"],
+ author_avatar: creator["avatar"],
+ published: comment["published"],
+ score: counts["score"] || 0,
+ upvotes: counts["upvotes"] || 0,
+ child_count: counts["child_count"] || 0
+ }
+ end)
+
+ _ ->
+ []
+ end
+
+ {:ok, %Finch.Response{status: _status}} ->
+ []
+
+ {:error, _reason} ->
+ []
+ end
+
+ :error ->
+ []
+ end
+ end
+
+ def fetch_top_comments(_, _), do: []
+
+ defp lemmy_comment_hidden?(%{"comment" => comment}) when is_map(comment) do
+ comment["deleted"] == true or comment["removed"] == true or
+ not Elektrine.Strings.present?(comment["content"])
+ end
+
+ defp lemmy_comment_hidden?(_), do: true
+
+ @doc """
+ Fetch all available comments for a Lemmy post with content and parent refs.
+ Returns a list of ActivityPub-like comment maps suitable for local storage.
+ """
+ def fetch_post_comments(post_url, limit \\ 100)
+
+ def fetch_post_comments(post_url, limit) when is_binary(post_url) do
+ fetch_post_comments(post_url, limit, [])
+ end
+
+ def fetch_post_comments(_, _), do: []
+
+ def fetch_post_comments(post_url, limit, opts) when is_binary(post_url) and is_list(opts) do
+ case resolve_post_reference(post_url) do
+ {:ok, domain, post_id} ->
+ fetch_post_comments_from_instance(domain, post_id, post_url, limit, opts)
+
+ :error ->
+ []
+ end
+ end
+
+ def fetch_post_comments(_, _, _), do: []
+
+ @doc false
+ def fetch_post_comments_from_instance(domain, post_id, post_url, limit \\ 100, opts \\ [])
+
+ def fetch_post_comments_from_instance(domain, post_id, post_url, limit, opts)
+ when is_binary(domain) and is_binary(post_id) and is_binary(post_url) and is_integer(limit) and
+ is_list(opts) and limit > 0 do
+ headers = [{"Accept", "application/json"}, {"User-Agent", "Elektrine/1.0"}]
+ requested_page_size = Keyword.get(opts, :page_size, @comment_page_size)
+ page_size = min(max(limit, 1), normalize_page_size(requested_page_size))
+
+ comment_api_url_templates(domain, post_id)
+ |> Enum.find_value([], fn api_url_template ->
+ comments =
+ fetch_post_comment_pages(
+ api_url_template,
+ headers,
+ page_size,
+ limit,
+ 1,
+ [],
+ opts
+ )
+
+ case lemmy_comment_views_to_ap(comments, post_url) do
+ [] -> nil
+ decoded_comments -> decoded_comments
+ end
+ end)
+ end
+
+ def fetch_post_comments_from_instance(_, _, _, _, _), do: []
+
+ defp comment_api_url_templates(domain, post_id) do
+ [
+ fn page_size, page ->
+ "https://#{domain}/api/v4/comment/list?post_id=#{post_id}&limit=#{page_size}&page=#{page}&max_depth=10"
+ end,
+ fn page_size, page ->
+ "https://#{domain}/api/v3/comment/list?post_id=#{post_id}&limit=#{page_size}&page=#{page}&max_depth=10"
+ end,
+ fn page_size, page ->
+ "https://#{domain}/api/v3/comment/list?post_id=#{post_id}&sort=Hot&limit=#{page_size}&page=#{page}&max_depth=10"
+ end,
+ fn page_size, page ->
+ "https://#{domain}/api/v3/comment/list?post_id=#{post_id}&type_=All&sort=Hot&limit=#{page_size}&page=#{page}&max_depth=10"
+ end
+ ]
+ end
+
+ defp fetch_post_comment_pages(
+ _api_url_template,
+ _headers,
+ _page_size,
+ limit,
+ _page,
+ acc,
+ _opts
+ )
+ when length(acc) >= limit do
+ Enum.take(acc, limit)
+ end
+
+ defp fetch_post_comment_pages(
+ _api_url_template,
+ _headers,
+ _page_size,
+ limit,
+ page,
+ acc,
+ _opts
+ )
+ when page > @comment_max_pages do
+ Enum.take(acc, limit)
+ end
+
+ defp fetch_post_comment_pages(api_url_template, headers, page_size, limit, page, acc, opts) do
+ api_url = api_url_template.(page_size, page)
+
+ case safe_request_lemmy_api(
+ :get,
+ api_url,
+ headers,
+ nil,
+ Keyword.merge([receive_timeout: 10_000], opts)
+ ) do
+ {:ok, %Finch.Response{status: 200, body: body}} ->
+ case decode_lemmy_comment_views(body) do
+ {:ok, comments} ->
+ updated_acc = acc ++ comments
+
+ cond do
+ comments == [] ->
+ Enum.take(updated_acc, limit)
+
+ length(updated_acc) >= limit ->
+ Enum.take(updated_acc, limit)
+
+ length(comments) < page_size ->
+ updated_acc
+
+ true ->
+ fetch_post_comment_pages(
+ api_url_template,
+ headers,
+ page_size,
+ limit,
+ page + 1,
+ updated_acc,
+ opts
+ )
+ end
+
+ :error ->
+ Enum.take(acc, limit)
+ end
+
+ _ ->
+ Enum.take(acc, limit)
+ end
+ end
+
+ defp decode_lemmy_comment_views(body) when is_binary(body) do
+ case Jason.decode(body) do
+ {:ok, %{"comments" => comments}} when is_list(comments) -> {:ok, comments}
+ _ -> :error
+ end
+ end
+
+ defp decode_lemmy_comment_views(_), do: :error
+
+ defp lemmy_comment_views_to_ap(comments, post_url) when is_list(comments) do
+ comment_id_to_ap =
+ comments
+ |> Enum.reduce(%{}, fn comment_view, acc ->
+ comment = comment_view["comment"] || %{}
+
+ case {comment["id"], comment["ap_id"]} do
+ {id, ap_id} when is_integer(id) and is_binary(ap_id) ->
+ Map.put(acc, Integer.to_string(id), ap_id)
+
+ {id, ap_id} when is_binary(id) and is_binary(ap_id) ->
+ Map.put(acc, id, ap_id)
+
+ _ ->
+ acc
+ end
+ end)
+
+ comments
+ |> Enum.reject(&lemmy_comment_hidden?/1)
+ |> Enum.map(&lemmy_comment_to_ap(&1, comment_id_to_ap, post_url))
+ |> Enum.reject(&is_nil/1)
+ end
+
+ defp lemmy_comment_views_to_ap(_, _), do: []
+
+ defp normalize_page_size(value) when is_integer(value),
+ do: value |> max(1) |> min(@comment_page_size)
+
+ defp normalize_page_size(_), do: @comment_page_size
+
+ defp extract_domain(url), do: Elektrine.TextHelpers.extract_domain_from_url(url)
+
+ defp lemmy_comment_to_ap(comment_view, comment_id_to_ap, post_url) when is_map(comment_view) do
+ comment = comment_view["comment"] || %{}
+ creator = comment_view["creator"] || %{}
+ counts = comment_view["counts"] || %{}
+
+ ap_id = comment["ap_id"]
+ actor_id = creator["actor_id"]
+
+ if is_binary(ap_id) && is_binary(actor_id) do
+ %{
+ "id" => ap_id,
+ "type" => "Note",
+ "content" => comment["content"] || "",
+ "published" => comment["published"],
+ "attributedTo" => actor_id,
+ "inReplyTo" => parse_lemmy_parent_ref(comment, comment_id_to_ap, post_url),
+ "likes" => %{"totalItems" => counts["upvotes"] || 0},
+ "replies" => %{"totalItems" => counts["child_count"] || 0},
+ "shares" => %{"totalItems" => 0},
+ "upvotes" => counts["upvotes"] || 0,
+ "downvotes" => counts["downvotes"] || 0,
+ "score" => counts["score"] || 0,
+ "_lemmy" => %{
+ "creator_name" => creator["name"],
+ "creator_avatar" => creator["avatar"],
+ "path" => comment["path"],
+ "score" => counts["score"] || 0,
+ "upvotes" => counts["upvotes"] || 0,
+ "downvotes" => counts["downvotes"] || 0,
+ "child_count" => counts["child_count"] || 0
+ },
+ "sensitive" => false
+ }
+ end
+ end
+
+ defp lemmy_comment_to_ap(_, _, _), do: nil
+
+ defp parse_lemmy_parent_ref(comment, comment_id_to_ap, post_url) when is_map(comment) do
+ path = comment["path"]
+
+ case path do
+ value when is_binary(value) ->
+ segments = String.split(value, ".")
+
+ case Enum.reverse(segments) do
+ [_self, parent_id | _] when is_binary(parent_id) and parent_id != "" ->
+ Map.get(comment_id_to_ap, parent_id, post_url)
+
+ _ ->
+ post_url
+ end
+
+ _ ->
+ post_url
+ end
+ end
+
+ defp parse_lemmy_parent_ref(_, _, post_url), do: post_url
+
+ # Check if a post is from a Lemmy-compatible community platform.
+ defp lemmy_post?(post) do
+ activitypub_id = get_activitypub_id(post)
+ activitypub_url = get_activitypub_url(post)
+ community_uri = get_community_actor_uri(post)
+
+ (is_binary(activitypub_id) && community_post_url?(activitypub_id)) ||
+ (is_binary(activitypub_url) && community_post_url?(activitypub_url)) ||
+ is_binary(community_uri)
+ end
+
+ defp get_activitypub_id(%{activitypub_id: id}) when is_binary(id), do: id
+ defp get_activitypub_id(%{"id" => id}) when is_binary(id), do: id
+ defp get_activitypub_id(_), do: nil
+
+ defp get_activitypub_url(%{activitypub_url: url}) when is_binary(url), do: url
+ defp get_activitypub_url(%{"url" => url}) when is_binary(url), do: url
+ defp get_activitypub_url(_), do: nil
+
+ defp get_community_actor_uri(%{media_metadata: metadata}) when is_map(metadata) do
+ metadata["community_actor_uri"]
+ end
+
+ defp get_community_actor_uri(%{"media_metadata" => metadata}) when is_map(metadata) do
+ metadata["community_actor_uri"]
+ end
+
+ defp get_community_actor_uri(_), do: nil
+
+ @doc """
+ Returns true when the URL matches a Lemmy/PieFed/Mbin community post pattern.
+
+ This intentionally requires a numeric post id for `/post/` so URLs like
+ `bsky.app/profile/.../post/` are not treated as Lemmy posts.
+ """
+ def community_post_url?(url) when is_binary(url) do
+ Regex.match?(~r{/post/\d+(?:$|[/?#])}, url) ||
+ Regex.match?(~r{/c/[^/]+/p/\d+(?:$|[/?#])}, url) ||
+ Regex.match?(~r{/m/[^/]+/[pt]/\d+(?:$|[/?#])}, url)
+ end
+
+ def community_post_url?(_), do: false
+
+ defp resolve_post_reference(post_url) do
+ case Regex.run(~r{https?://([^/]+)/post/(\d+)}, post_url) do
+ [_, domain, post_id] ->
+ {:ok, domain, post_id}
+
+ _ ->
+ case Regex.run(~r{https?://([^/]+)/c/[^/]+/p/(\d+)}, post_url) do
+ [_, domain, post_id] ->
+ {:ok, domain, post_id}
+
+ _ ->
+ case Regex.run(~r{https?://([^/]+)/m/[^/]+/[pt]/(\d+)}, post_url) do
+ [_, domain, post_id] ->
+ {:ok, domain, post_id}
+
+ _ ->
+ resolve_post_reference_via_api(post_url)
+ end
+ end
+ end
+ end
+
+ defp resolve_post_reference_via_api(post_url) do
+ case URI.parse(post_url) do
+ %URI{host: domain} when is_binary(domain) ->
+ resolve_url = "https://#{domain}/api/v4/resolve_object?q=#{URI.encode_www_form(post_url)}"
+
+ case safe_request_lemmy_api(:get, resolve_url, [{"Accept", "application/json"}], nil,
+ receive_timeout: 10_000
+ ) do
+ {:ok, %Finch.Response{status: 200, body: body}} ->
+ case Jason.decode(body) do
+ {:ok, %{"post" => %{"post" => %{"id" => post_id}}}} when is_integer(post_id) ->
+ {:ok, domain, Integer.to_string(post_id)}
+
+ {:ok, %{"post" => %{"post" => %{"id" => post_id}}}} when is_binary(post_id) ->
+ {:ok, domain, post_id}
+
+ _ ->
+ :error
+ end
+
+ _ ->
+ :error
+ end
+
+ _ ->
+ :error
+ end
+ end
+
+ defp parse_count(value) when is_integer(value), do: max(value, 0)
+
+ defp parse_count(value) when is_binary(value) do
+ case Integer.parse(String.trim(value)) do
+ {parsed, _} -> max(parsed, 0)
+ :error -> 0
+ end
+ end
+
+ defp parse_count(_), do: 0
+
+ defp extract_collection_total(%{"totalItems" => total}), do: parse_count(total)
+ defp extract_collection_total(%{totalItems: total}), do: parse_count(total)
+
+ defp extract_collection_total(total) when is_integer(total) or is_binary(total),
+ do: parse_count(total)
+
+ defp extract_collection_total(_), do: 0
+
+ defp fetch_post_counts_from_html(post_url) when is_binary(post_url) do
+ headers = [{"Accept", "text/html,application/xhtml+xml"}, {"User-Agent", "Elektrine/1.0"}]
+
+ case safe_request(:get, post_url, headers, nil, receive_timeout: 5_000) do
+ {:ok, %Finch.Response{status: 200, body: body}} ->
+ parse_post_counts_from_html(body)
+
+ _ ->
+ nil
+ end
+ end
+
+ defp parse_post_counts_from_html(body) when is_binary(body) do
+ score = extract_named_capture_integer(body, ~r/class="score"[^>]*>(?[^<]+))
+
+ comments =
+ extract_named_capture_integer(body, ~r/]*>\s*(?\d+)\s+Comments\s*<\/h2>/i) ||
+ extract_named_capture_integer(body, ~r/post_reply_count_text">\s*(?\d+)\s*]*title="(?\d+)\s*,\s*\d+"/)
+
+ downvotes =
+ extract_named_capture_integer(body, ~r/class="score"[^>]*title="\d+\s*,\s*(?\d+)"/)
+
+ if is_integer(score) or is_integer(comments) do
+ %{
+ upvotes: upvotes || max(score || 0, 0),
+ downvotes: downvotes || 0,
+ score: score || 0,
+ comments: comments || 0
+ }
+ else
+ nil
+ end
+ end
+
+ defp parse_post_counts_from_html(_), do: nil
+
+ defp extract_named_capture_integer(body, regex) when is_binary(body) do
+ case Regex.named_captures(regex, body) do
+ %{"value" => value} ->
+ case Integer.parse(String.trim(value)) do
+ {parsed, _} -> parsed
+ :error -> nil
+ end
+
+ _ ->
+ nil
+ end
+ end
+
+ defp safe_request(method, url, headers, body, opts) do
+ case Keyword.get(opts, :request_fun) do
+ request_fun when is_function(request_fun, 5) ->
+ request_fun.(method, url, headers, body, opts)
+
+ _ ->
+ request = Finch.build(method, url, headers, body || "")
+ SafeFetch.request(request, Elektrine.Finch, opts)
+ end
+ end
+
+ defp safe_request_lemmy_api(method, url, headers, body, opts) do
+ case safe_request(method, url, headers, body, opts) do
+ {:ok, %Finch.Response{status: 404}} ->
+ url
+ |> fallback_lemmy_api_url()
+ |> case do
+ nil -> {:ok, %Finch.Response{status: 404, headers: [], body: ""}}
+ fallback_url -> safe_request(method, fallback_url, headers, body, opts)
+ end
+
+ other ->
+ other
+ end
+ end
+
+ defp fallback_lemmy_api_url(url) when is_binary(url) do
+ if String.contains?(url, "/api/v4/") do
+ String.replace(url, "/api/v4/", "/api/v3/", global: false)
+ else
+ nil
+ end
+ end
+
+ defp fallback_lemmy_api_url(_), do: nil
+end
diff --git a/apps/elektrine/lib/elektrine/activitypub/lemmy_cache.ex b/apps/elektrine/lib/elektrine/activitypub/lemmy_cache.ex
new file mode 100644
index 0000000..fb3df2e
--- /dev/null
+++ b/apps/elektrine/lib/elektrine/activitypub/lemmy_cache.ex
@@ -0,0 +1,173 @@
+defmodule Elektrine.ActivityPub.LemmyCache do
+ @moduledoc """
+ Cache for Lemmy post counts and top comments.
+ Stores data in database to avoid slow API calls on page load.
+ """
+
+ use Ecto.Schema
+ import Ecto.Query
+ alias Elektrine.ActivityPub.LemmyApi
+ alias Elektrine.Repo
+
+ @cache_ttl_minutes 15
+
+ schema "lemmy_counts_cache" do
+ field :activitypub_id, :string
+ field :upvotes, :integer, default: 0
+ field :downvotes, :integer, default: 0
+ field :score, :integer, default: 0
+ field :comments, :integer, default: 0
+ field :top_comments, {:array, :map}, default: []
+ field :fetched_at, :utc_datetime
+
+ timestamps()
+ end
+
+ def changeset(cache, attrs) do
+ cache
+ |> Ecto.Changeset.cast(attrs, [
+ :activitypub_id,
+ :upvotes,
+ :downvotes,
+ :score,
+ :comments,
+ :top_comments,
+ :fetched_at
+ ])
+ |> Ecto.Changeset.validate_required([:activitypub_id, :fetched_at])
+ end
+
+ @doc """
+ Get cached counts for multiple posts.
+ Returns a map of activitypub_id => %{upvotes, downvotes, score, comments}
+ """
+ def get_cached_counts(activitypub_ids) when is_list(activitypub_ids) do
+ from(c in __MODULE__,
+ where: c.activitypub_id in ^activitypub_ids,
+ select:
+ {c.activitypub_id,
+ %{
+ upvotes: c.upvotes,
+ downvotes: c.downvotes,
+ score: c.score,
+ comments: c.comments
+ }}
+ )
+ |> Repo.all()
+ |> Map.new()
+ end
+
+ @doc """
+ Get both counts and comments for posts.
+ Returns {counts_map, comments_map}
+ """
+ def get_cached_data(activitypub_ids) when is_list(activitypub_ids) do
+ results =
+ from(c in __MODULE__,
+ where: c.activitypub_id in ^activitypub_ids,
+ select: c
+ )
+ |> Repo.all()
+
+ counts =
+ results
+ |> Enum.map(fn c ->
+ {c.activitypub_id,
+ %{
+ upvotes: c.upvotes,
+ downvotes: c.downvotes,
+ score: c.score,
+ comments: c.comments
+ }}
+ end)
+ |> Map.new()
+
+ comments =
+ results
+ |> Enum.filter(fn c -> c.top_comments != [] end)
+ |> Enum.map(fn c -> {c.activitypub_id, c.top_comments} end)
+ |> Map.new()
+
+ {counts, comments}
+ end
+
+ @doc """
+ Check which posts need their cache refreshed (stale or missing).
+ Returns list of activitypub_ids that need updating.
+ """
+ def get_stale_ids(activitypub_ids) when is_list(activitypub_ids) do
+ cutoff = DateTime.add(DateTime.utc_now(), -@cache_ttl_minutes, :minute)
+
+ cached_fresh =
+ from(c in __MODULE__,
+ where: c.activitypub_id in ^activitypub_ids and c.fetched_at > ^cutoff,
+ select: c.activitypub_id
+ )
+ |> Repo.all()
+ |> MapSet.new()
+
+ activitypub_ids
+ |> Enum.filter(fn id -> not MapSet.member?(cached_fresh, id) end)
+ end
+
+ @doc """
+ Update cache for a single post. Called by background worker.
+ """
+ def refresh_cache(activitypub_id) when is_binary(activitypub_id) do
+ # Only fetch if it's a Lemmy post
+ if LemmyApi.community_post_url?(activitypub_id) do
+ counts = LemmyApi.fetch_post_counts(activitypub_id) || %{}
+ top_comments = LemmyApi.fetch_top_comments(activitypub_id, 3) || []
+
+ attrs = %{
+ activitypub_id: activitypub_id,
+ upvotes: counts[:upvotes] || 0,
+ downvotes: counts[:downvotes] || 0,
+ score: counts[:score] || 0,
+ comments: counts[:comments] || 0,
+ top_comments: top_comments,
+ fetched_at: DateTime.utc_now() |> DateTime.truncate(:second)
+ }
+
+ %__MODULE__{}
+ |> changeset(attrs)
+ |> Repo.insert(
+ on_conflict:
+ {:replace,
+ [:upvotes, :downvotes, :score, :comments, :top_comments, :fetched_at, :updated_at]},
+ conflict_target: :activitypub_id
+ )
+ else
+ {:ok, nil}
+ end
+ end
+
+ @doc """
+ Schedule background refresh for posts that need updating.
+ """
+ def schedule_refresh(activitypub_ids) when is_list(activitypub_ids) do
+ stale_ids = get_stale_ids(activitypub_ids)
+
+ # Only schedule if there are stale posts
+ if stale_ids != [] do
+ # Schedule one job that handles all stale posts
+ %{activitypub_ids: stale_ids}
+ |> Elektrine.ActivityPub.LemmyCacheWorker.new()
+ |> Elektrine.JobQueue.insert()
+ else
+ {:ok, :no_stale}
+ end
+ end
+
+ @doc """
+ Clean up old cache entries (older than 24 hours).
+ """
+ def cleanup_old_entries do
+ cutoff = DateTime.add(DateTime.utc_now(), -24, :hour)
+
+ from(c in __MODULE__,
+ where: c.fetched_at < ^cutoff
+ )
+ |> Repo.delete_all()
+ end
+end
diff --git a/apps/elektrine/lib/elektrine/activitypub/lemmy_cache_worker.ex b/apps/elektrine/lib/elektrine/activitypub/lemmy_cache_worker.ex
new file mode 100644
index 0000000..a1dde79
--- /dev/null
+++ b/apps/elektrine/lib/elektrine/activitypub/lemmy_cache_worker.ex
@@ -0,0 +1,43 @@
+defmodule Elektrine.ActivityPub.LemmyCacheWorker do
+ @moduledoc """
+ Oban worker for refreshing Lemmy post counts cache in background.
+ """
+
+ use Oban.Worker,
+ queue: :default,
+ max_attempts: 2
+
+ require Logger
+
+ alias Elektrine.ActivityPub.LemmyCache
+
+ @impl Oban.Worker
+ def perform(%Oban.Job{args: %{"activitypub_ids" => activitypub_ids}}) do
+ Logger.debug("LemmyCacheWorker: Refreshing #{length(activitypub_ids)} posts")
+
+ # Process in parallel but with limited concurrency
+ activitypub_ids
+ |> Task.async_stream(
+ fn id -> LemmyCache.refresh_cache(id) end,
+ max_concurrency: 5,
+ timeout: 10_000,
+ on_timeout: :kill_task
+ )
+ |> Enum.each(fn
+ {:ok, {:ok, _}} -> :ok
+ {:ok, {:error, reason}} -> Logger.warning("LemmyCache refresh failed: #{inspect(reason)}")
+ {:exit, :timeout} -> Logger.warning("LemmyCache refresh timed out")
+ _ -> :ok
+ end)
+
+ :ok
+ end
+
+ def perform(%Oban.Job{args: %{"activitypub_id" => activitypub_id}}) do
+ # Single post refresh
+ case LemmyCache.refresh_cache(activitypub_id) do
+ {:ok, _} -> :ok
+ {:error, reason} -> {:error, reason}
+ end
+ end
+end
diff --git a/apps/elektrine/lib/elektrine/activitypub/lemmy_comment_backfill.ex b/apps/elektrine/lib/elektrine/activitypub/lemmy_comment_backfill.ex
new file mode 100644
index 0000000..337fb57
--- /dev/null
+++ b/apps/elektrine/lib/elektrine/activitypub/lemmy_comment_backfill.ex
@@ -0,0 +1,168 @@
+defmodule Elektrine.ActivityPub.LemmyCommentBackfill do
+ @moduledoc false
+
+ import Ecto.Query
+
+ alias Elektrine.ActivityPub.LemmyApi
+ alias Elektrine.Repo
+ alias Elektrine.Social.Message
+ alias Elektrine.Social.Messages
+
+ @spec run(keyword()) :: %{posts: non_neg_integer(), comments: non_neg_integer()}
+ def run(opts \\ []) do
+ dry_run? = Keyword.get(opts, :dry_run, false)
+
+ lemmy_posts = list_lemmy_posts()
+
+ remote_comments_updated =
+ Enum.reduce(lemmy_posts, 0, fn post, acc ->
+ acc + backfill_post_comments(post, dry_run?: dry_run?)
+ end)
+
+ fallback_comments_updated = backfill_upvotes_from_like_count(dry_run?: dry_run?)
+
+ %{
+ posts: length(lemmy_posts),
+ comments: remote_comments_updated + fallback_comments_updated,
+ remote_comments: remote_comments_updated,
+ fallback_comments: fallback_comments_updated
+ }
+ end
+
+ @spec backfill_post_comments(Message.t(), keyword()) :: non_neg_integer()
+ def backfill_post_comments(%Message{} = post, opts \\ []) do
+ dry_run? = Keyword.get(opts, :dry_run, false)
+ fetcher = Keyword.get(opts, :fetcher, &LemmyApi.fetch_comment_counts/1)
+
+ post
+ |> lemmy_post_ref()
+ |> fetcher.()
+ |> apply_comment_counts(dry_run?: dry_run?)
+ end
+
+ @spec apply_comment_counts(map(), keyword()) :: non_neg_integer()
+ def apply_comment_counts(comment_counts, opts \\ [])
+
+ def apply_comment_counts(comment_counts, opts) when is_map(comment_counts) do
+ dry_run? = Keyword.get(opts, :dry_run, false)
+
+ ap_ids = Map.keys(comment_counts)
+
+ if ap_ids == [] do
+ 0
+ else
+ Message
+ |> where([m], m.activitypub_id in ^ap_ids)
+ |> Repo.all()
+ |> Enum.reduce(0, fn message, acc ->
+ counts = Map.get(comment_counts, message.activitypub_id, %{})
+
+ if comment_counts_changed?(message, counts) do
+ unless dry_run? do
+ persist_comment_counts(message, counts)
+ end
+
+ acc + 1
+ else
+ acc
+ end
+ end)
+ end
+ end
+
+ def apply_comment_counts(_, _), do: 0
+
+ @spec backfill_upvotes_from_like_count(keyword()) :: non_neg_integer()
+ def backfill_upvotes_from_like_count(opts \\ []) do
+ dry_run? = Keyword.get(opts, :dry_run, false)
+
+ Message
+ |> where(
+ [m],
+ fragment("? LIKE '%/comment/%'", m.activitypub_id) or
+ fragment("? LIKE '%/comment/%'", m.activitypub_url)
+ )
+ |> where([m], m.upvotes == 0 and m.like_count > 0)
+ |> Repo.all()
+ |> Enum.reduce(0, fn message, acc ->
+ unless dry_run? do
+ Repo.update_all(
+ from(m in Message, where: m.id == ^message.id),
+ set: [
+ upvotes: message.like_count,
+ updated_at: DateTime.utc_now() |> DateTime.truncate(:second)
+ ]
+ )
+
+ Messages.broadcast_post_counts_updated(message.id, %{
+ like_count: message.like_count,
+ reply_count: message.reply_count || 0,
+ share_count: message.share_count || 0
+ })
+ end
+
+ acc + 1
+ end)
+ end
+
+ defp list_lemmy_posts do
+ Message
+ |> join(:inner, [p], r in Message, on: r.reply_to_id == p.id)
+ |> where([p, _r], is_nil(p.reply_to_id))
+ |> where([p, _r], not is_nil(p.activitypub_id))
+ |> distinct([p, _r], true)
+ |> select([p, _r], p)
+ |> Repo.all()
+ |> Enum.filter(fn post ->
+ LemmyApi.community_post_url?(post.activitypub_id || post.activitypub_url)
+ end)
+ end
+
+ defp lemmy_post_ref(%Message{activitypub_id: activitypub_id}) when is_binary(activitypub_id),
+ do: activitypub_id
+
+ defp lemmy_post_ref(%Message{activitypub_url: activitypub_url}) when is_binary(activitypub_url),
+ do: activitypub_url
+
+ defp lemmy_post_ref(_), do: nil
+
+ defp comment_counts_changed?(message, counts) do
+ message.like_count != normalized_count(counts, :upvotes) ||
+ message.upvotes != normalized_count(counts, :upvotes) ||
+ message.downvotes != normalized_count(counts, :downvotes) ||
+ message.score != normalized_count(counts, :score) ||
+ message.reply_count != normalized_count(counts, :child_count)
+ end
+
+ defp persist_comment_counts(message, counts) do
+ updated_counts = %{
+ like_count: normalized_count(counts, :upvotes),
+ upvotes: normalized_count(counts, :upvotes),
+ downvotes: normalized_count(counts, :downvotes),
+ score: normalized_count(counts, :score),
+ reply_count: normalized_count(counts, :child_count),
+ share_count: message.share_count || 0
+ }
+
+ Repo.update_all(
+ from(m in Message, where: m.id == ^message.id),
+ set: [
+ like_count: updated_counts.like_count,
+ upvotes: updated_counts.upvotes,
+ downvotes: updated_counts.downvotes,
+ score: updated_counts.score,
+ reply_count: updated_counts.reply_count,
+ updated_at: DateTime.utc_now() |> DateTime.truncate(:second)
+ ]
+ )
+
+ Messages.broadcast_post_counts_updated(message.id, updated_counts)
+ end
+
+ defp normalized_count(counts, key) do
+ case Map.get(counts, key, 0) do
+ value when is_integer(value) -> max(value, 0)
+ _ -> 0
+ end
+ end
+end
diff --git a/apps/elektrine/lib/elektrine/activitypub/local_references.ex b/apps/elektrine/lib/elektrine/activitypub/local_references.ex
new file mode 100644
index 0000000..3412dbf
--- /dev/null
+++ b/apps/elektrine/lib/elektrine/activitypub/local_references.ex
@@ -0,0 +1,272 @@
+defmodule Elektrine.ActivityPub.LocalReferences do
+ @moduledoc false
+
+ import Ecto.Query, warn: false
+
+ alias Elektrine.Accounts.User
+ alias Elektrine.ActivityPub.Activity
+ alias Elektrine.Domains
+ alias Elektrine.Repo
+
+ @public_audience_uri "https://www.w3.org/ns/activitystreams#Public"
+
+ def actor_prefixes do
+ domains = Domains.activitypub_domains()
+
+ urls =
+ [instance_url()] ++
+ Enum.flat_map(domains, fn domain -> ["https://#{domain}", "http://#{domain}"] end)
+
+ urls
+ |> Enum.uniq()
+ |> Enum.map(&(String.trim_trailing(&1, "/") <> "/users/"))
+ end
+
+ def local_username_from_uri(uri) when is_binary(uri) do
+ normalized_uri = String.trim(uri)
+
+ if Elektrine.Strings.present?(normalized_uri) do
+ case URI.parse(normalized_uri) do
+ %URI{host: host, path: path} when is_binary(host) and is_binary(path) ->
+ if Domains.local_activitypub_domain?(String.downcase(host)) do
+ username_from_local_path(path)
+ else
+ {:error, :not_local}
+ end
+
+ _ ->
+ {:error, :invalid_uri}
+ end
+ else
+ {:error, :invalid_uri}
+ end
+ end
+
+ def local_username_from_uri(_), do: {:error, :invalid_uri}
+
+ def resolve_target_user(activity) when is_map(activity) do
+ case resolve_target_user_id(activity) do
+ user_id when is_integer(user_id) -> Repo.get(User, user_id)
+ _ -> nil
+ end
+ end
+
+ def resolve_target_user(_), do: nil
+
+ def resolve_target_user_id(activity) when is_map(activity) do
+ direct_ref =
+ activity
+ |> candidate_target_refs()
+ |> Enum.find_value(&target_user_id_from_ref/1)
+
+ direct_ref || inferred_recipient_user_id(activity)
+ end
+
+ def resolve_target_user_id(_), do: nil
+
+ defp username_from_local_path(path) do
+ case extract_local_identifier_from_path(path) do
+ nil ->
+ {:error, :not_local}
+
+ identifier ->
+ case Elektrine.Accounts.get_user_by_activitypub_identifier(identifier) do
+ %User{username: username} -> {:ok, username}
+ _ -> {:ok, actor_identifier(identifier)}
+ end
+ end
+ end
+
+ defp extract_local_identifier_from_path(path) when is_binary(path) do
+ case path |> String.trim_leading("/") |> String.split("/", trim: true) do
+ ["users", identifier | _] ->
+ if Elektrine.Strings.present?(identifier), do: identifier, else: nil
+
+ [<<"@", identifier::binary>> | _] ->
+ if Elektrine.Strings.present?(identifier), do: identifier, else: nil
+
+ _ ->
+ nil
+ end
+ end
+
+ defp inferred_recipient_user_id(%{"type" => type} = activity)
+ when type in ["Follow", "Accept", "Reject", "Block", "Flag", "Move"] do
+ single_recipient_user_id(activity)
+ end
+
+ defp inferred_recipient_user_id(_), do: nil
+
+ defp candidate_target_refs(activity) when is_map(activity) do
+ []
+ |> add_candidate_ref(Map.get(activity, "object"))
+ |> add_candidate_ref(Map.get(activity, "target"))
+ |> Enum.reverse()
+ end
+
+ defp add_candidate_ref(acc, nil), do: acc
+ defp add_candidate_ref(acc, value) when is_binary(value), do: [value | acc]
+
+ defp add_candidate_ref(acc, values) when is_list(values) do
+ Enum.reduce(values, acc, fn value, nested_acc -> add_candidate_ref(nested_acc, value) end)
+ end
+
+ defp add_candidate_ref(acc, %{} = value) do
+ acc
+ |> add_candidate_ref(Map.get(value, "id"))
+ |> add_candidate_ref(Map.get(value, "object"))
+ |> add_candidate_ref(Map.get(value, "target"))
+ |> add_candidate_ref(Map.get(value, "inReplyTo"))
+ |> add_candidate_ref(Map.get(value, "url"))
+ |> add_candidate_ref(Map.get(value, "href"))
+ end
+
+ defp add_candidate_ref(acc, _value), do: acc
+
+ defp single_recipient_user_id(activity) do
+ recipient_ids =
+ activity
+ |> recipient_refs()
+ |> Enum.map(&local_user_id_from_uri/1)
+ |> Enum.reject(&is_nil/1)
+ |> Enum.uniq()
+
+ case recipient_ids do
+ [user_id] -> user_id
+ _ -> nil
+ end
+ end
+
+ defp recipient_refs(activity) when is_map(activity) do
+ activity_object =
+ case Map.get(activity, "object") do
+ %{} = object -> object
+ _ -> %{}
+ end
+
+ mention_refs =
+ if public_activity?(activity, activity_object) do
+ []
+ else
+ mention_hrefs(activity_object)
+ end
+
+ [
+ Map.get(activity, "to"),
+ Map.get(activity, "cc"),
+ Map.get(activity, "audience"),
+ Map.get(activity, "target"),
+ Map.get(activity_object, "to"),
+ Map.get(activity_object, "cc"),
+ Map.get(activity_object, "audience"),
+ mention_refs
+ ]
+ |> Enum.flat_map(&recipient_values/1)
+ end
+
+ defp recipient_refs(_), do: []
+
+ defp recipient_values(nil), do: []
+ defp recipient_values(value) when is_binary(value), do: [value]
+
+ defp recipient_values(values) when is_list(values) do
+ Enum.flat_map(values, &recipient_values/1)
+ end
+
+ defp recipient_values(%{} = value) do
+ [Map.get(value, "id"), Map.get(value, "href"), Map.get(value, "url")]
+ |> Enum.map(&Elektrine.Strings.present/1)
+ |> Enum.reject(&is_nil/1)
+ end
+
+ defp recipient_values(_), do: []
+
+ defp mention_hrefs(%{"tag" => tags}) when is_list(tags) do
+ tags
+ |> Enum.filter(&(Map.get(&1, "type") == "Mention"))
+ |> Enum.map(&Map.get(&1, "href"))
+ |> Enum.map(&Elektrine.Strings.present/1)
+ |> Enum.reject(&is_nil/1)
+ end
+
+ defp mention_hrefs(_), do: []
+
+ defp public_activity?(activity, activity_object)
+ when is_map(activity) and is_map(activity_object) do
+ [
+ Map.get(activity, "to"),
+ Map.get(activity, "cc"),
+ Map.get(activity, "audience"),
+ Map.get(activity_object, "to"),
+ Map.get(activity_object, "cc"),
+ Map.get(activity_object, "audience")
+ ]
+ |> Enum.flat_map(&recipient_values/1)
+ |> Enum.any?(&(&1 == @public_audience_uri))
+ end
+
+ defp public_activity?(_, _), do: false
+
+ defp target_user_id_from_ref(ref) when is_binary(ref) do
+ local_user_id_from_activity(ref) ||
+ local_user_id_from_uri(ref) ||
+ local_user_id_from_message(ref)
+ end
+
+ defp target_user_id_from_ref(_), do: nil
+
+ defp local_user_id_from_activity(activity_id) when is_binary(activity_id) do
+ case get_activity_by_id(activity_id) do
+ %Activity{internal_user_id: user_id} when is_integer(user_id) -> user_id
+ _ -> nil
+ end
+ end
+
+ defp local_user_id_from_uri(uri) when is_binary(uri) do
+ case local_username_from_uri(uri) do
+ {:ok, username} ->
+ case Elektrine.Accounts.get_user_by_username(username) do
+ %User{id: user_id} -> user_id
+ _ -> nil
+ end
+
+ _ ->
+ nil
+ end
+ end
+
+ defp local_user_id_from_uri(_), do: nil
+
+ defp local_user_id_from_message(ref) when is_binary(ref) do
+ case Elektrine.Messaging.get_message_by_activitypub_ref(ref) do
+ %{sender_id: user_id} when is_integer(user_id) -> user_id
+ _ -> nil
+ end
+ end
+
+ defp get_activity_by_id(activity_id) do
+ from(a in Activity, where: a.activity_id == ^activity_id)
+ |> Repo.one()
+ end
+
+ defp instance_url do
+ Domains.inferred_base_url_for_domain(Domains.instance_domain())
+ end
+
+ defp actor_identifier(%User{handle: handle, username: username}) do
+ if Elektrine.Strings.present?(handle), do: handle, else: username
+ end
+
+ defp actor_identifier(%{handle: handle, username: username}) do
+ if Elektrine.Strings.present?(handle), do: handle, else: username
+ end
+
+ defp actor_identifier(identifier) when is_binary(identifier) do
+ identifier
+ |> String.trim()
+ |> String.trim_leading("@")
+ |> String.downcase()
+ end
+
+ defp actor_identifier(_), do: nil
+end
diff --git a/apps/elektrine/lib/elektrine/activitypub/mastodon_api.ex b/apps/elektrine/lib/elektrine/activitypub/mastodon_api.ex
new file mode 100644
index 0000000..3511c1e
--- /dev/null
+++ b/apps/elektrine/lib/elektrine/activitypub/mastodon_api.ex
@@ -0,0 +1,944 @@
+defmodule Elektrine.ActivityPub.MastodonApi do
+ @moduledoc """
+ Helper module for fetching data from Mastodon-compatible instances via their API.
+
+ Supports Mastodon, Pleroma, Akkoma, Misskey (partial), and other compatible servers.
+ Uses the Mastodon API v1 endpoints which are widely supported across the fediverse.
+ """
+
+ alias Elektrine.Domains
+ alias Elektrine.HTTP.SafeFetch
+
+ @doc """
+ Fetch status counts (favourites, reblogs, replies) from a Mastodon-compatible instance.
+ Returns nil if the status is not from a compatible instance or if the fetch fails.
+
+ ## Examples
+
+ iex> fetch_status_counts("https://mastodon.social/users/user/statuses/123456789")
+ %{favourites_count: 42, reblogs_count: 10, replies_count: 5}
+
+ iex> fetch_status_counts("https://example.com/not-a-status")
+ nil
+ """
+ def fetch_status_counts(status_url, opts \\ [])
+
+ def fetch_status_counts(status_url, opts) when is_binary(status_url) and is_list(opts) do
+ case extract_status_info(status_url) do
+ {:ok, domain, status_id} ->
+ fetch_from_api(domain, status_id, opts)
+
+ {:search, domain, status_url} ->
+ fetch_status_counts_via_search(domain, status_url, opts)
+
+ :error ->
+ nil
+ end
+ end
+
+ def fetch_status_counts(_, _), do: nil
+
+ @doc """
+ Fetch counts for a post struct/map, using `activitypub_url` as a fallback when
+ `activitypub_id` is not directly addressable by a Mastodon-compatible API.
+ """
+ def fetch_status_counts_for_post(post, opts \\ []) do
+ case status_count_lookup(post) do
+ {_result_key, status_url} -> fetch_status_counts(status_url, opts)
+ nil -> nil
+ end
+ end
+
+ @doc """
+ Fetch counts for multiple statuses in parallel.
+ Returns a map of activitypub_id => counts.
+ Uses yield_many to avoid blocking on slow/failed requests.
+ """
+ def fetch_statuses_counts(posts) when is_list(posts) do
+ tasks =
+ posts
+ |> Enum.flat_map(fn post ->
+ case status_count_lookup(post) do
+ {result_key, status_url} ->
+ [
+ Task.async(fn ->
+ counts = fetch_status_counts(status_url)
+ {result_key, counts}
+ end)
+ ]
+
+ nil ->
+ []
+ end
+ end)
+
+ # Use yield_many with 5s timeout - accept whatever completes in time
+ results = Task.yield_many(tasks, timeout: 5_000)
+
+ # Shutdown any tasks that didn't complete
+ Enum.each(results, fn {task, result} ->
+ if result == nil, do: Task.shutdown(task, :brutal_kill)
+ end)
+
+ # Collect successful results
+ results
+ |> Enum.flat_map(fn
+ {_task, {:ok, {id, counts}}} when counts != nil -> [{id, counts}]
+ _ -> []
+ end)
+ |> Map.new()
+ end
+
+ @doc """
+ Fetch replies to a status from a Mastodon-compatible instance.
+ Returns a list of reply statuses with their counts.
+ """
+ def fetch_status_context(status_url, opts \\ [])
+
+ def fetch_status_context(status_url, opts) when is_binary(status_url) and is_list(opts) do
+ case extract_status_info(status_url) do
+ {:ok, domain, status_id} ->
+ if misskey_note_url?(status_url) do
+ fetch_misskey_context(domain, status_id, status_url, opts)
+ else
+ fetch_context_from_api(domain, status_id, status_url, opts)
+ end
+
+ {:search, domain, search_url} ->
+ fetch_status_context_via_search(domain, search_url, opts)
+
+ :error ->
+ {:error, :invalid_url}
+ end
+ end
+
+ def fetch_status_context(_, _), do: {:error, :invalid_url}
+
+ @doc """
+ Fetch who favourited a status (likers).
+ Returns a list of account info maps.
+ """
+ def fetch_favourited_by(status_url, limit_or_opts \\ 40, opts \\ [])
+
+ def fetch_favourited_by(status_url, opts, []) when is_list(opts),
+ do: fetch_favourited_by(status_url, 40, opts)
+
+ def fetch_favourited_by(status_url, limit, opts)
+ when is_binary(status_url) and is_integer(limit) and is_list(opts) do
+ if misskey_note_url?(status_url) do
+ case extract_status_info(status_url) do
+ {:ok, domain, note_id} -> fetch_misskey_reactions(domain, note_id, limit, opts)
+ _ -> {:error, :invalid_url}
+ end
+ else
+ case status_endpoint_lookup(status_url, opts) do
+ {:ok, domain, status_id} ->
+ api_url =
+ "https://#{domain}/api/v1/statuses/#{status_id}/favourited_by?limit=#{limit}"
+
+ fetch_account_list(api_url, opts)
+
+ {:error, _reason} ->
+ {:error, :invalid_url}
+ end
+ end
+ end
+
+ def fetch_favourited_by(_, _, _), do: {:error, :invalid_url}
+
+ @doc """
+ Fetch who favourited a post struct/map, using `activitypub_url` as a fallback
+ when the canonical `activitypub_id` needs display/status URL resolution.
+ """
+ def fetch_favourited_by_for_post(post, limit_or_opts \\ 40, opts \\ [])
+
+ def fetch_favourited_by_for_post(post, opts, []) when is_list(opts),
+ do: fetch_favourited_by_for_post(post, 40, opts)
+
+ def fetch_favourited_by_for_post(post, limit, opts)
+ when is_integer(limit) and is_list(opts) do
+ case status_interaction_lookup(post) do
+ {_result_key, status_url} -> fetch_favourited_by(status_url, limit, opts)
+ nil -> {:error, :invalid_url}
+ end
+ end
+
+ @doc """
+ Fetch who reblogged a status (boosters).
+ Returns a list of account info maps.
+ """
+ def fetch_reblogged_by(status_url, limit_or_opts \\ 40, opts \\ [])
+
+ def fetch_reblogged_by(status_url, opts, []) when is_list(opts),
+ do: fetch_reblogged_by(status_url, 40, opts)
+
+ def fetch_reblogged_by(status_url, limit, opts)
+ when is_binary(status_url) and is_integer(limit) and is_list(opts) do
+ if misskey_note_url?(status_url) do
+ case extract_status_info(status_url) do
+ {:ok, domain, note_id} -> fetch_misskey_renotes(domain, note_id, limit, opts)
+ _ -> {:error, :invalid_url}
+ end
+ else
+ case status_endpoint_lookup(status_url, opts) do
+ {:ok, domain, status_id} ->
+ api_url = "https://#{domain}/api/v1/statuses/#{status_id}/reblogged_by?limit=#{limit}"
+ fetch_account_list(api_url, opts)
+
+ {:error, _reason} ->
+ {:error, :invalid_url}
+ end
+ end
+ end
+
+ def fetch_reblogged_by(_, _, _), do: {:error, :invalid_url}
+
+ @doc """
+ Fetch who reblogged a post struct/map, using `activitypub_url` as a fallback
+ when the canonical `activitypub_id` needs display/status URL resolution.
+ """
+ def fetch_reblogged_by_for_post(post, limit_or_opts \\ 40, opts \\ [])
+
+ def fetch_reblogged_by_for_post(post, opts, []) when is_list(opts),
+ do: fetch_reblogged_by_for_post(post, 40, opts)
+
+ def fetch_reblogged_by_for_post(post, limit, opts)
+ when is_integer(limit) and is_list(opts) do
+ case status_interaction_lookup(post) do
+ {_result_key, status_url} -> fetch_reblogged_by(status_url, limit, opts)
+ nil -> {:error, :invalid_url}
+ end
+ end
+
+ @doc """
+ Detect if a post can use the counts API in this module.
+ This includes Misskey note URLs because count lookup falls back to `/api/notes/show`.
+ """
+ def count_api_compatible?(post) do
+ post
+ |> status_url_candidates()
+ |> Enum.any?(&count_api_compatible_url?/1)
+ end
+
+ @doc """
+ Detect if a post can use Mastodon-compatible status endpoints like
+ `/api/v1/statuses/:id`, `/context`, `/favourited_by`, and `/reblogged_by`.
+ """
+ def mastodon_compatible?(post) do
+ post
+ |> status_url_candidates()
+ |> Enum.any?(&mastodon_compatible_url?/1)
+ end
+
+ # Private functions
+
+ defp extract_status_info(url) do
+ cond do
+ # Mastodon/Pleroma/Akkoma/GoToSocial: /users/{user}/statuses/{id}
+ match = Regex.run(~r{https?://([^/]+)/users/[^/]+/statuses/([a-zA-Z0-9_-]+)}, url) ->
+ [_, domain, status_id] = match
+ {:ok, domain, status_id}
+
+ # Mastodon display URL: /@{user}/{id}
+ match = Regex.run(~r{https?://([^/]+)/@[^/]+/([a-zA-Z0-9_-]+)}, url) ->
+ [_, domain, status_id] = match
+ {:ok, domain, status_id}
+
+ # Pleroma/Akkoma display URL: /notice/{id}
+ match = Regex.run(~r{https?://([^/]+)/notice/([a-zA-Z0-9_-]+)(?:$|[/?#])}, url) ->
+ [_, domain, status_id] = match
+ {:ok, domain, status_id}
+
+ # Misskey/Calckey: /notes/{id} - need to use their API
+ match = Regex.run(~r{https?://([^/]+)/notes/([a-zA-Z0-9]+)(?:$|[/?#])}, url) ->
+ [_, domain, note_id] = match
+ {:ok, domain, note_id}
+
+ # Pixelfed: /p/{user}/{id}
+ match = Regex.run(~r{https?://([^/]+)/p/[^/]+/(\d+)$}, url) ->
+ [_, domain, status_id] = match
+ {:ok, domain, status_id}
+
+ # Pleroma/Akkoma/Friendica object URLs need API search to resolve an internal status ID.
+ match = Regex.run(~r{https?://([^/]+)/objects/[a-f0-9-]+$}i, url) ->
+ [_, domain] = match
+ {:search, domain, url}
+
+ true ->
+ :error
+ end
+ end
+
+ defp status_count_lookup(post) do
+ candidates = status_url_candidates(post)
+
+ status_url =
+ Enum.find(candidates, &direct_count_api_url?/1) ||
+ Enum.find(candidates, &count_api_compatible_url?/1)
+
+ if status_url do
+ {get_activitypub_id(post) || status_url, status_url}
+ end
+ end
+
+ defp status_interaction_lookup(post) do
+ status_url =
+ post
+ |> status_url_candidates()
+ |> Enum.find(&(mastodon_compatible_url?(&1) || misskey_note_url?(&1)))
+
+ if status_url do
+ {get_activitypub_id(post) || status_url, status_url}
+ end
+ end
+
+ defp status_url_candidates(post) do
+ [get_activitypub_id(post), get_activitypub_url(post)]
+ |> Enum.filter(&(is_binary(&1) and String.trim(&1) != ""))
+ |> Enum.uniq()
+ end
+
+ defp count_api_compatible_url?(url) when is_binary(url) do
+ cond do
+ # Lemmy posts/comments are handled by LemmyApi, not Mastodon-compatible APIs.
+ String.contains?(url, "/post/") -> false
+ String.contains?(url, "/comment/") -> false
+ direct_count_api_url?(url) -> true
+ # Friendica/Akkoma object URLs may still be resolvable by API search paths.
+ String.match?(url, ~r{/objects/[a-f0-9-]+(?:$|[/?#])}i) -> true
+ true -> false
+ end
+ end
+
+ defp count_api_compatible_url?(_), do: false
+
+ defp mastodon_compatible_url?(url) when is_binary(url) do
+ count_api_compatible_url?(url) && !String.match?(url, ~r{/notes/[a-zA-Z0-9]+(?:$|[/?#])})
+ end
+
+ defp mastodon_compatible_url?(_), do: false
+
+ defp misskey_note_url?(url) when is_binary(url),
+ do: String.match?(url, ~r{/notes/[a-zA-Z0-9]+(?:$|[/?#])})
+
+ defp misskey_note_url?(_), do: false
+
+ defp direct_count_api_url?(url) when is_binary(url) do
+ String.match?(url, ~r{/users/[^/]+/statuses/[a-zA-Z0-9_-]+(?:$|[/?#])}) ||
+ String.match?(url, ~r{/@[^/]+/[a-zA-Z0-9_-]+(?:$|[/?#])}) ||
+ String.match?(url, ~r{/notice/[a-zA-Z0-9_-]+(?:$|[/?#])}) ||
+ String.match?(url, ~r{/notes/[a-zA-Z0-9]+(?:$|[/?#])}) ||
+ String.match?(url, ~r{/p/[^/]+/\d+(?:$|[/?#])})
+ end
+
+ defp direct_count_api_url?(_), do: false
+
+ defp status_endpoint_lookup(status_url, opts) do
+ case extract_status_info(status_url) do
+ {:ok, domain, status_id} ->
+ {:ok, domain, status_id}
+
+ {:search, domain, search_url} ->
+ case fetch_status_via_search(domain, search_url, opts) do
+ {:ok, %{"id" => status_id}} when is_binary(status_id) and status_id != "" ->
+ {:ok, domain, status_id}
+
+ _ ->
+ {:error, :invalid_url}
+ end
+
+ :error ->
+ {:error, :invalid_url}
+ end
+ end
+
+ defp fetch_context_from_api(domain, status_id, root_status_url, opts, root_status \\ nil) do
+ api_url = "https://#{domain}/api/v1/statuses/#{status_id}/context"
+ headers = build_headers()
+
+ case safe_request(:get, api_url, headers, nil, Keyword.merge([receive_timeout: 10_000], opts)) do
+ {:ok, %Finch.Response{status: 200, body: body}} ->
+ case Jason.decode(body) do
+ {:ok, %{"descendants" => descendants} = context} ->
+ ancestors = context["ancestors"] || []
+
+ # Mastodon context returns local status IDs in `in_reply_to_id`. Build a
+ # lookup map so we can expose ActivityPub URIs for reply threading.
+ id_to_uri_map =
+ build_status_id_to_uri_map(
+ status_id,
+ root_status_url,
+ ancestors,
+ descendants,
+ root_status
+ )
+
+ {:ok,
+ Enum.map(descendants, fn status ->
+ parent_id = normalize_status_id(status["in_reply_to_id"])
+
+ %{
+ id: status["id"],
+ uri: status["uri"],
+ url: status["url"],
+ content: status["content"],
+ account: extract_account_info(status["account"]),
+ favourites_count: status["favourites_count"] || 0,
+ reblogs_count: status["reblogs_count"] || 0,
+ replies_count: status["replies_count"] || 0,
+ created_at: status["created_at"],
+ in_reply_to_id: status["in_reply_to_id"],
+ in_reply_to_uri:
+ if(parent_id,
+ do: Map.get(id_to_uri_map, parent_id, root_status_url),
+ else: nil
+ )
+ }
+ end)}
+
+ _ ->
+ {:error, :parse_error}
+ end
+
+ {:ok, %Finch.Response{status: 404}} ->
+ maybe_fetch_status_context_via_search(domain, root_status_url, opts)
+
+ {:ok, %Finch.Response{status: status}} ->
+ {:error, {:http_error, status}}
+
+ {:error, reason} ->
+ {:error, reason}
+ end
+ end
+
+ defp maybe_fetch_status_context_via_search(domain, status_url, opts) do
+ if Keyword.get(opts, :skip_context_search, false) do
+ {:error, {:http_error, 404}}
+ else
+ fetch_status_context_via_search(domain, status_url, opts)
+ end
+ end
+
+ defp fetch_status_context_via_search(domain, status_url, opts) do
+ api_url =
+ "https://#{domain}/api/v2/search?q=#{URI.encode_www_form(status_url)}&type=statuses&resolve=true&limit=1"
+
+ case safe_request(
+ :get,
+ api_url,
+ build_headers(),
+ nil,
+ Keyword.merge([receive_timeout: 15_000], opts)
+ ) do
+ {:ok, %Finch.Response{status: 200, body: body}} ->
+ case Jason.decode(body) do
+ {:ok, %{"statuses" => [%{"id" => status_id} = root_status | _]}} ->
+ root_status_url = root_status["uri"] || root_status["url"] || status_url
+
+ fetch_context_from_api(
+ domain,
+ status_id,
+ root_status_url,
+ Keyword.put(opts, :skip_context_search, true),
+ root_status
+ )
+
+ _ ->
+ {:error, :not_found}
+ end
+
+ {:ok, %Finch.Response{status: status}} ->
+ {:error, {:http_error, status}}
+
+ {:error, reason} ->
+ {:error, reason}
+ end
+ end
+
+ defp build_status_id_to_uri_map(
+ root_status_id,
+ root_status_url,
+ ancestors,
+ descendants,
+ root_status
+ ) do
+ root_id = normalize_status_id(root_status_id)
+
+ statuses =
+ ([%{"id" => root_id, "uri" => root_status_url}] ++
+ List.wrap(root_status) ++
+ ancestors ++
+ descendants)
+ |> Enum.filter(&is_map/1)
+
+ Enum.reduce(statuses, %{}, fn status, acc ->
+ id = normalize_status_id(status["id"])
+ uri = status["uri"] || status["url"]
+
+ if id && is_binary(uri) && uri != "" do
+ Map.put(acc, id, uri)
+ else
+ acc
+ end
+ end)
+ end
+
+ defp normalize_status_id(id) when is_integer(id), do: Integer.to_string(id)
+ defp normalize_status_id(id) when is_binary(id), do: id
+ defp normalize_status_id(_), do: nil
+
+ defp fetch_status_counts_via_search(domain, status_url, opts) do
+ case fetch_status_via_search(domain, status_url, opts) do
+ {:ok, status} -> status_counts(status)
+ {:error, _reason} -> nil
+ end
+ end
+
+ defp fetch_status_via_search(domain, status_url, opts) do
+ api_url =
+ "https://#{domain}/api/v2/search?q=#{URI.encode_www_form(status_url)}&type=statuses&resolve=true&limit=1"
+
+ case safe_request(
+ :get,
+ api_url,
+ build_headers(),
+ nil,
+ Keyword.merge([receive_timeout: 15_000], opts)
+ ) do
+ {:ok, %Finch.Response{status: 200, body: body}} ->
+ parse_search_status_response(body)
+
+ {:ok, %Finch.Response{status: _status}} ->
+ {:error, :not_found}
+
+ {:error, _reason} ->
+ {:error, :request_failed}
+ end
+ end
+
+ defp fetch_from_api(domain, status_id, opts) do
+ # Try Mastodon API first
+ api_url = "https://#{domain}/api/v1/statuses/#{status_id}"
+ headers = build_headers()
+
+ case safe_request(:get, api_url, headers, nil, Keyword.merge([receive_timeout: 5_000], opts)) do
+ {:ok, %Finch.Response{status: 200, body: body}} ->
+ parse_status_response(body)
+
+ {:ok, %Finch.Response{status: 404}} ->
+ # Maybe it's a Misskey instance - try their API
+ try_misskey_api(domain, status_id, opts)
+
+ {:ok, %Finch.Response{status: _status}} ->
+ nil
+
+ {:error, _reason} ->
+ nil
+ end
+ end
+
+ defp try_misskey_api(domain, note_id, opts) do
+ # Misskey uses POST for their API
+ case fetch_misskey_note(domain, note_id, opts) do
+ {:ok, note} -> misskey_status_counts(note)
+ {:error, _reason} -> nil
+ end
+ end
+
+ defp fetch_misskey_note(domain, note_id, opts) do
+ misskey_post(domain, "/api/notes/show", %{"noteId" => note_id}, opts, receive_timeout: 5_000)
+ end
+
+ defp fetch_misskey_reactions(domain, note_id, limit, opts) do
+ case misskey_post(
+ domain,
+ "/api/notes/reactions",
+ %{"noteId" => note_id, "limit" => limit},
+ opts,
+ receive_timeout: 5_000
+ ) do
+ {:ok, reactions} when is_list(reactions) ->
+ {:ok,
+ reactions
+ |> Enum.map(&misskey_reaction_account/1)
+ |> Enum.reject(&is_nil/1)}
+
+ {:ok, _} ->
+ {:error, :parse_error}
+
+ error ->
+ error
+ end
+ end
+
+ defp fetch_misskey_renotes(domain, note_id, limit, opts) do
+ case misskey_post(
+ domain,
+ "/api/notes/renotes",
+ %{"noteId" => note_id, "limit" => limit},
+ opts,
+ receive_timeout: 5_000
+ ) do
+ {:ok, renotes} when is_list(renotes) ->
+ {:ok,
+ renotes
+ |> Enum.map(&misskey_renote_account/1)
+ |> Enum.reject(&is_nil/1)}
+
+ {:ok, _} ->
+ {:error, :parse_error}
+
+ error ->
+ error
+ end
+ end
+
+ defp fetch_misskey_context(domain, note_id, root_status_url, opts) do
+ case misskey_post(
+ domain,
+ "/api/notes/children",
+ %{"noteId" => note_id, "limit" => 100, "depth" => 12},
+ opts,
+ receive_timeout: 10_000
+ ) do
+ {:ok, children} when is_list(children) ->
+ {:ok,
+ children
+ |> Enum.map(&misskey_note_context_status(&1, domain, note_id, root_status_url))
+ |> Enum.reject(&is_nil/1)}
+
+ {:ok, _} ->
+ {:error, :parse_error}
+
+ error ->
+ error
+ end
+ end
+
+ defp misskey_post(domain, path, payload, opts, request_opts) do
+ api_url = "https://#{domain}#{path}"
+ headers = [{"Content-Type", "application/json"} | build_headers()]
+ body = Jason.encode!(payload)
+
+ case safe_request(:post, api_url, headers, body, Keyword.merge(request_opts, opts)) do
+ {:ok, %Finch.Response{status: 200, body: response_body}} ->
+ Jason.decode(response_body)
+
+ {:ok, %Finch.Response{status: status}} ->
+ {:error, {:http_error, status}}
+
+ {:error, reason} ->
+ {:error, reason}
+ end
+ end
+
+ defp parse_status_response(body) do
+ case Jason.decode(body) do
+ {:ok, status} ->
+ status_counts(status)
+
+ _ ->
+ nil
+ end
+ end
+
+ defp parse_search_status_response(body) do
+ case Jason.decode(body) do
+ {:ok, %{"statuses" => [%{} = status | _]}} -> {:ok, status}
+ _ -> {:error, :parse_error}
+ end
+ end
+
+ defp status_counts(status) when is_map(status) do
+ %{
+ favourites_count: status["favourites_count"] || 0,
+ reblogs_count: status["reblogs_count"] || 0,
+ replies_count: status["replies_count"] || 0,
+ quotes_count: status["quotes_count"] || get_in(status, ["pleroma", "quotes_count"]) || 0,
+ status_metadata: status_metadata(status)
+ }
+ end
+
+ defp misskey_status_counts(note) when is_map(note) do
+ reaction_count =
+ [
+ nonnegative_count(note["reactionCount"]),
+ misskey_reaction_total(note["reactions"])
+ ]
+ |> Enum.max(fn -> 0 end)
+
+ %{
+ favourites_count: reaction_count + nonnegative_count(note["likeCount"]),
+ reblogs_count: nonnegative_count(note["renoteCount"]),
+ replies_count: nonnegative_count(note["repliesCount"]),
+ quotes_count: misskey_quote_count(note),
+ status_metadata: misskey_status_metadata(note)
+ }
+ end
+
+ defp status_metadata(status) when is_map(status) do
+ %{}
+ |> maybe_put_metadata(
+ "emoji_reactions",
+ status["emoji_reactions"] || get_in(status, ["pleroma", "emoji_reactions"])
+ )
+ |> maybe_put_metadata("quotes_count", status["quotes_count"])
+ |> maybe_put_metadata("quote", get_in(status, ["pleroma", "quote"]) || status["quote"])
+ |> maybe_put_metadata(
+ "quote_id",
+ get_in(status, ["pleroma", "quote_id"]) || status["quote_id"]
+ )
+ |> maybe_put_metadata(
+ "quote_url",
+ get_in(status, ["pleroma", "quote_url"]) || status["quote_url"]
+ )
+ |> maybe_put_metadata("card", status["card"])
+ |> maybe_put_metadata("application", status["application"])
+ |> maybe_put_metadata("language", status["language"])
+ |> maybe_put_metadata("media_attachments", status["media_attachments"])
+ |> maybe_put_metadata("pleroma", status["pleroma"])
+ end
+
+ defp misskey_status_metadata(note) when is_map(note) do
+ %{}
+ |> maybe_put_metadata("emoji_reactions", misskey_emoji_reactions(note["reactions"]))
+ |> maybe_put_metadata("quotes_count", positive_metadata_count(misskey_quote_count(note)))
+ |> maybe_put_metadata("quote", note["renote"])
+ |> maybe_put_metadata("quote_id", note["renoteId"] || get_in(note, ["renote", "id"]))
+ |> maybe_put_metadata(
+ "quote_url",
+ note["renoteUri"] || note["renoteUrl"] || misskey_note_url(note["renote"])
+ )
+ |> maybe_put_metadata("card", note["cw"] && %{"title" => note["cw"]})
+ |> maybe_put_metadata("application", misskey_application(note))
+ |> maybe_put_metadata("language", note["lang"] || note["language"])
+ |> maybe_put_metadata("media_attachments", misskey_media_attachments(note["files"]))
+ |> maybe_put_metadata("misskey", misskey_note_metadata(note))
+ end
+
+ defp misskey_emoji_reactions(reactions) when is_map(reactions) do
+ reactions
+ |> Enum.map(fn {emoji, count} ->
+ %{"name" => emoji, "count" => nonnegative_count(count)}
+ end)
+ |> Enum.filter(&(&1["count"] > 0))
+ end
+
+ defp misskey_emoji_reactions(_), do: []
+
+ defp misskey_reaction_total(reactions) when is_map(reactions) do
+ Enum.reduce(reactions, 0, fn {_emoji, count}, total -> total + nonnegative_count(count) end)
+ end
+
+ defp misskey_reaction_total(_), do: 0
+
+ defp misskey_quote_count(note) when is_map(note) do
+ nonnegative_count(note["quoteCount"] || note["quotesCount"] || note["quotedCount"])
+ end
+
+ defp misskey_application(%{"app" => %{} = app}), do: app
+ defp misskey_application(%{"viaMobile" => true}), do: %{"name" => "Misskey Mobile"}
+ defp misskey_application(_), do: nil
+
+ defp misskey_note_metadata(note) when is_map(note) do
+ note
+ |> Map.take([
+ "id",
+ "createdAt",
+ "cw",
+ "visibility",
+ "reactionAcceptance",
+ "reactionCount",
+ "reactions",
+ "renoteCount",
+ "repliesCount",
+ "renoteId",
+ "replyId",
+ "uri",
+ "url"
+ ])
+ end
+
+ defp misskey_media_attachments(files) when is_list(files) do
+ files
+ |> Enum.map(&misskey_media_attachment/1)
+ |> Enum.reject(&is_nil/1)
+ end
+
+ defp misskey_media_attachments(_), do: []
+
+ defp misskey_media_attachment(%{} = file) do
+ url = file["url"] || file["uri"] || file["thumbnailUrl"]
+
+ if is_binary(url) and url != "" do
+ %{
+ "id" => to_string(file["id"] || ""),
+ "type" => misskey_media_type(file, url),
+ "url" => url,
+ "preview_url" => file["thumbnailUrl"] || file["sensitiveThumbnailUrl"] || url,
+ "remote_url" => file["uri"] || url,
+ "meta" => file["properties"] || %{},
+ "description" => file["comment"] || file["name"],
+ "blurhash" => file["blurhash"]
+ }
+ end
+ end
+
+ defp misskey_media_attachment(_), do: nil
+
+ defp misskey_media_type(file, url) do
+ media_type = String.downcase(to_string(file["type"] || file["mimeType"] || ""))
+ url_downcased = String.downcase(url)
+
+ cond do
+ String.starts_with?(media_type, "video/") -> "video"
+ String.starts_with?(media_type, "audio/") -> "audio"
+ String.starts_with?(media_type, "image/gif") -> "gifv"
+ String.starts_with?(media_type, "image/") -> "image"
+ String.match?(url_downcased, ~r/\.(mp4|webm|ogv|mov)(\?.*)?$/) -> "video"
+ String.match?(url_downcased, ~r/\.(mp3|wav|ogg|m4a|flac)(\?.*)?$/) -> "audio"
+ String.match?(url_downcased, ~r/\.gif(\?.*)?$/) -> "gifv"
+ true -> "unknown"
+ end
+ end
+
+ defp positive_metadata_count(count) when is_integer(count) and count > 0, do: count
+ defp positive_metadata_count(_), do: nil
+
+ defp misskey_reaction_account(%{"user" => user}) when is_map(user),
+ do: extract_account_info(user)
+
+ defp misskey_reaction_account(%{"userId" => user_id}) when is_binary(user_id),
+ do: %{id: user_id}
+
+ defp misskey_reaction_account(_), do: nil
+
+ defp misskey_renote_account(%{"user" => user}) when is_map(user), do: extract_account_info(user)
+ defp misskey_renote_account(_), do: nil
+
+ defp misskey_note_context_status(note, domain, root_note_id, root_status_url)
+ when is_map(note) do
+ counts = misskey_status_counts(note)
+ note_id = normalize_status_id(note["id"])
+ reply_id = normalize_status_id(note["replyId"])
+ note_url = misskey_note_url(note) || if(note_id, do: "https://#{domain}/notes/#{note_id}")
+
+ %{
+ id: note_id,
+ uri: note["uri"] || note_url,
+ url: note_url,
+ content: misskey_note_content(note),
+ account: extract_account_info(note["user"]),
+ favourites_count: counts.favourites_count,
+ reblogs_count: counts.reblogs_count,
+ replies_count: counts.replies_count,
+ created_at: note["createdAt"],
+ in_reply_to_id: reply_id,
+ in_reply_to_uri:
+ cond do
+ reply_id == normalize_status_id(root_note_id) -> root_status_url
+ is_binary(reply_id) -> "https://#{domain}/notes/#{reply_id}"
+ true -> nil
+ end
+ }
+ end
+
+ defp misskey_note_context_status(_note, _domain, _root_note_id, _root_status_url), do: nil
+
+ defp misskey_note_content(note) when is_map(note) do
+ note["text"] || note["cw"] || ""
+ end
+
+ defp misskey_note_url(note) when is_map(note) do
+ note["url"] || note["uri"] || note["href"]
+ end
+
+ defp misskey_note_url(_), do: nil
+
+ defp nonnegative_count(value) when is_integer(value), do: max(value, 0)
+
+ defp nonnegative_count(value) when is_binary(value) do
+ case Integer.parse(String.trim(value)) do
+ {count, _} -> max(count, 0)
+ :error -> 0
+ end
+ end
+
+ defp nonnegative_count(_), do: 0
+
+ defp maybe_put_metadata(metadata, _key, nil), do: metadata
+ defp maybe_put_metadata(metadata, _key, []), do: metadata
+ defp maybe_put_metadata(metadata, _key, %{} = value) when map_size(value) == 0, do: metadata
+ defp maybe_put_metadata(metadata, key, value), do: Map.put(metadata, key, value)
+
+ defp fetch_account_list(api_url, opts) do
+ headers = build_headers()
+
+ case safe_request(:get, api_url, headers, nil, Keyword.merge([receive_timeout: 5_000], opts)) do
+ {:ok, %Finch.Response{status: 200, body: body}} ->
+ case Jason.decode(body) do
+ {:ok, accounts} when is_list(accounts) ->
+ {:ok, Enum.map(accounts, &extract_account_info/1)}
+
+ _ ->
+ {:error, :parse_error}
+ end
+
+ {:ok, %Finch.Response{status: status}} ->
+ {:error, {:http_error, status}}
+
+ {:error, reason} ->
+ {:error, reason}
+ end
+ end
+
+ defp extract_account_info(account) when is_map(account) do
+ username = account["username"]
+ host = account["host"] || account["domain"]
+
+ %{
+ id: account["id"],
+ username: username,
+ acct: account["acct"] || misskey_acct(username, host),
+ display_name: account["display_name"] || account["name"],
+ url: account["url"] || account["uri"],
+ uri: account["uri"] || account["url"],
+ avatar: account["avatar"] || account["avatarUrl"]
+ }
+ end
+
+ defp extract_account_info(_), do: nil
+
+ defp misskey_acct(username, host) when is_binary(username) and is_binary(host) and host != "",
+ do: "#{username}@#{host}"
+
+ defp misskey_acct(username, _host), do: username
+
+ defp build_headers do
+ [
+ {"Accept", "application/json"},
+ {"User-Agent", "Elektrine/1.0 (ActivityPub; +#{Domains.public_base_url()})"}
+ ]
+ end
+
+ defp safe_request(method, url, headers, body, opts) do
+ case Keyword.get(opts, :request_fun) do
+ request_fun when is_function(request_fun, 5) ->
+ request_fun.(method, url, headers, body, opts)
+
+ _ ->
+ request = Finch.build(method, url, headers, body || "")
+ SafeFetch.request(request, Elektrine.Finch, opts)
+ end
+ end
+
+ defp get_activitypub_id(%{activitypub_id: id}) when is_binary(id), do: id
+ defp get_activitypub_id(%{"activitypub_id" => id}) when is_binary(id), do: id
+ defp get_activitypub_id(%{"id" => id}) when is_binary(id), do: id
+ defp get_activitypub_id(_), do: nil
+
+ defp get_activitypub_url(%{activitypub_url: url}) when is_binary(url), do: url
+ defp get_activitypub_url(%{"activitypub_url" => url}) when is_binary(url), do: url
+ defp get_activitypub_url(%{"url" => url}) when is_binary(url), do: url
+ defp get_activitypub_url(_), do: nil
+end
diff --git a/apps/elektrine/lib/elektrine/activitypub/mentions.ex b/apps/elektrine/lib/elektrine/activitypub/mentions.ex
new file mode 100644
index 0000000..dab3475
--- /dev/null
+++ b/apps/elektrine/lib/elektrine/activitypub/mentions.ex
@@ -0,0 +1,136 @@
+defmodule Elektrine.ActivityPub.Mentions do
+ @moduledoc """
+ Handles mention extraction and federation for ActivityPub.
+ """
+
+ require Logger
+ alias Elektrine.ActivityPub
+ alias Elektrine.ActivityPub.RemoteFetch
+ alias Elektrine.Domains
+
+ @remote_mention_regex ~r/(^|[^A-Za-z0-9_@\/])@([a-zA-Z0-9_][a-zA-Z0-9_-]*)@((?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,63})(?![A-Za-z0-9.-])/u
+ @local_mention_regex ~r/(^|[^A-Za-z0-9_@\/])@([a-zA-Z0-9_][a-zA-Z0-9_-]*)(?![A-Za-z0-9_@.-])/u
+ @non_fediverse_mention_regex ~r/(^|[^A-Za-z0-9_@\/])@((?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,63})(?![A-Za-z0-9._-])/u
+ @non_fediverse_handle_regex ~r/(^|[^A-Za-z0-9._%+-@\/])([A-Za-z0-9][A-Za-z0-9._-]{0,62}@(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,63})(?![A-Za-z0-9._-])/u
+
+ @doc """
+ Extracts remote mentions from text content.
+ Matches @user@domain.com format.
+ """
+ def extract_mentions(content) when is_binary(content) do
+ Regex.scan(@remote_mention_regex, content)
+ |> Enum.map(fn [_full, _prefix, username, domain] ->
+ %{username: username, domain: domain, handle: "#{username}@#{domain}"}
+ end)
+ |> Enum.uniq_by(& &1.handle)
+ end
+
+ def extract_mentions(nil), do: []
+ def extract_mentions(_), do: []
+
+ @doc """
+ Extracts local mentions from text content.
+ Supports both @user and @user@local-domain while ignoring non-federated
+ handles like @x.com.
+ """
+ def extract_local_mentions(content) when is_binary(content) do
+ short_mentions =
+ Regex.scan(@local_mention_regex, content)
+ |> Enum.map(fn [_full, _prefix, username] ->
+ %{username: username, handle: username}
+ end)
+
+ local_federated_mentions =
+ extract_mentions(content)
+ |> Enum.filter(fn mention -> Domains.local_activitypub_domain?(mention.domain) end)
+ |> Enum.map(fn mention -> %{username: mention.username, handle: mention.handle} end)
+
+ (short_mentions ++ local_federated_mentions)
+ |> Enum.uniq_by(&String.downcase(&1.username))
+ end
+
+ def extract_local_mentions(nil), do: []
+ def extract_local_mentions(_), do: []
+
+ @doc """
+ Extracts non-federated domain-style handles like @x.com.
+ """
+ def extract_non_fediverse_mentions(content) when is_binary(content) do
+ domain_only_mentions =
+ Regex.scan(@non_fediverse_mention_regex, content)
+ |> Enum.map(fn [_full, _prefix, handle] -> %{handle: handle} end)
+
+ bare_handle_mentions =
+ Regex.scan(@non_fediverse_handle_regex, content)
+ |> Enum.map(fn [_full, _prefix, handle] -> %{handle: handle} end)
+
+ (domain_only_mentions ++ bare_handle_mentions)
+ |> Enum.uniq_by(&String.downcase(&1.handle))
+ end
+
+ def extract_non_fediverse_mentions(nil), do: []
+ def extract_non_fediverse_mentions(_), do: []
+
+ @doc """
+ Counts mention-like tokens across local, federated, and non-federated handles.
+ """
+ def count_mentions(content) when is_binary(content) do
+ length(Regex.scan(@local_mention_regex, content)) +
+ length(Regex.scan(@remote_mention_regex, content)) +
+ length(Regex.scan(@non_fediverse_mention_regex, content)) +
+ length(Regex.scan(@non_fediverse_handle_regex, content))
+ end
+
+ def count_mentions(_), do: 0
+
+ @doc """
+ Resolves mentions to ActivityPub actor URIs.
+ Returns {actor_uris, inbox_urls} for including in activities.
+ """
+ def resolve_mentions(content) do
+ mentions = extract_mentions(content)
+
+ mentions
+ |> Enum.map(fn mention ->
+ case RemoteFetch.webfinger_lookup(mention.handle) do
+ {:ok, actor_uri} ->
+ case ActivityPub.get_or_fetch_actor(actor_uri) do
+ {:ok, actor} ->
+ %{
+ uri: actor.uri,
+ inbox: actor.inbox_url,
+ handle: mention.handle
+ }
+
+ {:error, reason} ->
+ Logger.warning(
+ "Failed to fetch mentioned actor #{mention.handle}: #{inspect(reason)}"
+ )
+
+ nil
+ end
+
+ {:error, reason} ->
+ Logger.warning("WebFinger failed for #{mention.handle}: #{inspect(reason)}")
+ nil
+ end
+ end)
+ |> Enum.filter(&(&1 != nil))
+ end
+
+ @doc """
+ Gets inbox URLs for mentioned users.
+ """
+ def get_mention_inboxes(content) do
+ resolved = resolve_mentions(content)
+ Enum.map(resolved, & &1.inbox)
+ end
+
+ @doc """
+ Gets actor URIs for mentioned users (for cc field).
+ """
+ def get_mention_uris(content) do
+ resolved = resolve_mentions(content)
+ Enum.map(resolved, & &1.uri)
+ end
+end
diff --git a/apps/elektrine/lib/elektrine/activitypub/mrf.ex b/apps/elektrine/lib/elektrine/activitypub/mrf.ex
new file mode 100644
index 0000000..cf96aff
--- /dev/null
+++ b/apps/elektrine/lib/elektrine/activitypub/mrf.ex
@@ -0,0 +1,164 @@
+defmodule Elektrine.ActivityPub.MRF do
+ @moduledoc """
+ Message Rewrite Facility (MRF) for ActivityPub federation.
+
+ MRF is a policy-based system for filtering, modifying, or rejecting
+ incoming ActivityPub activities. Policies are applied in order and
+ can transform or reject activities before they are processed.
+
+ ## Configuration
+
+ config :elektrine, :mrf,
+ policies: [
+ Elektrine.ActivityPub.MRF.SimplePolicy
+ ],
+ transparency: true
+
+ ## Built-in Policies
+
+ - `SimplePolicy` - Domain-based accept/reject, media removal, NSFW marking
+ - `KeywordPolicy` - Filters based on content keywords
+ - `DropPolicy` - Drops all activities (for testing)
+ - `NoOpPolicy` - Passes all activities through (default)
+ """
+
+ require Logger
+
+ @doc """
+ Filters an activity through all configured MRF policies.
+
+ Returns `{:ok, activity}` if the activity passes all policies,
+ or `{:reject, reason}` if any policy rejects it.
+ """
+ def filter(activity) when is_map(activity) do
+ policies = get_policies()
+
+ Enum.reduce_while(policies, {:ok, activity}, fn policy, {:ok, acc} ->
+ case filter_one(policy, acc) do
+ {:ok, filtered} -> {:cont, {:ok, filtered}}
+ {:reject, reason} -> {:halt, {:reject, reason}}
+ end
+ end)
+ end
+
+ @doc """
+ Filters an activity through a single policy.
+ """
+ def filter_one(policy, activity) do
+ # Don't filter Undo, Block, or Delete through most policies
+ # These should generally be processed to maintain consistency
+ activity_type = activity["type"]
+
+ if activity_type in ["Undo", "Block", "Delete"] and
+ policy != Elektrine.ActivityPub.MRF.SimplePolicy do
+ {:ok, activity}
+ else
+ try do
+ policy.filter(activity)
+ rescue
+ e ->
+ Logger.error("MRF policy #{inspect(policy)} crashed: #{inspect(e)}")
+ {:ok, activity}
+ end
+ end
+ end
+
+ @doc """
+ Gets the list of configured MRF policies.
+ """
+ def get_policies do
+ configured = Application.get_env(:elektrine, :mrf, [])[:policies] || []
+
+ # Always include these core policies at the end
+ core_policies = [
+ Elektrine.ActivityPub.MRF.AntiFollowbotPolicy,
+ Elektrine.ActivityPub.MRF.NoPlaceholderTextPolicy,
+ Elektrine.ActivityPub.MRF.NoEmptyPolicy,
+ Elektrine.ActivityPub.MRF.HellthreadPolicy,
+ Elektrine.ActivityPub.MRF.ObjectAgePolicy,
+ Elektrine.ActivityPub.MRF.SimplePolicy,
+ Elektrine.ActivityPub.MRF.NormalizePolicy
+ ]
+
+ (List.wrap(configured) ++ core_policies)
+ |> Enum.uniq()
+ end
+
+ @doc """
+ Describes the current MRF configuration.
+ Used for transparency in nodeinfo.
+ """
+ def describe do
+ policies = get_policies()
+
+ policy_configs =
+ Enum.reduce(policies, %{}, fn policy, acc ->
+ case policy.describe() do
+ {:ok, config} -> Map.merge(acc, config)
+ _ -> acc
+ end
+ end)
+
+ policy_names =
+ policies
+ |> Enum.map(fn policy ->
+ policy
+ |> to_string()
+ |> String.split(".")
+ |> List.last()
+ end)
+
+ transparency = Application.get_env(:elektrine, :mrf, [])[:transparency] || false
+
+ {:ok,
+ %{
+ mrf_policies: policy_names,
+ transparency: transparency
+ }
+ |> Map.merge(if(transparency, do: policy_configs, else: %{}))}
+ end
+
+ @doc """
+ Checks if a domain matches any in a list of domains/patterns.
+ Supports wildcard patterns like "*.example.com".
+ """
+ def subdomain_match?(patterns, host) when is_list(patterns) do
+ Enum.any?(patterns, fn pattern ->
+ subdomain_match_one?(pattern, host)
+ end)
+ end
+
+ defp subdomain_match_one?(pattern, host) do
+ cond do
+ # Exact match
+ pattern == host ->
+ true
+
+ # Wildcard subdomain match: *.example.com matches foo.example.com
+ String.starts_with?(pattern, "*.") ->
+ base = String.trim_leading(pattern, "*.")
+ host == base or String.ends_with?(host, "." <> base)
+
+ # No match
+ true ->
+ false
+ end
+ end
+
+ @doc """
+ Extracts the host from an actor URI or activity.
+ """
+ def get_actor_host(%{"actor" => actor}) when is_binary(actor) do
+ URI.parse(actor).host
+ end
+
+ def get_actor_host(%{"id" => id}) when is_binary(id) do
+ URI.parse(id).host
+ end
+
+ def get_actor_host(uri) when is_binary(uri) do
+ URI.parse(uri).host
+ end
+
+ def get_actor_host(_), do: nil
+end
diff --git a/apps/elektrine/lib/elektrine/activitypub/mrf/anti_followbot_policy.ex b/apps/elektrine/lib/elektrine/activitypub/mrf/anti_followbot_policy.ex
new file mode 100644
index 0000000..c694abf
--- /dev/null
+++ b/apps/elektrine/lib/elektrine/activitypub/mrf/anti_followbot_policy.ex
@@ -0,0 +1,85 @@
+defmodule Elektrine.ActivityPub.MRF.AntiFollowbotPolicy do
+ @moduledoc """
+ Rejects obvious remote follow-bot accounts unless the local target follows them.
+ """
+
+ @behaviour Elektrine.ActivityPub.MRF.Policy
+
+ alias Elektrine.Accounts
+ alias Elektrine.ActivityPub
+ alias Elektrine.ActivityPub.Actor
+ alias Elektrine.Profiles
+
+ @impl true
+ def filter(%{"type" => "Follow", "actor" => actor_ref, "object" => object_ref} = activity) do
+ with actor_uri when is_binary(actor_uri) <- actor_uri(actor_ref),
+ %Actor{} = actor <- ActivityPub.get_actor_by_uri(actor_uri),
+ true <- followbot?(actor),
+ {:ok, target_user} <- local_target_user(object_ref),
+ false <- Profiles.following_remote_actor_by_identity?(target_user.id, actor) do
+ {:reject, "[AntiFollowbotPolicy] rejected follow-bot actor #{actor_uri}"}
+ else
+ _ -> {:ok, activity}
+ end
+ end
+
+ def filter(activity), do: {:ok, activity}
+
+ @impl true
+ def describe, do: {:ok, %{mrf_anti_followbot: true}}
+
+ defp followbot?(%Actor{} = actor), do: followbot_score(actor) >= 0.8
+
+ defp followbot_score(%Actor{} = actor) do
+ nickname_score(actor) + display_name_score(actor) + actor_type_score(actor)
+ end
+
+ defp nickname_score(%Actor{username: username, domain: domain})
+ when is_binary(username) and is_binary(domain) do
+ score_nickname(String.downcase("#{username}@#{domain}"))
+ end
+
+ defp nickname_score(_actor), do: 0.0
+
+ defp score_nickname("followbot@" <> _), do: 1.0
+ defp score_nickname("federationbot@" <> _), do: 1.0
+ defp score_nickname("federation_bot@" <> _), do: 1.0
+ defp score_nickname(_nickname), do: 0.0
+
+ defp display_name_score(%Actor{display_name: display_name}) when is_binary(display_name) do
+ display_name
+ |> String.downcase()
+ |> String.trim()
+ |> score_display_name()
+ end
+
+ defp display_name_score(_actor), do: 0.0
+
+ defp score_display_name("federation bot"), do: 1.0
+ defp score_display_name("federationbot"), do: 1.0
+ defp score_display_name("fedibot"), do: 1.0
+ defp score_display_name(_display_name), do: 0.0
+
+ defp actor_type_score(%Actor{actor_type: actor_type})
+ when actor_type in ["Service", "Application"],
+ do: 1.0
+
+ defp actor_type_score(_actor), do: 0.0
+
+ defp local_target_user(object_ref) do
+ case object_ref |> actor_uri() |> ActivityPub.local_username_from_uri() do
+ {:ok, username} when is_binary(username) ->
+ case Accounts.get_user_by_activitypub_identifier(username) do
+ nil -> :error
+ user -> {:ok, user}
+ end
+
+ _ ->
+ :error
+ end
+ end
+
+ defp actor_uri(uri) when is_binary(uri), do: uri
+ defp actor_uri(%{"id" => uri}) when is_binary(uri), do: uri
+ defp actor_uri(_), do: nil
+end
diff --git a/apps/elektrine/lib/elektrine/activitypub/mrf/anti_link_spam_policy.ex b/apps/elektrine/lib/elektrine/activitypub/mrf/anti_link_spam_policy.ex
new file mode 100644
index 0000000..c45f078
--- /dev/null
+++ b/apps/elektrine/lib/elektrine/activitypub/mrf/anti_link_spam_policy.ex
@@ -0,0 +1,92 @@
+defmodule Elektrine.ActivityPub.MRF.AntiLinkSpamPolicy do
+ @moduledoc """
+ Rejects link posts from unknown or very new remote actors.
+ """
+
+ @behaviour Elektrine.ActivityPub.MRF.Policy
+
+ import Ecto.Query
+
+ alias Elektrine.ActivityPub
+ alias Elektrine.ActivityPub.Actor
+ alias Elektrine.ActivityPub.MRF.Utils
+ alias Elektrine.Repo
+ alias Elektrine.Social.Message
+
+ @impl true
+ def filter(%{"type" => "Create", "actor" => actor, "object" => object} = activity)
+ when is_binary(actor) and is_map(object) do
+ if Utils.local_actor?(actor) or not contains_external_links?(object) do
+ {:ok, activity}
+ else
+ case remote_actor_reputation(actor) do
+ :trusted -> {:ok, activity}
+ :unknown -> {:reject, "[AntiLinkSpamPolicy] unknown remote actor posted links"}
+ :new -> {:reject, "[AntiLinkSpamPolicy] new remote actor posted links"}
+ end
+ end
+ end
+
+ def filter(activity), do: {:ok, activity}
+
+ @impl true
+ def describe do
+ {:ok, %{mrf_anti_link_spam: Map.new(config())}}
+ end
+
+ defp contains_external_links?(object) do
+ content = Map.get(object, "content", "")
+
+ hrefs =
+ Regex.scan(~r/]+href=["']([^"']+)["']/i, content, capture: :all_but_first)
+ |> List.flatten()
+ |> Enum.reject(&mention_or_hashtag_link?/1)
+
+ plain_urls =
+ content
+ |> Utils.strip_html()
+ |> then(&Regex.scan(~r/https?:\/\/[^\s<>"']+/i, &1))
+ |> List.flatten()
+
+ hrefs != [] or plain_urls != []
+ end
+
+ defp mention_or_hashtag_link?(href) when is_binary(href) do
+ String.contains?(href, ["/tags/", "/tag/", "/users/", "/@"]) or String.starts_with?(href, "#")
+ end
+
+ defp mention_or_hashtag_link?(_), do: false
+
+ defp remote_actor_reputation(actor_uri) do
+ case ActivityPub.get_actor_by_uri(actor_uri) do
+ nil ->
+ :unknown
+
+ %Actor{} = actor ->
+ cond do
+ cached_post_count(actor.id) > 0 -> :trusted
+ old_enough?(actor) -> :trusted
+ true -> :new
+ end
+ end
+ end
+
+ defp cached_post_count(actor_id) do
+ Repo.one(
+ from m in Message,
+ where: m.remote_actor_id == ^actor_id and is_nil(m.deleted_at),
+ select: count(m.id)
+ ) || 0
+ end
+
+ defp old_enough?(%Actor{inserted_at: inserted_at}) do
+ min_age_seconds = Keyword.get(config(), :min_actor_age_seconds, 30 * 60)
+ DateTime.diff(DateTime.utc_now(), to_datetime(inserted_at), :second) >= min_age_seconds
+ end
+
+ defp to_datetime(%DateTime{} = datetime), do: datetime
+ defp to_datetime(%NaiveDateTime{} = datetime), do: DateTime.from_naive!(datetime, "Etc/UTC")
+ defp to_datetime(_), do: DateTime.utc_now()
+
+ defp config, do: Application.get_env(:elektrine, :mrf_anti_link_spam, [])
+end
diff --git a/apps/elektrine/lib/elektrine/activitypub/mrf/anti_mention_spam_policy.ex b/apps/elektrine/lib/elektrine/activitypub/mrf/anti_mention_spam_policy.ex
new file mode 100644
index 0000000..0ad8459
--- /dev/null
+++ b/apps/elektrine/lib/elektrine/activitypub/mrf/anti_mention_spam_policy.ex
@@ -0,0 +1,68 @@
+defmodule Elektrine.ActivityPub.MRF.AntiMentionSpamPolicy do
+ @moduledoc """
+ Rejects mention-heavy posts from unknown or very new remote actors.
+ """
+
+ @behaviour Elektrine.ActivityPub.MRF.Policy
+
+ import Ecto.Query
+
+ alias Elektrine.ActivityPub
+ alias Elektrine.ActivityPub.Actor
+ alias Elektrine.ActivityPub.MRF.Utils
+ alias Elektrine.Repo
+ alias Elektrine.Social.Message
+
+ @impl true
+ def filter(%{"type" => "Create", "actor" => actor} = activity) when is_binary(actor) do
+ if Utils.local_actor?(actor) or Utils.mention_recipients(activity) == [] do
+ {:ok, activity}
+ else
+ case remote_actor_reputation(actor) do
+ :trusted -> {:ok, activity}
+ :unknown -> {:reject, "[AntiMentionSpamPolicy] unknown remote actor mentioned users"}
+ :new -> {:reject, "[AntiMentionSpamPolicy] new remote actor mentioned users"}
+ end
+ end
+ end
+
+ def filter(activity), do: {:ok, activity}
+
+ @impl true
+ def describe do
+ {:ok, %{mrf_anti_mention_spam: Map.new(config())}}
+ end
+
+ defp remote_actor_reputation(actor_uri) do
+ case ActivityPub.get_actor_by_uri(actor_uri) do
+ nil ->
+ :unknown
+
+ %Actor{} = actor ->
+ cond do
+ cached_post_count(actor.id) > 0 -> :trusted
+ old_enough?(actor) -> :trusted
+ true -> :new
+ end
+ end
+ end
+
+ defp cached_post_count(actor_id) do
+ Repo.one(
+ from m in Message,
+ where: m.remote_actor_id == ^actor_id and is_nil(m.deleted_at),
+ select: count(m.id)
+ ) || 0
+ end
+
+ defp old_enough?(%Actor{inserted_at: inserted_at}) do
+ min_age_seconds = Keyword.get(config(), :min_actor_age_seconds, 30 * 60)
+ DateTime.diff(DateTime.utc_now(), to_datetime(inserted_at), :second) >= min_age_seconds
+ end
+
+ defp to_datetime(%DateTime{} = datetime), do: datetime
+ defp to_datetime(%NaiveDateTime{} = datetime), do: DateTime.from_naive!(datetime, "Etc/UTC")
+ defp to_datetime(_), do: DateTime.utc_now()
+
+ defp config, do: Application.get_env(:elektrine, :mrf_anti_mention_spam, [])
+end
diff --git a/apps/elektrine/lib/elektrine/activitypub/mrf/emoji_policy.ex b/apps/elektrine/lib/elektrine/activitypub/mrf/emoji_policy.ex
new file mode 100644
index 0000000..ddfed28
--- /dev/null
+++ b/apps/elektrine/lib/elektrine/activitypub/mrf/emoji_policy.ex
@@ -0,0 +1,102 @@
+defmodule Elektrine.ActivityPub.MRF.EmojiPolicy do
+ @moduledoc """
+ Removes, rejects, or delists activities based on custom emoji names/URLs.
+ """
+
+ @behaviour Elektrine.ActivityPub.MRF.Policy
+
+ alias Elektrine.ActivityPub.MRF.Utils
+
+ @impl true
+ def filter(%{"type" => "EmojiReact"} = activity) do
+ config = Application.get_env(:elektrine, :mrf_emoji, [])
+
+ if emoji_matches?(activity, config[:remove_url] || [], config[:remove_shortcode] || []) do
+ {:reject, "[EmojiPolicy] rejected emoji reaction"}
+ else
+ {:ok, activity}
+ end
+ end
+
+ def filter(%{"type" => type, "object" => object} = activity)
+ when type in ["Create", "Update"] and is_map(object) do
+ config = Application.get_env(:elektrine, :mrf_emoji, [])
+
+ object =
+ object
+ |> remove_matching(:url, config[:remove_url] || [])
+ |> remove_matching(:shortcode, config[:remove_shortcode] || [])
+
+ activity = Map.put(activity, "object", object)
+
+ if type == "Create" and
+ emoji_matches?(
+ object,
+ config[:federated_timeline_removal_url] || [],
+ config[:federated_timeline_removal_shortcode] || []
+ ) do
+ {:ok, Utils.delist(activity)}
+ else
+ {:ok, activity}
+ end
+ end
+
+ def filter(%{"type" => type} = actor)
+ when type in ["Person", "Group", "Application", "Service", "Organization"] do
+ config = Application.get_env(:elektrine, :mrf_emoji, [])
+
+ {:ok,
+ actor
+ |> remove_matching(:url, config[:remove_url] || [])
+ |> remove_matching(:shortcode, config[:remove_shortcode] || [])}
+ end
+
+ def filter(activity), do: {:ok, activity}
+
+ @impl true
+ def describe do
+ {:ok, %{mrf_emoji: Application.get_env(:elektrine, :mrf_emoji, []) |> Map.new()}}
+ end
+
+ defp remove_matching(object, kind, patterns) do
+ tags =
+ object
+ |> Map.get("tag", [])
+ |> List.wrap()
+ |> Enum.reject(fn
+ %{"type" => "Emoji"} = tag -> Utils.matches_any?(emoji_value(tag, kind), patterns)
+ _ -> false
+ end)
+
+ emoji =
+ object
+ |> Map.get("emoji", %{})
+ |> case do
+ map when is_map(map) ->
+ Enum.reject(map, fn {name, url} ->
+ value = if kind == :url, do: to_string(url), else: String.trim(to_string(name), ":")
+ Utils.matches_any?(value, patterns)
+ end)
+ |> Map.new()
+
+ other ->
+ other
+ end
+
+ object
+ |> Map.put("tag", tags)
+ |> Map.put("emoji", emoji)
+ end
+
+ defp emoji_matches?(object, url_patterns, shortcode_patterns) do
+ Enum.any?(Utils.emoji_tags(object), fn tag ->
+ Utils.matches_any?(emoji_value(tag, :url), url_patterns) or
+ Utils.matches_any?(emoji_value(tag, :shortcode), shortcode_patterns)
+ end)
+ end
+
+ defp emoji_value(%{"icon" => %{"url" => url}}, :url) when is_binary(url), do: url
+ defp emoji_value(%{"url" => url}, :url) when is_binary(url), do: url
+ defp emoji_value(%{"name" => name}, :shortcode) when is_binary(name), do: String.trim(name, ":")
+ defp emoji_value(_tag, _kind), do: ""
+end
diff --git a/apps/elektrine/lib/elektrine/activitypub/mrf/force_bot_unlisted_policy.ex b/apps/elektrine/lib/elektrine/activitypub/mrf/force_bot_unlisted_policy.ex
new file mode 100644
index 0000000..e88600e
--- /dev/null
+++ b/apps/elektrine/lib/elektrine/activitypub/mrf/force_bot_unlisted_policy.ex
@@ -0,0 +1,51 @@
+defmodule Elektrine.ActivityPub.MRF.ForceBotUnlistedPolicy do
+ @moduledoc """
+ Converts public posts from likely bot actors to unlisted.
+ """
+
+ @behaviour Elektrine.ActivityPub.MRF.Policy
+
+ alias Elektrine.ActivityPub
+ alias Elektrine.ActivityPub.Actor
+ alias Elektrine.ActivityPub.MRF.Utils
+
+ @impl true
+ def filter(%{"type" => "Create", "actor" => actor, "object" => object} = activity)
+ when is_binary(actor) and is_map(object) do
+ if Utils.visibility(activity) == "public" and bot_actor?(actor) do
+ {:ok, Utils.delist(activity)}
+ else
+ {:ok, activity}
+ end
+ end
+
+ def filter(activity), do: {:ok, activity}
+
+ @impl true
+ def describe, do: {:ok, %{mrf_force_bot_unlisted: true}}
+
+ defp bot_actor?(actor_uri) do
+ case ActivityPub.get_actor_by_uri(actor_uri) do
+ %Actor{actor_type: type} when type in ["Application", "Service"] ->
+ true
+
+ %Actor{username: username} when is_binary(username) ->
+ bot_username?(username)
+
+ _ ->
+ actor_uri
+ |> URI.parse()
+ |> Map.get(:path)
+ |> to_string()
+ |> Path.basename()
+ |> bot_username?()
+ end
+ end
+
+ defp bot_username?(username) when is_binary(username) do
+ Regex.match?(~r/(^|[-_.])(bot|ebooks)($|[-_.])/i, username) or
+ Regex.match?(~r/(bot|ebooks)@/i, username)
+ end
+
+ defp bot_username?(_), do: false
+end
diff --git a/apps/elektrine/lib/elektrine/activitypub/mrf/hashtag_policy.ex b/apps/elektrine/lib/elektrine/activitypub/mrf/hashtag_policy.ex
new file mode 100644
index 0000000..5a39279
--- /dev/null
+++ b/apps/elektrine/lib/elektrine/activitypub/mrf/hashtag_policy.ex
@@ -0,0 +1,53 @@
+defmodule Elektrine.ActivityPub.MRF.HashtagPolicy do
+ @moduledoc """
+ Rejects, delists, or marks sensitive posts by hashtag.
+ """
+
+ @behaviour Elektrine.ActivityPub.MRF.Policy
+
+ alias Elektrine.ActivityPub.MRF.Utils
+
+ @impl true
+ def filter(%{"type" => type, "object" => object} = activity)
+ when type in ["Create", "Update"] and is_map(object) do
+ hashtags = Utils.hashtags(object)
+ config = Application.get_env(:elektrine, :mrf_hashtag, [])
+
+ if intersects?(hashtags, Keyword.get(config, :reject, [])) do
+ {:reject, "[HashtagPolicy] rejected hashtag"}
+ else
+ activity =
+ activity
+ |> maybe_delist(type, hashtags, Keyword.get(config, :federated_timeline_removal, []))
+ |> maybe_sensitive(hashtags, Keyword.get(config, :sensitive, []))
+
+ {:ok, activity}
+ end
+ end
+
+ def filter(activity), do: {:ok, activity}
+
+ @impl true
+ def describe do
+ {:ok, %{mrf_hashtag: Application.get_env(:elektrine, :mrf_hashtag, []) |> Map.new()}}
+ end
+
+ defp maybe_delist(activity, "Create", hashtags, patterns) do
+ if intersects?(hashtags, patterns), do: Utils.delist(activity), else: activity
+ end
+
+ defp maybe_delist(activity, _type, _hashtags, _patterns), do: activity
+
+ defp maybe_sensitive(%{"object" => object} = activity, hashtags, patterns) do
+ if intersects?(hashtags, patterns) do
+ Map.put(activity, "object", Map.put(object, "sensitive", true))
+ else
+ activity
+ end
+ end
+
+ defp intersects?(hashtags, patterns) do
+ normalized = Enum.map(patterns, &Utils.normalize_hashtag/1)
+ Enum.any?(hashtags, &(&1 in normalized))
+ end
+end
diff --git a/apps/elektrine/lib/elektrine/activitypub/mrf/hellthread_policy.ex b/apps/elektrine/lib/elektrine/activitypub/mrf/hellthread_policy.ex
new file mode 100644
index 0000000..bc96d87
--- /dev/null
+++ b/apps/elektrine/lib/elektrine/activitypub/mrf/hellthread_policy.ex
@@ -0,0 +1,138 @@
+defmodule Elektrine.ActivityPub.MRF.HellthreadPolicy do
+ @moduledoc """
+ MRF policy for filtering hellthreads (posts with excessive mentions).
+
+ Hellthreads are posts that mention a large number of users, often used for spam
+ or harassment. This policy limits the number of mentions allowed in a post.
+
+ ## Configuration
+
+ Configure in runtime.exs or config.exs:
+
+ config :elektrine, :mrf_hellthread,
+ # Maximum mentions before action is taken
+ delist_threshold: 10,
+ reject_threshold: 20
+
+ ## Actions
+
+ - Posts with mentions >= `delist_threshold` are removed from public timelines
+ - Posts with mentions >= `reject_threshold` are rejected entirely
+ """
+
+ @behaviour Elektrine.ActivityPub.MRF.Policy
+
+ require Logger
+
+ @default_delist_threshold 10
+ @default_reject_threshold 20
+
+ @impl true
+ def filter(%{"type" => "Create", "object" => object} = activity) when is_map(object) do
+ # Count mentions from both activity-level to/cc and object-level tags
+ mention_count = count_mentions(activity, object)
+ config = get_config()
+
+ reject_threshold = config[:reject_threshold] || @default_reject_threshold
+ delist_threshold = config[:delist_threshold] || @default_delist_threshold
+
+ cond do
+ mention_count >= reject_threshold ->
+ Logger.info(
+ "HellthreadPolicy: Rejecting activity with #{mention_count} mentions (threshold: #{reject_threshold})"
+ )
+
+ {:reject, "Too many mentions (#{mention_count})"}
+
+ mention_count >= delist_threshold ->
+ Logger.info(
+ "HellthreadPolicy: Delisting activity with #{mention_count} mentions (threshold: #{delist_threshold})"
+ )
+
+ # Remove from public timelines
+ object = Map.put(object, "_mrf_federated_timeline_removal", true)
+ {:ok, Map.put(activity, "object", object)}
+
+ true ->
+ {:ok, activity}
+ end
+ end
+
+ def filter(activity), do: {:ok, activity}
+
+ @impl true
+ def describe do
+ config = get_config()
+
+ {:ok,
+ %{
+ mrf_hellthread: %{
+ delist_threshold: config[:delist_threshold] || @default_delist_threshold,
+ reject_threshold: config[:reject_threshold] || @default_reject_threshold
+ }
+ }}
+ end
+
+ defp count_mentions(activity, object) do
+ # Count recipients from activity-level to/cc fields
+ # (this is where ActivityPub specifies delivery targets)
+ activity_to_count = count_recipients(activity["to"])
+ activity_cc_count = count_recipients(activity["cc"])
+
+ # Also count recipients from object-level to/cc (some implementations use this)
+ object_to_count = count_recipients(object["to"])
+ object_cc_count = count_recipients(object["cc"])
+
+ # Count Mention tags in the object
+ tag_count = count_mention_tags(object["tag"])
+
+ # Use the maximum of all counts
+ # (different implementations put mentions in different places)
+ max(
+ activity_to_count + activity_cc_count,
+ max(object_to_count + object_cc_count, tag_count)
+ )
+ end
+
+ defp count_recipients(nil), do: 0
+
+ defp count_recipients(recipients) when is_list(recipients) do
+ # Don't count public addresses or followers collections
+ recipients
+ |> Enum.reject(fn uri ->
+ public_address?(uri) || followers_collection?(uri)
+ end)
+ |> length()
+ end
+
+ defp count_recipients(_), do: 0
+
+ defp count_mention_tags(nil), do: 0
+
+ defp count_mention_tags(tags) when is_list(tags) do
+ tags
+ |> Enum.count(fn tag -> is_map(tag) && tag["type"] == "Mention" end)
+ end
+
+ defp count_mention_tags(_), do: 0
+
+ defp public_address?(uri) when is_binary(uri) do
+ uri in [
+ "https://www.w3.org/ns/activitystreams#Public",
+ "as:Public",
+ "Public"
+ ]
+ end
+
+ defp public_address?(_), do: false
+
+ defp followers_collection?(uri) when is_binary(uri) do
+ String.ends_with?(uri, "/followers")
+ end
+
+ defp followers_collection?(_), do: false
+
+ defp get_config do
+ Application.get_env(:elektrine, :mrf_hellthread, [])
+ end
+end
diff --git a/apps/elektrine/lib/elektrine/activitypub/mrf/inline_quote_policy.ex b/apps/elektrine/lib/elektrine/activitypub/mrf/inline_quote_policy.ex
new file mode 100644
index 0000000..772521e
--- /dev/null
+++ b/apps/elektrine/lib/elektrine/activitypub/mrf/inline_quote_policy.ex
@@ -0,0 +1,56 @@
+defmodule Elektrine.ActivityPub.MRF.InlineQuotePolicy do
+ @moduledoc """
+ Appends a visible quote URL line to quoted posts for clients that ignore quote metadata.
+ """
+
+ @behaviour Elektrine.ActivityPub.MRF.Policy
+
+ @impl true
+ def filter(%{"object" => %{"quoteUrl" => quote_url} = object} = activity)
+ when is_binary(quote_url) do
+ {:ok, Map.put(activity, "object", inline_quote(object, quote_url))}
+ end
+
+ def filter(activity), do: {:ok, activity}
+
+ @impl true
+ def describe do
+ {:ok, %{mrf_inline_quote: %{template: template()}}}
+ end
+
+ defp inline_quote(%{"content" => content} = object, quote_url) when is_binary(content) do
+ if inline_quote_present?(content, quote_url) do
+ object
+ else
+ Map.put(object, "content", append_quote(content, quote_url))
+ end
+ end
+
+ defp inline_quote(object, quote_url),
+ do: Map.put(object, "content", append_quote("", quote_url))
+
+ defp append_quote(content, quote_url) do
+ quote_line =
+ template()
+ |> String.replace("{url}", ~s( #{quote_url} ))
+ |> then(&~s( #{&1} ))
+
+ if String.ends_with?(content, " ") do
+ String.replace_suffix(content, "", quote_line <> "")
+ else
+ content <> quote_line
+ end
+ end
+
+ defp inline_quote_present?(content, quote_url) do
+ String.contains?(content, quote_url) or
+ String.contains?(content, ~s(class="quote-inline")) or
+ String.contains?(content, ~s(class='quote-inline'))
+ end
+
+ defp template do
+ :elektrine
+ |> Application.get_env(:mrf_inline_quote, [])
+ |> Keyword.get(:template, "QT: {url}")
+ end
+end
diff --git a/apps/elektrine/lib/elektrine/activitypub/mrf/keyword_policy.ex b/apps/elektrine/lib/elektrine/activitypub/mrf/keyword_policy.ex
new file mode 100644
index 0000000..ba10737
--- /dev/null
+++ b/apps/elektrine/lib/elektrine/activitypub/mrf/keyword_policy.ex
@@ -0,0 +1,207 @@
+defmodule Elektrine.ActivityPub.MRF.KeywordPolicy do
+ @moduledoc """
+ MRF policy for filtering content based on keywords and regex patterns.
+
+ ## Configuration
+
+ Configure in runtime.exs or config.exs:
+
+ config :elektrine, :mrf_keyword,
+ reject: [
+ "spam phrase",
+ ~r/buy.*now/i
+ ],
+ federated_timeline_removal: [
+ "controversial topic",
+ ~r/politics/i
+ ],
+ replace: [
+ {~r/bad word/i, "****"}
+ ],
+ mark_sensitive: [
+ ~r/nsfw/i,
+ "adult content"
+ ]
+
+ ## Actions
+
+ - `reject` - Block the activity entirely
+ - `federated_timeline_removal` - Remove from federated timeline but allow to followers
+ - `replace` - Replace matching text with specified replacement
+ - `mark_sensitive` - Mark the post as sensitive/NSFW
+
+ ## Pattern Types
+
+ - String: Exact case-insensitive match
+ - Regex: Full regex pattern matching
+ """
+
+ @behaviour Elektrine.ActivityPub.MRF.Policy
+
+ require Logger
+
+ @impl true
+ def filter(%{"type" => "Create", "object" => object} = activity) when is_map(object) do
+ with {:ok, object} <- check_reject(object),
+ {:ok, object} <- check_replace(object),
+ {:ok, object} <- check_federated_timeline_removal(object),
+ {:ok, object} <- check_mark_sensitive(object) do
+ {:ok, Map.put(activity, "object", object)}
+ end
+ end
+
+ # Also filter Update activities
+ def filter(%{"type" => "Update", "object" => object} = activity) when is_map(object) do
+ with {:ok, object} <- check_reject(object),
+ {:ok, object} <- check_replace(object),
+ {:ok, object} <- check_federated_timeline_removal(object),
+ {:ok, object} <- check_mark_sensitive(object) do
+ {:ok, Map.put(activity, "object", object)}
+ end
+ end
+
+ def filter(activity), do: {:ok, activity}
+
+ @impl true
+ def describe do
+ config = get_config()
+
+ # Only expose counts in describe, not actual patterns (for security)
+ {:ok,
+ %{
+ mrf_keyword: %{
+ reject_count: length(config[:reject] || []),
+ federated_timeline_removal_count: length(config[:federated_timeline_removal] || []),
+ replace_count: length(config[:replace] || []),
+ mark_sensitive_count: length(config[:mark_sensitive] || [])
+ }
+ }}
+ end
+
+ # Private functions
+
+ defp check_reject(object) do
+ patterns = get_config()[:reject] || []
+
+ if matches_any?(object, patterns) do
+ Logger.info("KeywordPolicy: Rejecting activity due to keyword match")
+ {:reject, "Blocked by keyword filter"}
+ else
+ {:ok, object}
+ end
+ end
+
+ defp check_replace(object) do
+ replacements = get_config()[:replace] || []
+
+ object =
+ Enum.reduce(replacements, object, fn {pattern, replacement}, obj ->
+ apply_replacement(obj, pattern, replacement)
+ end)
+
+ {:ok, object}
+ end
+
+ defp check_federated_timeline_removal(object) do
+ patterns = get_config()[:federated_timeline_removal] || []
+
+ if matches_any?(object, patterns) do
+ # Remove from federated timeline by adjusting visibility
+ # This is done by setting a flag that the timeline queries will check
+ object =
+ object
+ |> Map.put("_mrf_federated_timeline_removal", true)
+
+ {:ok, object}
+ else
+ {:ok, object}
+ end
+ end
+
+ defp check_mark_sensitive(object) do
+ patterns = get_config()[:mark_sensitive] || []
+
+ if matches_any?(object, patterns) do
+ object = Map.put(object, "sensitive", true)
+ {:ok, object}
+ else
+ {:ok, object}
+ end
+ end
+
+ defp matches_any?(object, patterns) do
+ content = extract_searchable_content(object)
+ Enum.any?(patterns, fn pattern -> matches_pattern?(content, pattern) end)
+ end
+
+ defp extract_searchable_content(object) do
+ # Combine all searchable fields
+ fields = [
+ object["content"],
+ object["summary"],
+ object["name"],
+ # Check hashtags
+ extract_hashtags(object["tag"])
+ ]
+
+ fields
+ |> Enum.reject(&is_nil/1)
+ |> Enum.join(" ")
+ |> strip_html()
+ |> String.downcase()
+ end
+
+ defp extract_hashtags(nil), do: ""
+
+ defp extract_hashtags(tags) when is_list(tags) do
+ tags
+ |> Enum.filter(fn tag -> is_map(tag) && tag["type"] == "Hashtag" end)
+ |> Enum.map_join(" ", fn tag -> tag["name"] || "" end)
+ end
+
+ defp extract_hashtags(_), do: ""
+
+ defp strip_html(nil), do: ""
+
+ defp strip_html(html) do
+ html
+ |> String.replace(~r/<[^>]*>/, " ")
+ |> String.replace(~r/\s+/, " ")
+ |> String.trim()
+ end
+
+ defp matches_pattern?(content, %Regex{} = regex) do
+ Regex.match?(regex, content)
+ end
+
+ defp matches_pattern?(content, pattern) when is_binary(pattern) do
+ String.contains?(content, String.downcase(pattern))
+ end
+
+ defp matches_pattern?(_, _), do: false
+
+ defp apply_replacement(object, pattern, replacement) do
+ content = object["content"]
+
+ if content do
+ new_content = replace_in_text(content, pattern, replacement)
+ Map.put(object, "content", new_content)
+ else
+ object
+ end
+ end
+
+ defp replace_in_text(text, %Regex{} = pattern, replacement) do
+ Regex.replace(pattern, text, replacement)
+ end
+
+ defp replace_in_text(text, pattern, replacement) when is_binary(pattern) do
+ # Case-insensitive string replacement
+ regex = Regex.compile!(Regex.escape(pattern), "i")
+ Regex.replace(regex, text, replacement)
+ end
+
+ defp get_config do
+ Application.get_env(:elektrine, :mrf_keyword, [])
+ end
+end
diff --git a/apps/elektrine/lib/elektrine/activitypub/mrf/mention_policy.ex b/apps/elektrine/lib/elektrine/activitypub/mrf/mention_policy.ex
new file mode 100644
index 0000000..fd9d635
--- /dev/null
+++ b/apps/elektrine/lib/elektrine/activitypub/mrf/mention_policy.ex
@@ -0,0 +1,35 @@
+defmodule Elektrine.ActivityPub.MRF.MentionPolicy do
+ @moduledoc """
+ Rejects Create activities that mention protected actor URIs.
+ """
+
+ @behaviour Elektrine.ActivityPub.MRF.Policy
+
+ alias Elektrine.ActivityPub.MRF.Utils
+
+ @impl true
+ def filter(%{"type" => "Create"} = activity) do
+ rejected_actors =
+ :elektrine
+ |> Application.get_env(:mrf_mention, [])
+ |> Keyword.get(:actors, [])
+
+ case Enum.find(Utils.mention_recipients(activity), &(&1 in rejected_actors)) do
+ actor when is_binary(actor) -> {:reject, "[MentionPolicy] rejected mention of #{actor}"}
+ _ -> {:ok, activity}
+ end
+ end
+
+ def filter(activity), do: {:ok, activity}
+
+ @impl true
+ def describe do
+ count =
+ :elektrine
+ |> Application.get_env(:mrf_mention, [])
+ |> Keyword.get(:actors, [])
+ |> length()
+
+ {:ok, %{mrf_mention: %{actor_count: count}}}
+ end
+end
diff --git a/apps/elektrine/lib/elektrine/activitypub/mrf/no_empty_policy.ex b/apps/elektrine/lib/elektrine/activitypub/mrf/no_empty_policy.ex
new file mode 100644
index 0000000..2a2e22b
--- /dev/null
+++ b/apps/elektrine/lib/elektrine/activitypub/mrf/no_empty_policy.ex
@@ -0,0 +1,48 @@
+defmodule Elektrine.ActivityPub.MRF.NoEmptyPolicy do
+ @moduledoc """
+ Rejects local Note creates/updates that contain no real content.
+
+ This catches accidental posts that are blank or only mention handles, while
+ allowing media-only posts with attachments.
+ """
+
+ @behaviour Elektrine.ActivityPub.MRF.Policy
+
+ alias Elektrine.ActivityPub.MRF.Utils
+
+ @impl true
+ def filter(%{"actor" => actor, "object" => %{"type" => "Note"} = object} = activity)
+ when is_binary(actor) do
+ if Utils.local_actor?(actor) and Utils.create_or_update?(activity) and empty_note?(object) do
+ {:reject, "[NoEmptyPolicy] empty local note"}
+ else
+ {:ok, activity}
+ end
+ end
+
+ def filter(activity), do: {:ok, activity}
+
+ @impl true
+ def describe, do: {:ok, %{mrf_no_empty: true}}
+
+ defp empty_note?(object) do
+ not has_attachment?(object) and only_mentions_or_blank?(source_text(object))
+ end
+
+ defp has_attachment?(%{"attachment" => attachments}) when is_list(attachments),
+ do: attachments != []
+
+ defp has_attachment?(_), do: false
+
+ defp source_text(%{"source" => %{"content" => content}}), do: content
+ defp source_text(%{"source" => source}) when is_binary(source), do: source
+ defp source_text(%{"content" => content}) when is_binary(content), do: Utils.strip_html(content)
+ defp source_text(_), do: ""
+
+ defp only_mentions_or_blank?(text) when is_binary(text) do
+ text
+ |> String.split(~r/\s+/, trim: true)
+ |> Enum.reject(&String.starts_with?(&1, "@"))
+ |> Enum.empty?()
+ end
+end
diff --git a/apps/elektrine/lib/elektrine/activitypub/mrf/no_placeholder_text_policy.ex b/apps/elektrine/lib/elektrine/activitypub/mrf/no_placeholder_text_policy.ex
new file mode 100644
index 0000000..e561100
--- /dev/null
+++ b/apps/elektrine/lib/elektrine/activitypub/mrf/no_placeholder_text_policy.ex
@@ -0,0 +1,24 @@
+defmodule Elektrine.ActivityPub.MRF.NoPlaceholderTextPolicy do
+ @moduledoc """
+ Removes placeholder content from media posts.
+
+ Some servers send media-only posts with "." or ".
" as filler text.
+ """
+
+ @behaviour Elektrine.ActivityPub.MRF.Policy
+
+ @impl true
+ def filter(
+ %{"type" => type, "object" => %{"content" => content, "attachment" => attachments}} =
+ activity
+ )
+ when type in ["Create", "Update"] and content in [".", ".
"] and is_list(attachments) and
+ attachments != [] do
+ {:ok, put_in(activity, ["object", "content"], "")}
+ end
+
+ def filter(activity), do: {:ok, activity}
+
+ @impl true
+ def describe, do: {:ok, %{mrf_no_placeholder_text: true}}
+end
diff --git a/apps/elektrine/lib/elektrine/activitypub/mrf/normalize_policy.ex b/apps/elektrine/lib/elektrine/activitypub/mrf/normalize_policy.ex
new file mode 100644
index 0000000..61ea9b7
--- /dev/null
+++ b/apps/elektrine/lib/elektrine/activitypub/mrf/normalize_policy.ex
@@ -0,0 +1,42 @@
+defmodule Elektrine.ActivityPub.MRF.NormalizePolicy do
+ @moduledoc """
+ Normalizes incoming ActivityPub activities.
+
+ This policy runs last and ensures activities have consistent structure:
+ - Normalizes content markup
+ - Ensures required fields are present
+ - Sanitizes potentially dangerous content
+ """
+
+ @behaviour Elektrine.ActivityPub.MRF.Policy
+
+ @impl true
+ def filter(%{"type" => type, "object" => %{"content" => content} = object} = activity)
+ when type in ["Create", "Update"] and is_binary(content) do
+ # Normalize HTML content - strip dangerous tags but preserve safe formatting
+ sanitized_content = sanitize_html(content)
+ updated_object = Map.put(object, "content", sanitized_content)
+
+ {:ok, Map.put(activity, "object", updated_object)}
+ end
+
+ def filter(activity), do: {:ok, activity}
+
+ defp sanitize_html(html) when is_binary(html) do
+ # Use HtmlSanitizeEx if available, otherwise basic sanitization
+ if Code.ensure_loaded?(HtmlSanitizeEx) do
+ HtmlSanitizeEx.basic_html(html)
+ else
+ # Basic fallback - strip script tags at minimum
+ html
+ |> String.replace(~r/
+
+
+