diff --git a/Dockerfile b/Dockerfile index e2308f371..25ee37e68 100644 --- a/Dockerfile +++ b/Dockerfile @@ -19,7 +19,8 @@ FROM base AS build # Install packages needed to build gems RUN apt-get update -qq && \ - apt-get install --no-install-recommends -y build-essential git libvips pkg-config libyaml-dev + apt-get install -y --no-install-recommends -y build-essential git libvips pkg-config libyaml-dev default-libmysqlclient-dev && \ + rm -rf /var/lib/apt/lists /var/cache/apt/archives # Install application gems COPY Gemfile Gemfile.lock .ruby-version ./ @@ -47,7 +48,7 @@ FROM base # Install packages needed for deployment RUN apt-get update -qq && \ - apt-get install --no-install-recommends -y curl libsqlite3-0 libvips build-essential ffmpeg groff libreoffice-writer libreoffice-impress libreoffice-calc mupdf-tools && \ + apt-get install --no-install-recommends -y curl libsqlite3-0 libvips build-essential ffmpeg groff libreoffice-writer libreoffice-impress libreoffice-calc mupdf-tools default-libmysqlclient-dev && \ rm -rf /var/lib/apt/lists /var/cache/apt/archives # Copy built artifacts: gems, application diff --git a/Gemfile b/Gemfile index 8c43ff533..e5e3637e9 100644 --- a/Gemfile +++ b/Gemfile @@ -32,6 +32,16 @@ gem "image_processing", "~> 1.14" gem "platform_agent" gem "aws-sdk-s3", require: false +# 37id and Queenbee integration +gem "signal_id", bc: "signal_id", branch: "rails4" +gem "mysql2" # needed by signal_id +gem "queuety", bc: "queuety", branch: "rails4" # needed by signal_id +gem "service_concurrency_prevention", bc: "service_concurrency_prevention" # needed by queuety +gem "portfolio", ">= 4.6", bc: "portfolio" # needed by signal_id +gem "file_repository", "~> 1.4.5", bc: "file_repository" # needed by portfolio +gem "queenbee", bc: "queenbee-plugin" +gem "activeresource", require: "active_resource" # needed by queenbee + # Telemetry, logging, and operations gem "mission_control-jobs" gem "sentry-ruby" @@ -55,4 +65,5 @@ group :test do gem "selenium-webdriver" gem "webmock" gem "vcr" + gem "mocha" end diff --git a/Gemfile.lock b/Gemfile.lock index 696e77ae4..f18b08187 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,13 +1,13 @@ GIT remote: https://github.com/basecamp/actiontext-lexical - revision: 3f1ec3916f468ac2bb626091bcb97469c2c52483 + revision: 79ac1958183afb1d8cecd6799d230b63392eb014 specs: actiontext-lexical (0.1.0) rails (>= 8.0.2) GIT remote: https://github.com/basecamp/active_record-tenanted - revision: f6fca189655ae6609d10ecad7cceed61592320c4 + revision: 81a4d02a363fa749de517c90e19588365f934913 specs: active_record-tenanted (0.1.0) activerecord (>= 8.1.alpha) @@ -15,6 +15,12 @@ GIT railties (>= 8.1.alpha) zeitwerk +GIT + remote: https://github.com/basecamp/file_repository + revision: 17e90fc35ccb9f0a0dae1ed8623f01386696002d + specs: + file_repository (1.4.5) + GIT remote: https://github.com/basecamp/kamal revision: 344e2d7995e0134aa52f195a984d8d5e24e31d94 @@ -32,6 +38,32 @@ GIT thor (~> 1.3) zeitwerk (>= 2.6.18, < 3.0) +GIT + remote: https://github.com/basecamp/portfolio + revision: 26dd0ccbe4aad5a5c539f90c00e82b6175443636 + specs: + portfolio (5.2.0) + activesupport + file_repository (>= 1.4.4) + +GIT + remote: https://github.com/basecamp/queenbee-plugin + revision: eb476e7f309b7642aa20254f0bdbadcec1dda2b2 + specs: + queenbee (3.2.0) + activeresource + builder + rexml + +GIT + remote: https://github.com/basecamp/queuety + revision: bf7e2a552fb674533d3e092f06dbf9b5d1ebc753 + branch: rails4 + specs: + queuety (4.0.2) + activerecord + service_concurrency_prevention + GIT remote: https://github.com/basecamp/rails-structured-logging revision: 76960cb5c15fc2b6b5f7542e05d7dcc031cef9e6 @@ -40,6 +72,29 @@ GIT json rails (>= 6.0.0) +GIT + remote: https://github.com/basecamp/service_concurrency_prevention + revision: 320da99431718615ed86f8723e8b9d3259c602a3 + specs: + service_concurrency_prevention (1.1.1) + activesupport + +GIT + remote: https://github.com/basecamp/signal_id + revision: 8908b2550ff55325b04c108146e00b4408a64e93 + branch: rails4 + specs: + signal_id (4.3.2) + bcrypt-ruby (>= 3.0.0) + google-id-token (>= 1.4.0) + mocha + multi_json + portfolio (>= 4.0) + pwned (~> 2.0) + queuety (>= 3.0) + rails (>= 4.0.2) + rails-controller-testing + GIT remote: https://github.com/crmne/ruby_llm.git revision: a35aa111a0bb36b7f0363b1f1f2a25e3d5378fd3 @@ -56,7 +111,7 @@ GIT GIT remote: https://github.com/rails/rails.git - revision: fd49d6a3180244969fb5ab9768d0bc7d9e4df9fe + revision: 53a9a3181a59f4e6dff73ba9453734edd7f7e076 branch: main specs: actioncable (8.1.0.alpha) @@ -159,22 +214,30 @@ GEM specs: action_text-trix (2.1.15) railties + activemodel-serializers-xml (1.0.3) + activemodel (>= 5.0.0.a) + activesupport (>= 5.0.0.a) + builder (~> 3.1) + activeresource (6.1.4) + activemodel (>= 6.0) + activemodel-serializers-xml (~> 1.0) + activesupport (>= 6.0) addressable (2.8.7) public_suffix (>= 2.0.2, < 7.0) ast (2.4.3) aws-eventstream (1.4.0) - aws-partitions (1.1115.0) - aws-sdk-core (3.225.2) + aws-partitions (1.1119.0) + aws-sdk-core (3.226.0) aws-eventstream (~> 1, >= 1.3.0) aws-partitions (~> 1, >= 1.992.0) aws-sigv4 (~> 1.9) base64 jmespath (~> 1, >= 1.6.1) logger - aws-sdk-kms (1.104.0) + aws-sdk-kms (1.105.0) aws-sdk-core (~> 3, >= 3.225.0) aws-sigv4 (~> 1.5) - aws-sdk-s3 (1.189.1) + aws-sdk-s3 (1.190.0) aws-sdk-core (~> 3, >= 3.225.0) aws-sdk-kms (~> 1) aws-sigv4 (~> 1.5) @@ -182,6 +245,8 @@ GEM aws-eventstream (~> 1, >= 1.0.2) base64 (0.3.0) bcrypt (3.1.20) + bcrypt-ruby (3.1.5) + bcrypt (>= 3.1.3) bcrypt_pbkdf (1.1.1) benchmark (0.4.1) bigdecimal (3.2.2) @@ -210,7 +275,7 @@ GEM rexml crass (1.0.6) date (3.4.1) - debug (1.10.0) + debug (1.11.0) irb (~> 1.10) reline (>= 0.3.8) dotenv (3.1.8) @@ -243,6 +308,8 @@ GEM addressable (>= 2.5.0) globalid (1.2.1) activesupport (>= 6.1) + google-id-token (1.4.2) + jwt (>= 1) hashdiff (1.2.0) i18n (1.14.7) concurrent-ruby (~> 1.0) @@ -263,6 +330,8 @@ GEM activesupport (>= 5.0.0) jmespath (1.6.2) json (2.12.2) + jwt (2.10.1) + base64 language_server-protocol (3.17.0.5) lint_roller (1.1.0) logger (1.7.0) @@ -292,11 +361,15 @@ GEM railties (>= 7.1) stimulus-rails turbo-rails + mocha (2.7.1) + ruby2_keywords (>= 0.0.5) msgpack (1.8.0) + multi_json (1.15.0) multipart-post (2.4.1) + mysql2 (0.5.6) net-http (0.6.0) uri - net-imap (0.5.8) + net-imap (0.5.9) date net-protocol net-pop (0.1.2) @@ -343,6 +416,7 @@ GEM public_suffix (6.0.2) puma (6.6.0) nio4r (~> 2.0) + pwned (2.4.1) raabro (1.4.0) racc (1.8.1) rack (3.1.16) @@ -355,6 +429,10 @@ GEM rack (>= 1.3) rackup (2.2.1) rack (>= 3) + rails-controller-testing (1.0.5) + actionpack (>= 5.0.1.rc1) + actionview (>= 5.0.1.rc1) + activesupport (>= 5.0.1.rc1) rails-dom-testing (2.3.0) activesupport (>= 5.0.0) minitest @@ -365,7 +443,7 @@ GEM rainbow (3.1.1) rake (13.3.0) rb_sys (0.9.106) - rdoc (6.14.0) + rdoc (6.14.1) erb psych (>= 4.0.0) redcarpet (3.6.1) @@ -410,6 +488,7 @@ GEM ruby-vips (2.2.3) ffi (~> 1.12) logger + ruby2_keywords (0.0.5) rubyzip (2.4.1) securerandom (0.4.1) selenium-webdriver (4.33.0) @@ -461,10 +540,10 @@ GEM railties (>= 6.0.0) stringio (3.1.7) thor (1.3.2) - thruster (0.1.13) - thruster (0.1.13-arm64-darwin) - thruster (0.1.13-x86_64-darwin) - thruster (0.1.13-x86_64-linux) + thruster (0.1.14) + thruster (0.1.14-arm64-darwin) + thruster (0.1.14-x86_64-darwin) + thruster (0.1.14-x86_64-linux) tiktoken_ruby (0.0.11.1) rb_sys (= 0.9.106) tiktoken_ruby (0.0.11.1-arm64-darwin) @@ -506,6 +585,7 @@ PLATFORMS DEPENDENCIES actiontext-lexical! active_record-tenanted! + activeresource aws-sdk-s3 bcrypt (~> 3.1.7) bootsnap @@ -513,15 +593,21 @@ DEPENDENCIES bundler-audit capybara debug + file_repository (~> 1.4.5)! geared_pagination (~> 1.2) image_processing (~> 1.14) importmap-rails jbuilder kamal! mission_control-jobs + mocha + mysql2 platform_agent + portfolio (>= 4.6)! propshaft puma (>= 5.0) + queenbee! + queuety! rails! rails_structured_logging! redcarpet @@ -532,6 +618,8 @@ DEPENDENCIES selenium-webdriver sentry-rails sentry-ruby + service_concurrency_prevention! + signal_id! solid_cable (>= 3.0) solid_cache (~> 1.0) solid_queue (~> 1.1) diff --git a/app/assets/stylesheets/actiontext-lexical.css b/app/assets/stylesheets/actiontext-lexical.css index a8a3e227a..dac5999f8 100644 --- a/app/assets/stylesheets/actiontext-lexical.css +++ b/app/assets/stylesheets/actiontext-lexical.css @@ -1,136 +1,201 @@ -lexical-editor { - .node--selected { - &:not(:has(img)) { - box-shadow: 0 0 0 var(--hover-size) var(--hover-color); +@layer components { + lexical-editor { + display: block; + position: relative; + overflow: visible; + + .node--selected:not(.attachment--custom) { + &:not(:has(img)) { + box-shadow: 0 0 0 var(--hover-size) var(--hover-color); + } + + &:has(img) { + img { + box-shadow: 0 0 0 var(--hover-size) var(--hover-color); + } + } } - &:has(img) { - img { - box-shadow: 0 0 0 var(--hover-size) var(--hover-color); + &.lexical-editor--empty { + .lexical-editor__content::before { + content: attr(placeholder); + color: currentColor; + cursor: text; + opacity: 0.66; + pointer-events: none; + position: absolute; + white-space: pre-line; } } } - &.lexical-editor--empty { - .lexical-editor__content::before { - content: attr(placeholder); - color: currentColor; - cursor: text; - opacity: 0.66; - pointer-events: none; - position: absolute; - white-space: pre-line; + .lexical-dialog-actions { + display: flex; + font-size: var(--text-x-small); + flex: 1 1 0%; + gap: var(--inline-space-half); + margin-block-start: var(--block-space-half); + + .btn { + --radius: 0.3em; + + inline-size: 100%; + justify-content: center; + + &:is([type="submit"]) { + --btn-background: var(--card-color, var(--color-link)); + --btn-color: var(--color-ink-inverted); + --outline-color: var(--card-color, var(--color-link)); + } } - } -} -.lexical-dialog-actions { - display: flex; - font-size: var(--text-x-small); - flex: 1 1 0%; - gap: var(--inline-space-half); - margin-block-start: var(--block-space-half); - - .btn { - --radius: 0.3em; - - inline-size: 100%; - justify-content: center; - - &:is([type="submit"]) { - --btn-background: var(--card-color, var(--color-link)); - --btn-color: var(--color-ink-inverted); - --outline-color: var(--card-color, var(--color-link)); + span { + inline-size: 100%; } } - span { - inline-size: 100%; - } -} + .lexical-editor__content { + --outline-size: max(2px, 0.08em); -.lexical-editor__content { - --outline-size: max(2px, 0.08em); + margin: var(--block-space-half) 0; + min-block-size: calc(7lh + var(--block-space)); + outline: 0; + padding: var(--block-space-half) var(--inline-space); - margin: var(--block-space-half) 0; - min-block-size: calc(7lh + var(--block-space)); - outline: 0; - padding: var(--block-space-half) var(--inline-space); + * { + &:first-child { + margin-block-start: 0; + } - * { - &:first-child { - margin-block-start: 0; - } - - &:last-child { - margin-block-end: 0; - } - } -} - -.lexical-editor--drag-over { - background-color: var(--color-selected); - border-radius: 4px; - outline: 2px dashed var(--color-selected-dark); -} - -lexical-toolbar { - background-color: inherit; - border-block-end: 1px solid var(--color-ink-light); - color: currentColor; - display: flex; - font-size: inherit; - margin: 0; - max-inline-size: 100%; - padding: 0.2em 0; - position: relative; - - dialog { - background-color: var(--color-canvas); - border: 1px solid var(--color-ink-lighter); - border-radius: 0.5em; - box-shadow: var(--shadow); - color: var(--color-ink); - padding: var(--block-space) calc(var(--inline-space) * 1.5); - position: absolute; - z-index: 1; - - .input[type="url"] { - min-inline-size: 30ch; + &:last-child { + margin-block-end: 0; + } } } - > button { - aspect-ratio: 4/3.5; - appearance: none; - background-color: transparent; - block-size: 2em; - border: none; - border-radius: 0.2em; + .lexical-editor--drag-over { + background-color: var(--color-selected); + border-radius: 4px; + outline: 2px dashed var(--color-selected-dark); + } + + lexical-toolbar { + background-color: inherit; + border-block-end: 1px solid var(--color-ink-light); color: currentColor; - cursor: pointer; - display: grid; + display: flex; font-size: inherit; - inline-size: auto; - place-items: center; - transition: background-color 300ms ease; + margin: 0; + max-inline-size: 100%; + padding: 0.2em 0; + position: relative; - svg { - -webkit-touch-callout: none; - block-size: 0.85em; - fill: currentColor; - grid-area: 1/1; + dialog { + background-color: var(--color-canvas); + border: 1px solid var(--color-ink-lighter); + border-radius: 0.5em; + box-shadow: var(--shadow); + color: var(--color-ink); + padding: var(--block-space) calc(var(--inline-space) * 1.5); + position: absolute; + z-index: 1; + + .input[type="url"] { + min-inline-size: 30ch; + } + } + + > button { + aspect-ratio: 4/3.5; + appearance: none; + background-color: transparent; + block-size: 2em; + border: none; + border-radius: 0.2em; + color: currentColor; + cursor: pointer; + display: grid; + font-size: inherit; inline-size: auto; - user-select: none; - } + place-items: center; + transition: background-color 300ms ease; - &:is(:focus, :hover) { - background-color: var(--color-ink-lighter); - box-shadow: none; - } + svg { + -webkit-touch-callout: none; + block-size: 0.85em; + fill: currentColor; + grid-area: 1/1; + inline-size: auto; + user-select: none; + } - &:is(:active) { + &:is(:focus, :hover) { + background-color: var(--color-ink-lighter); + box-shadow: none; + } + + &:is(:active) { + background-color: var(--color-selected); + } + } + } + + /* Prompt + /* ------------------------------------------------------------------------ */ + + .lexical-prompt-menu { + --lexical-prompt-avatar-size: 24px; + --lexical-prompt-min-width: 20ch; + --lexical-prompt-padding: 0.5ch; + + background-color: var(--color-canvas); + border-radius: calc(var(--lexical-prompt-padding) * 2); + box-shadow: var(--shadow); + font-size: var(--text-small); + list-style: none; + margin: 0; + min-inline-size: var(--lexical-prompt-min-width); + padding: var(--lexical-prompt-padding); + visibility: hidden; + z-index: var(--z-popup); + } + + .lexical-prompt-menu--visible { + visibility: initial; + } + + .lexical-prompt-menu__item { + align-items: center; + border-radius: 0.5ch; + cursor: pointer; + display: flex; + gap: var(--lexical-prompt-padding); + padding: var(--lexical-prompt-padding); + + &:hover { background-color: var(--color-selected); } + + &[aria-selected] { + box-shadow: 0 0 0 var(--hover-size) var(--color-selected-dark) inset; + + &:hover { + --hover-size: 0; + } + } + + img { + block-size: var(--lexical-prompt-avatar-size); + border-radius: 50%; + flex-shrink: 0; + inline-size: var(--lexical-prompt-avatar-size); + margin: 0; + } + } + + /* Empty state */ + .lexical-prompt-menu__item--empty { + color: var(--color-ink-medium); + padding: var(--lexical-prompt-padding); } } diff --git a/app/assets/stylesheets/base.css b/app/assets/stylesheets/base.css index 425ff7156..0ddaf0c8c 100644 --- a/app/assets/stylesheets/base.css +++ b/app/assets/stylesheets/base.css @@ -90,7 +90,7 @@ border-radius: 0.3em; box-shadow: 0 0.1em 0 currentColor; font-family: var(--font-mono); - font-size: var(--text-x-small); + font-size: 0.8em; font-weight: 600; opacity: 0.7; padding: 0 0.4em; diff --git a/app/assets/stylesheets/buttons.css b/app/assets/stylesheets/buttons.css index d28368044..4c6e84874 100644 --- a/app/assets/stylesheets/buttons.css +++ b/app/assets/stylesheets/buttons.css @@ -225,43 +225,5 @@ inline-size: 100%; } } - - .btn__group--chunky { - --gap: 0.2em; - - gap: var(--gap); - margin: var(--block-space-half) auto calc(var(--block-space-half) / 2); - - .btn { - --btn-border-radius: 0.4em; - - align-content: end; - aspect-ratio: 5/3; - flex-basis: calc(33% - var(--gap) / 2); - flex-direction: column; - font-size: var(--text-small); - line-height: 1; - justify-content: center; - position: relative; - row-gap: 0.3lh; - text-align: center; - - kbd { - border: none; - box-shadow: none; - inset: 0.66em 0.33em auto auto; - opacity: 0.5; - position: absolute; - } - - .icon { - --icon-size: 2em !important; - } - - span { - display: flex; - } - } - } } diff --git a/app/assets/stylesheets/cards.css b/app/assets/stylesheets/cards.css index 3342d7b51..d6282acda 100644 --- a/app/assets/stylesheets/cards.css +++ b/app/assets/stylesheets/cards.css @@ -86,6 +86,7 @@ } .card__content { + contain: inline-size; flex: 2 1 auto; max-inline-size: 100%; } diff --git a/app/assets/stylesheets/dialog.css b/app/assets/stylesheets/dialog.css index 93d40ee1d..40158b06a 100644 --- a/app/assets/stylesheets/dialog.css +++ b/app/assets/stylesheets/dialog.css @@ -1,7 +1,6 @@ @layer components { :is(.dialog) { --backdrop-speed: 150ms; - --panel-size: max-content; --speed: 150ms; border: 0; diff --git a/app/assets/stylesheets/fizzy-menu.css b/app/assets/stylesheets/fizzy-menu.css index 8e68174bb..6fdd18e3b 100644 --- a/app/assets/stylesheets/fizzy-menu.css +++ b/app/assets/stylesheets/fizzy-menu.css @@ -1,28 +1,72 @@ -.fizzy-dialog { - --panel-border-color: var(--color-selected-dark); - --panel-border-radius: 1.4em; +@layer components { + .fizzy-dialog:is(.panel) { + --panel-border-color: var(--color-selected-dark); + --panel-border-radius: 1.4em; + --panel-size: 45ch; - box-shadow: 0 0 0 1px oklch(var(--lch-blue-medium) / 5%), - 0 0.2em 0.2em oklch(var(--lch-blue-medium) / 5%), - 0 0.4em 0.4em oklch(var(--lch-blue-medium) / 5%), - 0 0.8em 0.8em oklch(var(--lch-blue-medium) / 5%); - z-index: 5; -} + box-shadow: 0 0 0 1px oklch(var(--lch-blue-medium) / 5%), + 0 0.2em 0.2em oklch(var(--lch-blue-medium) / 5%), + 0 0.4em 0.4em oklch(var(--lch-blue-medium) / 5%), + 0 0.8em 0.8em oklch(var(--lch-blue-medium) / 5%); + } -.fizzy-menu { - --input-border-color: var(--color-selected-dark); - --input-padding: 0.2em 2em 0.2em 0.9em; + .input:is(.fizzy-menu) { + --input-border-color: var(--color-selected-dark); + --input-padding: 0.2em 2em 0.2em 0.9em; - box-shadow: 0 0 0 1px oklch(var(--lch-blue-medium) / 5%), - 0 0.2em 0.2em oklch(var(--lch-blue-medium) / 5%), - 0 0.4em 0.4em oklch(var(--lch-blue-medium) / 5%), - 0 0.8em 0.8em oklch(var(--lch-blue-medium) / 5%); - margin-block-start: 0.1em; + box-shadow: 0 0 0 1px oklch(var(--lch-blue-medium) / 5%), + 0 0.2em 0.2em oklch(var(--lch-blue-medium) / 5%), + 0 0.4em 0.4em oklch(var(--lch-blue-medium) / 5%), + 0 0.8em 0.8em oklch(var(--lch-blue-medium) / 5%); + margin-block-start: 0.1em; - svg { - block-size: auto; - inline-size: 1.3em; - margin-block-end: 0.2em; - margin-inline-end: 0.8ch; + svg { + block-size: auto; + inline-size: 1.3em; + margin-block-end: 0.2em; + margin-inline-end: 0.8ch; + } + } + + .fizzy-menu__hotkeys { + --gap: 8px; + + gap: var(--gap); + margin: var(--block-space) auto calc(var(--block-space-half) / 2); + + .btn { + --btn-border-radius: 0.4em; + + align-content: end; + aspect-ratio: 5/3; + background-color: var(--color-ink-lightest); + border-radius: 0.5em; + flex-basis: calc((100% - var(--gap) * 2) / 3); + flex-direction: column; + font-size: var(--text-small); + line-height: 1; + justify-content: center; + overflow: hidden; + position: relative; + row-gap: 0.3lh; + text-align: center; + + kbd { + border: none; + box-shadow: none; + inset: 0.66em 0.33em auto auto; + opacity: 0.5; + position: absolute; + } + + .icon { + --icon-size: 2em !important; + } + + span { + display: flex; + text-wrap: nowrap; + } + } } } \ No newline at end of file diff --git a/app/assets/stylesheets/knobs.css b/app/assets/stylesheets/knobs.css index 6014eb345..390fa6d5a 100644 --- a/app/assets/stylesheets/knobs.css +++ b/app/assets/stylesheets/knobs.css @@ -30,11 +30,22 @@ translate: -50% -50%; z-index: 1; + &::-moz-range-track { + block-size: var(--knob-size); + cursor: grab; + } + &::-webkit-slider-runnable-track { block-size: var(--knob-size); cursor: grab; } + &::-moz-range-thumb { + background-color: transparent; + border: none; + border-radius: 0; + } + &::-webkit-slider-thumb { appearance: none; background-color: transparent; diff --git a/app/assets/stylesheets/popup.css b/app/assets/stylesheets/popup.css index d450a46a2..0d1c27807 100644 --- a/app/assets/stylesheets/popup.css +++ b/app/assets/stylesheets/popup.css @@ -1,14 +1,13 @@ @layer components { .popup { --panel-border-radius: 0.5em; - --panel-padding: var(--block-space) var(--block-space) 0 var(--block-space); + --panel-padding: var(--block-space); --panel-size: auto; - inline-size: auto; inset: 0 auto auto 50%; max-block-size: 80vh; - min-inline-size: min(25ch, 90vw); - max-inline-size: min(55ch, 90vw); + min-inline-size: min(25ch, calc(100dvw - var(--block-space-double))); + max-inline-size: min(55ch, calc(100dvw - var(--block-space-double))); overflow: auto; position: absolute; transform: translateX(-50%); @@ -18,15 +17,14 @@ display: flex; } + &:has(.popup__footer) { + --panel-padding: var(--block-space) var(--block-space) 0 var(--block-space) + } + #header:has(&) { max-inline-size: 100vw; position: relative; z-index: 5; - - .popup { - min-inline-size: 40ch; - max-inline-size: 40ch; - } } .card & { @@ -37,14 +35,19 @@ .popup__footer { background-color: var(--color-canvas); - inset: auto 0 0 0; - padding: var(--block-space); + border-block-start: 1px solid var(--color-ink-lighter); + font-size: var(--text-small); + inset: auto 0 0; + margin-inline: calc(var(--block-space) * -1); + padding: var(--block-space-half) var(--inline-space) var(--block-space); position: sticky; + text-align: center; z-index: 1; } .popup__group { - background: var(--color-ink-lightest); + background: transparent; + border-radius: 0.3em; max-inline-size: 100%; padding-inline: var(--inline-space); @@ -61,13 +64,12 @@ } &[aria-selected] { - border-radius: 0.3em; box-shadow: 0 0 0 var(--hover-size) var(--color-selected-dark) inset; } } .popup__item { - --btn-background: var(--color-ink-lightest); + --btn-background: transparent; --btn-border-radius: 0.3em; --btn-border-size: 0; --btn-font-weight: 500; @@ -114,27 +116,39 @@ } .popup__list { + display: flex; + flex-direction: column; + inline-size: 100%; + margin: 0; + max-inline-size: 100%; + padding: 0; row-gap: 2px; } .popup__new { --btn-icon-size: 1em; + padding-inline-start: var(--inline-space-half); + .icon { margin-inline-end: 0.5em; } - - padding-inline-start: var(--inline-space-half); } .popup__title { font-weight: 800; } + + .popup__group-title { + font-size: var(--text-small); + margin-block: var(--block-space-half); + padding-inline: var(--inline-space); + text-transform: uppercase; + } } .popup--animated { --backdrop-speed: 150ms; - --panel-size: max-content; --speed: 150ms; opacity: 0; diff --git a/app/assets/stylesheets/rich-text-content.css b/app/assets/stylesheets/rich-text-content.css index 9b8c91cf6..e78805f95 100644 --- a/app/assets/stylesheets/rich-text-content.css +++ b/app/assets/stylesheets/rich-text-content.css @@ -20,7 +20,7 @@ :where(h5) { font-size: 0.83em; } :where(h6) { font-size: 0.67em; } - :where(p, ul, ol, dl, blockquote, figure, .attachment) { + :where(p, ul, ol, dl, blockquote, figure:not(.attachment--custom), .attachment:not(.attachment--custom)) { margin-block: var(--block-margin); overflow-wrap: break-word; text-wrap: pretty; @@ -54,7 +54,7 @@ } /* Attachments */ - .attachment { + .attachment:not(.attachment--custom) { block-size: auto; display: inline-block; inline-size: 100%; @@ -220,15 +220,6 @@ text-decoration: underline; } - /* Attachment junk gets wrapped in a paragraph causing unwanted space */ - p:has(action-text-attachment) { - display: none; - - + * { - margin-block-start: 0; - } - } - /* Avoid extra space due to empty paragraphs */ p:empty { display: none; @@ -329,5 +320,44 @@ } } } + + /* Custom attachments such as mentions, etc. */ + .attachment--custom { + display: inline; + padding: 0; + margin: 0; + white-space: normal; + } + + .attachment--custom, + action-text-attachment[content-type^='application/vnd.actiontext'] { + --attachment-image-size: 1em; + --attachment-selected-color: oklch(var(--lch-blue-dark)); + + align-items: center; + display: inline-flex; + gap: 0.25ch; + position: relative; + vertical-align: bottom; + + lexical-editor & { + cursor: pointer; + } + + img { + block-size: var(--attachment-image-size); + border-radius: 50%; + inline-size: var(--attachment-image-size); + } + + &.node--selected { + background: var(--attachment-selected-color); + box-shadow: + -0.25ch 0 0 var(--attachment-selected-color), + 0.5ch 0 0 var(--attachment-selected-color); + border-radius: 99rem; + color: var(--color-ink-inverted); + } + } } } diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index af52c2fd6..37f663be8 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -1,5 +1,5 @@ class ApplicationController < ActionController::Base - include Authentication, CurrentTimezone, SetPlatform, WriterAffinity + include Authentication, CurrentRequest, CurrentTimezone, SetPlatform, WriterAffinity stale_when_importmap_changes allow_browser versions: :modern diff --git a/app/controllers/concerns/authentication.rb b/app/controllers/concerns/authentication.rb index 746d3cda5..dc3ee7bb7 100644 --- a/app/controllers/concerns/authentication.rb +++ b/app/controllers/concerns/authentication.rb @@ -2,6 +2,7 @@ module Authentication extend ActiveSupport::Concern included do + before_action :set_current_account before_action :require_authentication helper_method :authenticated? end @@ -16,6 +17,12 @@ module Authentication skip_before_action :require_authentication, **options before_action :resume_session, **options end + + def require_untenanted_access(**options) + skip_before_action :set_current_account, **options + skip_before_action :require_authentication, **options + before_action :redirect_tenanted_request, **options + end end private @@ -40,7 +47,12 @@ module Authentication def request_authentication session[:return_to_after_authenticating] = request.url - redirect_to new_session_path + + if Rails.application.config.x.local_authentication + redirect_to new_session_path + else + redirect_to Launchpad.login_url(account: Current.account), allow_other_host: true + end end def after_authentication_url @@ -51,6 +63,9 @@ module Authentication redirect_to root_url if authenticated? end + def redirect_tenanted_request + redirect_to root_url if ApplicationRecord.current_tenant + end def start_new_session_for(user) user.sessions.create!(user_agent: request.user_agent, ip_address: request.remote_ip).tap do |session| @@ -64,6 +79,10 @@ module Authentication cookies.signed.permanent[:session_token] = { value: session.signed_id, httponly: true, same_site: :lax } end + def set_current_account + Current.account = Account.first + end + def terminate_session Current.session.destroy cookies.delete(:session_token) diff --git a/app/controllers/concerns/current_request.rb b/app/controllers/concerns/current_request.rb new file mode 100644 index 000000000..b7e4af73d --- /dev/null +++ b/app/controllers/concerns/current_request.rb @@ -0,0 +1,13 @@ +module CurrentRequest + extend ActiveSupport::Concern + + included do + before_action do + Current.http_method = request.method + Current.request_id = request.uuid + Current.user_agent = request.user_agent + Current.ip_address = request.ip + Current.referrer = request.referrer + end + end +end diff --git a/app/controllers/first_runs_controller.rb b/app/controllers/first_runs_controller.rb deleted file mode 100644 index e13b91b76..000000000 --- a/app/controllers/first_runs_controller.rb +++ /dev/null @@ -1,19 +0,0 @@ -class FirstRunsController < ApplicationController - allow_unauthenticated_access - - before_action :prevent_repeats - - def show - end - - def create - user = FirstRun.create!(params.expect(user: [ :name, :email_address, :password ])) - start_new_session_for user - redirect_to root_path - end - - private - def prevent_repeats - redirect_to root_path if Account.any? - end -end diff --git a/app/controllers/prompts/cards_controller.rb b/app/controllers/prompts/cards_controller.rb new file mode 100644 index 000000000..e20ae39a2 --- /dev/null +++ b/app/controllers/prompts/cards_controller.rb @@ -0,0 +1,32 @@ +class Prompts::CardsController < ApplicationController + MAX_RESULTS = 10 + + def index + @cards = if filter_param.present? + prepending_exact_matches_by_id(published_cards.mentioning(params[:filter])) + else + @cards = published_cards.latest + end + + if stale? etag: @cards + render layout: false + end + end + + private + def filter_param + params[:filter] + end + + def published_cards + Current.user.accessible_cards.published.limit(MAX_RESULTS) + end + + def prepending_exact_matches_by_id(cards) + if card_by_id = Current.user.accessible_cards.find_by_id(params[:filter]) + [ card_by_id ] + cards + else + cards + end + end +end diff --git a/app/controllers/prompts/collections/users_controller.rb b/app/controllers/prompts/collections/users_controller.rb new file mode 100644 index 000000000..1802f8fd1 --- /dev/null +++ b/app/controllers/prompts/collections/users_controller.rb @@ -0,0 +1,11 @@ +class Prompts::Collections::UsersController < ApplicationController + include CollectionScoped + + def index + @users = @collection.users + + if stale? etag: @users + render layout: false + end + end +end diff --git a/app/controllers/sessions/launchpad_controller.rb b/app/controllers/sessions/launchpad_controller.rb new file mode 100644 index 000000000..0e2ad16a7 --- /dev/null +++ b/app/controllers/sessions/launchpad_controller.rb @@ -0,0 +1,27 @@ +class Sessions::LaunchpadController < ApplicationController + require_unauthenticated_access + + before_action :store_sig, only: :show + before_action :restore_and_clear_sig, only: :update + + def show + end + + def update + if user = Current.account.signal_account.authenticate(sig: @sig).try(:peer) + start_new_session_for user + redirect_to after_authentication_url + else + render plain: "Authentication failed. This is probably a bug.", status: :unauthorized + end + end + + private + def store_sig + cookies[:_fizzy_launchpad_sig] = params.expect(:sig) + end + + def restore_and_clear_sig + @sig = cookies.delete :_fizzy_launchpad_sig + end +end diff --git a/app/controllers/signup/accounts_controller.rb b/app/controllers/signup/accounts_controller.rb new file mode 100644 index 000000000..4c0cf9de2 --- /dev/null +++ b/app/controllers/signup/accounts_controller.rb @@ -0,0 +1,30 @@ +class Signup::AccountsController < Signup::BaseController + before_action :reset_signup_storage + rate_limit to: 10, within: 3.minutes, only: :create, with: -> { redirect_to new_signup_account_path, alert: "Try again later." } + + def new + @signup = Signup.new + end + + def create + @signup = Signup.new(signup_params) + + if @signup.recognized? + store_signup + redirect_to Launchpad.authentication_url(purpose: "signup", login_hint: @signup.email_address, redirect_uri: signup_session_url), allow_other_host: true + elsif @signup.process + redirect_to_account(@signup.account) + else + render :new, status: :unprocessable_entity + end + end + + private + def signup_params + params.require(:signup).permit(*Signup::PERMITTED_KEYS) + end + + def store_signup + @signup.to_h.each { |key, value| signup_storage[key.to_s] = value } + end +end diff --git a/app/controllers/signup/base_controller.rb b/app/controllers/signup/base_controller.rb new file mode 100644 index 000000000..322cf2549 --- /dev/null +++ b/app/controllers/signup/base_controller.rb @@ -0,0 +1,40 @@ +class Signup::BaseController < ApplicationController + require_untenanted_access + before_action :redirect_if_local_auth + + http_basic_authenticate_with( + name: Rails.application.credentials.dig(:account_signup_http_basic_auth, :name), + password: Rails.application.credentials.dig(:account_signup_http_basic_auth, :password) + ) + + attr_reader :authenticated_identity + + private + def signup_storage + session[:signup] ||= {} + end + + def reset_signup_storage + session.delete(:signup) + end + + def authenticated_identity=(identity) + @authenticated_identity = identity + signup_storage["identity_id"] = identity.try(:id) + end + + def set_authenticated_identity + if identity_id = signup_storage["identity_id"] + @authenticated_identity = SignalId::Identity.find_by(id: identity_id) + end + end + + def redirect_to_account(account) + redirect_to account.signal_account.owner.remote_login_url(proceed_to: root_path), + allow_other_host: true + end + + def redirect_if_local_auth + render plain: "Unauthorized", status: :unauthorized if Rails.application.config.x.local_authentication + end +end diff --git a/app/controllers/signup/completions_controller.rb b/app/controllers/signup/completions_controller.rb new file mode 100644 index 000000000..4f97055a0 --- /dev/null +++ b/app/controllers/signup/completions_controller.rb @@ -0,0 +1,23 @@ +class Signup::CompletionsController < Signup::BaseController + before_action :set_authenticated_identity, only: :create + + def new + end + + def create + @signup = Signup.new(signup_params) + @signup.signal_identity = authenticated_identity + + if @signup.process + reset_signup_storage + redirect_to_account(@signup.account) + else + render plain: "Could not complete signup. This is probably a bug.", status: :unprocessable_entity, layout: false + end + end + + private + def signup_params + signup_storage.slice(*Signup::PERMITTED_KEYS.map(&:to_s)) + end +end diff --git a/app/controllers/signup/sessions_controller.rb b/app/controllers/signup/sessions_controller.rb new file mode 100644 index 000000000..b33c0cf2f --- /dev/null +++ b/app/controllers/signup/sessions_controller.rb @@ -0,0 +1,9 @@ +class Signup::SessionsController < Signup::BaseController + def create + if self.authenticated_identity = SignalId::Identity.authenticate(params.permit(:sig)) + redirect_to new_signup_completion_url + else + render plain: "Authentication failed. This is probably a bug.", status: :unauthorized + end + end +end diff --git a/app/helpers/notifications_helper.rb b/app/helpers/notifications_helper.rb index 4638e0590..d36a034ad 100644 --- a/app/helpers/notifications_helper.rb +++ b/app/helpers/notifications_helper.rb @@ -35,7 +35,7 @@ module NotificationsHelper button_to read_notification_path(notification), class: "card__notification-unread-indicator btn btn--circle borderless", title: "Mark as read", - data: { turbo_frame: "_top" } do + data: { action: "form#submit:stop", controller: "form", form_target: "submit", turbo_frame: "_top" } do concat(icon_tag("remove-med")) concat(tag.span("Mark as read", class: "for-screen-reader")) end diff --git a/app/helpers/rich_text_helper.rb b/app/helpers/rich_text_helper.rb new file mode 100644 index 000000000..2e0b63a93 --- /dev/null +++ b/app/helpers/rich_text_helper.rb @@ -0,0 +1,9 @@ +module RichTextHelper + def mentions_prompt(collection) + content_tag "lexical-prompt", "", trigger: "@", src: prompts_collection_users_path(collection), name: "mention" + end + + def cards_prompt + content_tag "lexical-prompt", "", trigger: "#", src: prompts_cards_path, name: "card", "insert-editable-text": true, "remote-filtering": true + end +end diff --git a/app/models/account.rb b/app/models/account.rb index afd022441..25b74efde 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -1,5 +1,14 @@ class Account < ApplicationRecord - include Entropic, Joinable + include Entropic, Joinable, SignalAccount has_many_attached :uploads + + def setup_basic_template + user = User.first + + Closure::Reason.create_defaults + Collection.create!(name: "Cards", creator: user, all_access: true) + workflow = Workflow.create!(name: "Basic") + Collection.first.update!(workflow: workflow) + end end diff --git a/app/models/account/signal_account.rb b/app/models/account/signal_account.rb new file mode 100644 index 000000000..610a4ed9c --- /dev/null +++ b/app/models/account/signal_account.rb @@ -0,0 +1,29 @@ +module Account::SignalAccount + extend ActiveSupport::Concern + + included do + unless Rails.application.config.x.local_authentication + # TODO: remove the "optional: true" once we've populated the accounts properly + belongs_to :signal_account, class_name: "SignalId::Account", primary_key: :queenbee_id, foreign_key: :queenbee_id, optional: true + end + end + + class_methods do + def create_with_admin_user(queenbee_id:) + new(queenbee_id: queenbee_id).tap do |account| + SignalId::Database.on_master do + account.name = account.signal_account.name + account.save! + + User.create!( + name: account.signal_account.owner.name, + email_address: account.signal_account.owner.email_address, + signal_user_id: account.signal_account.owner.id, + role: "admin", + password: SecureRandom.hex(36) # TODO: remove password column? + ) + end + end + end + end +end diff --git a/app/models/concerns/mentions.rb b/app/models/concerns/mentions.rb index 79ca110bd..86d017533 100644 --- a/app/models/concerns/mentions.rb +++ b/app/models/concerns/mentions.rb @@ -19,6 +19,10 @@ module Mentions private def scan_mentionees + mentionees_from_plain_text | mentionees_from_rich_text + end + + def mentionees_from_plain_text scan_mentioned_handles.filter_map do |mention| mentionable_users.find { |user| user.mentionable_handles.include?(mention) } end @@ -28,6 +32,14 @@ module Mentions mentionable_content.scan(/(? error + destroy_tenant + destroy_queenbee_account + destroy_signal_identity + + errors.add(:base, "An error occurred during signup: #{error.message}") + + false + end + + def recognized? + SignalId::Identity.find_by_email_address(email_address).present? + end + + def tenant_name + @tenant_name ||= signal_account.subdomain + end + + def to_h + { + full_name: full_name, + email_address: email_address, + company_name: company_name + }.compact_blank + end + + private + def create_signal_identity + unless signal_identity.present? + SignalId::Database.on_master do + @signal_identity = build_new_identity.tap(&:save!) + end + end + end + + def create_queenbee_account + @queenbee_account = Queenbee::Remote::Account.create!(queenbee_account_attributes) + SignalId::Database.on_master do + @signal_account = SignalId::Account.find_by(queenbee_id: @queenbee_account.id) + end + end + + def create_tenant(destroy_existing_tenant: false, &block) + ApplicationRecord.destroy_tenant(tenant_name) if destroy_existing_tenant + ApplicationRecord.create_tenant(tenant_name) do + @account = Account.create_with_admin_user(queenbee_id: queenbee_account.id) + @account.setup_basic_template + @user = User.first + end + end + + def destroy_tenant + if queenbee_account && ApplicationRecord.tenant_exist?(tenant_name) + ApplicationRecord.destroy_tenant(tenant_name) + @account = nil + @user = nil + end + end + + def destroy_queenbee_account + queenbee_account&.cancel + @queenbee_account = nil + end + + def destroy_signal_identity + SignalId::Database.on_master do + signal_identity&.destroy + end + @signal_identity = nil + end + + def validate_new_identity + unless signal_identity.present? + build_new_identity.tap do |identity| + unless identity.valid? + identity.errors.each { |error| errors.add(error.attribute, error.message) } + end + end + end + end + + def build_new_identity + SignalId::Identity.new do |identity| + identity.name = full_name || email_address + identity.email_address = email_address + identity.username = email_address + identity.password = password + end + end + + def queenbee_account_attributes + { + skip_remote: true, # Fizzy creates its own local account + product_name: "fizzy", + name: queenbee_account_name, + owner_identity_id: signal_identity.id, + trial: false, + subscription: subscription_attributes, + remote_request: request_attributes + } + end + + def subscription_attributes + subscription = FreeV1Subscription + { name: subscription.to_param, price: subscription.price } + end + + def request_attributes + { remote_address: Current.ip_address, user_agent: Current.user_agent, referrer: Current.referrer } + end + + def queenbee_account_name + name = company_name.presence || signal_identity.name + name += " (Beta)" if Rails.env.beta? + name + end +end diff --git a/app/models/subscription.rb b/app/models/subscription.rb new file mode 100644 index 000000000..cf9113b54 --- /dev/null +++ b/app/models/subscription.rb @@ -0,0 +1,9 @@ +class Subscription < Queenbee::Subscription + SHORT_NAMES = %w[ FreeV1 ] +end + +class FreeV1Subscription < Subscription + property :proper_name, "Free Subscription" + property :price, 0 + property :frequency, "yearly" +end diff --git a/app/models/user.rb b/app/models/user.rb index d68ccff46..5e295c4dd 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -1,5 +1,5 @@ class User < ApplicationRecord - include Accessor, Assignee, Mentionable, Named, Role, Transferable + include Accessor, ActionText::Attachable, Assignee, Mentionable, Named, Role, SignalUser, Transferable include Timelined # Depends on Accessor has_one_attached :avatar diff --git a/app/models/user/mentionable.rb b/app/models/user/mentionable.rb index 7a930ab02..be82f3d95 100644 --- a/app/models/user/mentionable.rb +++ b/app/models/user/mentionable.rb @@ -3,6 +3,11 @@ module User::Mentionable included do has_many :mentions, dependent: :destroy, inverse_of: :mentionee + + # Need to set in the included block so that it overrides Action Text's + def to_attachable_partial_path + "users/attachable" + end end def mentioned_by(mentioner, at:) @@ -13,6 +18,10 @@ module User::Mentionable [ initials, first_name, first_name_with_last_name_initial ].collect(&:downcase) end + def content_type + "application/vnd.actiontext.mention" + end + private def first_name_with_last_name_initial "#{first_name}#{last_name&.first}" diff --git a/app/models/user/signal_user.rb b/app/models/user/signal_user.rb new file mode 100644 index 000000000..924cfe87d --- /dev/null +++ b/app/models/user/signal_user.rb @@ -0,0 +1,9 @@ +module User::SignalUser + extend ActiveSupport::Concern + + included do + unless Rails.application.config.x.local_authentication + belongs_to :signal_user, dependent: :destroy, class_name: "SignalId::User", optional: true + end + end +end diff --git a/app/views/cards/assignments/new.html.erb b/app/views/cards/assignments/new.html.erb index a5abad19c..9ab019cac 100644 --- a/app/views/cards/assignments/new.html.erb +++ b/app/views/cards/assignments/new.html.erb @@ -12,7 +12,7 @@ type: "search", autocorrect: "off", autocomplete: "off", data: { "1p-ignore": "true", filter_target: "input", action: "input->filter#filter" } %> <% end %> - - People - + <% if platform.desktop? %> -