mirror of
https://github.com/basecamp/once-campfire.git
synced 2026-10-28 00:16:48 +09:00
Compare commits
214
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2021bc3296 | ||
|
|
56e2a287ea | ||
|
|
6c5ceca6b5 | ||
|
|
bf2b9cc61b | ||
|
|
fadf6f1859 | ||
|
|
931f6b2c38 | ||
|
|
93718f7408 | ||
|
|
417f087964 | ||
|
|
58a25c6281 | ||
|
|
00abff4088 | ||
|
|
b1122c7d6d | ||
|
|
2fb599e21c | ||
|
|
c78f03fda7 | ||
|
|
9474e70f5a | ||
|
|
a1dd6e1d8a | ||
|
|
d3e6602b88 | ||
|
|
7a5931fe2a | ||
|
|
26b3d2abb0 | ||
|
|
681bd78ea2 | ||
|
|
8f249cbde4 | ||
|
|
27f7b8a9b0 | ||
|
|
05ba551cef | ||
|
|
c3616f81b4 | ||
|
|
3a140f65b2 | ||
|
|
049fc920d3 | ||
|
|
6ba5d2ece3 | ||
|
|
a67ecde390 | ||
|
|
e4ef1785e8 | ||
|
|
6e312c6028 | ||
|
|
ee5fe3717a | ||
|
|
27f5461067 | ||
|
|
8bbe129030 | ||
|
|
765711f27d | ||
|
|
3c022a1641 | ||
|
|
2b9685c35c | ||
|
|
dbc7620a76 | ||
|
|
c49f53d8f8 | ||
|
|
6288430f37 | ||
|
|
7df98ac883 | ||
|
|
27065ef489 | ||
|
|
8a6e4290d8 | ||
|
|
14a2f8f550 | ||
|
|
4ceb92ea04 | ||
|
|
72648a8f41 | ||
|
|
b409b9998f | ||
|
|
3005154114 | ||
|
|
a991adb19d | ||
|
|
cbf52805bd | ||
|
|
0f2a1da560 | ||
|
|
6ad4735b19 | ||
|
|
819b3896fb | ||
|
|
b6d307537e | ||
|
|
fdec7dfc9f | ||
|
|
0d5998f057 | ||
|
|
87eafc025f | ||
|
|
1e0d353c60 | ||
|
|
cf63b61361 | ||
|
|
bf5dc0d74d | ||
|
|
839ea34c29 | ||
|
|
c48083dcfe | ||
|
|
15f56134e7 | ||
|
|
5f198146e8 | ||
|
|
9912e63d69 | ||
|
|
91036c5085 | ||
|
|
462eff10df | ||
|
|
414ff3376e | ||
|
|
7b16014f01 | ||
|
|
a196a93ed2 | ||
|
|
7e5dd695c9 | ||
|
|
32b4144b52 | ||
|
|
d2241f16db | ||
|
|
ff1a742f27 | ||
|
|
53520d4444 | ||
|
|
e0c0a1dc79 | ||
|
|
9d75c8b7eb | ||
|
|
1cb2f69786 | ||
|
|
e07da58668 | ||
|
|
20275e1fa7 | ||
|
|
d2155e85a0 | ||
|
|
5c72c35775 | ||
|
|
78a84a6ab7 | ||
|
|
21c49b3ccc | ||
|
|
c09c464e9a | ||
|
|
b39766c8d6 | ||
|
|
b67a0fb706 | ||
|
|
4690de5057 | ||
|
|
77c5234cb6 | ||
|
|
d485db6038 | ||
|
|
77d4eb0d3a | ||
|
|
2393f01ba2 | ||
|
|
d1f09b4276 | ||
|
|
c761763c8c | ||
|
|
259c06bea1 | ||
|
|
6913afa864 | ||
|
|
c73ea37e0c | ||
|
|
e0e31846c9 | ||
|
|
f2bdfde610 | ||
|
|
a740581f20 | ||
|
|
288ee9dc5c | ||
|
|
acef0c71cf | ||
|
|
ce4c05da81 | ||
|
|
f864403c2c | ||
|
|
d6b3dfd64d | ||
|
|
88fbeedc75 | ||
|
|
fbeb1ae993 | ||
|
|
ad541b1c6a | ||
|
|
345e63718f | ||
|
|
e12651240c | ||
|
|
ca626ea7e2 | ||
|
|
e6de359872 | ||
|
|
33c4b726e7 | ||
|
|
6288a10633 | ||
|
|
edaab3e5d1 | ||
|
|
506c2771fe | ||
|
|
955d799d11 | ||
|
|
1d39b8cbe1 | ||
|
|
b8be941b99 | ||
|
|
c057484dfb | ||
|
|
74bdbb0764 | ||
|
|
3212a683ee | ||
|
|
6cdcb459b9 | ||
|
|
cf0ba58d8d | ||
|
|
5cb4316133 | ||
|
|
254dd1d46f | ||
|
|
6c7f8fa15f | ||
|
|
659f95748a | ||
|
|
67b6df3847 | ||
|
|
957c9da9c4 | ||
|
|
862e128722 | ||
|
|
26c70b3254 | ||
|
|
ae149fca92 | ||
|
|
90b330024d | ||
|
|
e5cfffda67 | ||
|
|
87ba7aa3cc | ||
|
|
12249b9b3a | ||
|
|
5c5821a395 | ||
|
|
8fa6138fb9 | ||
|
|
beaf467a38 | ||
|
|
9a258bd1a0 | ||
|
|
f8e36e9761 | ||
|
|
83e7a7f4c9 | ||
|
|
eadbaab7d2 | ||
|
|
b5d3543e64 | ||
|
|
1694accbf8 | ||
|
|
8bdff5ddc6 | ||
|
|
6acad0549d | ||
|
|
6e7dd2fa0a | ||
|
|
eab554aa4b | ||
|
|
1bb7ef7c17 | ||
|
|
c38e77a897 | ||
|
|
9b1602d088 | ||
|
|
47bc5f5425 | ||
|
|
07f2482970 | ||
|
|
91d294f4a0 | ||
|
|
9ff6b4a381 | ||
|
|
e3b4cbc674 | ||
|
|
b7da9c63f7 | ||
|
|
ed0f9a5dba | ||
|
|
0ed0af44ef | ||
|
|
977cbcd135 | ||
|
|
5742dfaf73 | ||
|
|
8722057545 | ||
|
|
e6022a52c3 | ||
|
|
c1ad057db8 | ||
|
|
436ea06457 | ||
|
|
9e19658ee0 | ||
|
|
32e3e5aea8 | ||
|
|
ef4b88d748 | ||
|
|
eceec2898b | ||
|
|
c3ae67a2b6 | ||
|
|
3a501cd32c | ||
|
|
ef147d17db | ||
|
|
9dd19d0c95 | ||
|
|
79eb9a5516 | ||
|
|
94a48aacb6 | ||
|
|
c7ad91c088 | ||
|
|
e63402d60e | ||
|
|
6e25ddd92b | ||
|
|
fbc3fba364 | ||
|
|
b827d7ca9b | ||
|
|
22b6d4bdaf | ||
|
|
431aad8b71 | ||
|
|
aa9fe42810 | ||
|
|
db5275918f | ||
|
|
9310b002d7 | ||
|
|
dfd2dd2bca | ||
|
|
c860b51109 | ||
|
|
487125dc4c | ||
|
|
e33b7e50c9 | ||
|
|
f1aec61336 | ||
|
|
baeccebd90 | ||
|
|
7ab769a9f9 | ||
|
|
1e9f681b3a | ||
|
|
656215b186 | ||
|
|
39bab76e38 | ||
|
|
e8251401ce | ||
|
|
3ca1dcbf77 | ||
|
|
766bffae56 | ||
|
|
80c9e7fbfe | ||
|
|
d3f22d78e1 | ||
|
|
6ed9331a97 | ||
|
|
2aa4141077 | ||
|
|
27e9257b2a | ||
|
|
dd247a623b | ||
|
|
3b509f55ca | ||
|
|
ee37809220 | ||
|
|
5c5c82b27a | ||
|
|
2d749057f9 | ||
|
|
20ab5b1bc6 | ||
|
|
e8c1349aac | ||
|
|
c82a8d38c0 | ||
|
|
057d56513e | ||
|
|
5340f3da01 | ||
|
|
d4a56784e0 |
No files matched your search
@@ -50,3 +50,6 @@
|
|||||||
|
|
||||||
# Ignore test files
|
# Ignore test files
|
||||||
/test/
|
/test/
|
||||||
|
|
||||||
|
# Agent session state — worktrees under .claude/ are whole extra checkouts of this repo
|
||||||
|
.claude
|
||||||
@@ -20,13 +20,12 @@ updates:
|
|||||||
semver-minor-days: 3
|
semver-minor-days: 3
|
||||||
semver-patch-days: 2
|
semver-patch-days: 2
|
||||||
default-days: 7
|
default-days: 7
|
||||||
|
exclude:
|
||||||
|
- "brakeman"
|
||||||
|
|
||||||
- package-ecosystem: docker
|
# No docker ecosystem. Our base image tag is a build arg
|
||||||
directory: "/"
|
# (`FROM ruby:$RUBY_VERSION-slim`), which the updater cannot read, so the
|
||||||
schedule:
|
# entry only ever matched nothing — worse than no entry, because it looked
|
||||||
interval: weekly
|
# like coverage. Ruby base image bumps are a manual, reviewed step here, as
|
||||||
cooldown:
|
# they are across the rest of the fleet. Keep Dockerfile, Dockerfile-export
|
||||||
semver-major-days: 7
|
# and .ruby-version on the same version by hand.
|
||||||
semver-minor-days: 3
|
|
||||||
semver-patch-days: 2
|
|
||||||
default-days: 7
|
|
||||||
+40
-14
@@ -4,7 +4,7 @@ on:
|
|||||||
push:
|
push:
|
||||||
branches: [ main ]
|
branches: [ main ]
|
||||||
pull_request:
|
pull_request:
|
||||||
branches: [ main ]
|
branches: [ main, "release-*" ]
|
||||||
|
|
||||||
permissions: {}
|
permissions: {}
|
||||||
|
|
||||||
@@ -16,12 +16,12 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Set up Ruby
|
- name: Set up Ruby
|
||||||
uses: ruby/setup-ruby@319994f95fa847cf3fb3cd3dbe89f6dcde9f178f # v1.295.0
|
uses: ruby/setup-ruby@14594264cd68ce8a2345dd349bc3d138a4ef85c8 # v1.327.0
|
||||||
with:
|
with:
|
||||||
ruby-version: .ruby-version
|
ruby-version: .ruby-version
|
||||||
bundler-cache: true
|
bundler-cache: true
|
||||||
@@ -35,12 +35,12 @@ jobs:
|
|||||||
contents: read
|
contents: read
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Set up Ruby
|
- name: Set up Ruby
|
||||||
uses: ruby/setup-ruby@319994f95fa847cf3fb3cd3dbe89f6dcde9f178f # v1.295.0
|
uses: ruby/setup-ruby@14594264cd68ce8a2345dd349bc3d138a4ef85c8 # v1.327.0
|
||||||
with:
|
with:
|
||||||
ruby-version: .ruby-version
|
ruby-version: .ruby-version
|
||||||
bundler-cache: true
|
bundler-cache: true
|
||||||
@@ -48,6 +48,29 @@ jobs:
|
|||||||
- name: Lint code for consistent style
|
- name: Lint code for consistent style
|
||||||
run: bin/rubocop
|
run: bin/rubocop
|
||||||
|
|
||||||
|
herb:
|
||||||
|
name: Herb check
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
steps:
|
||||||
|
- name: Install packages
|
||||||
|
run: sudo apt-get update && sudo apt-get install --no-install-recommends -y libvips
|
||||||
|
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
|
- name: Set up Ruby
|
||||||
|
uses: ruby/setup-ruby@a0102e0972be65f351c307e2d64b9314a57c8073 # v1.324.0
|
||||||
|
with:
|
||||||
|
ruby-version: .ruby-version
|
||||||
|
bundler-cache: true
|
||||||
|
|
||||||
|
- name: Check that every HTML template compiles through Herb
|
||||||
|
run: bin/rails herb:check
|
||||||
|
|
||||||
lint-actions:
|
lint-actions:
|
||||||
name: GitHub Actions audit
|
name: GitHub Actions audit
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
@@ -55,15 +78,15 @@ jobs:
|
|||||||
contents: read
|
contents: read
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
|
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Run actionlint
|
- name: Run actionlint
|
||||||
uses: rhysd/actionlint@393031adb9afb225ee52ae2ccd7a5af5525e03e8 # v1.7.11
|
uses: rhysd/actionlint@914e7df21a07ef503a81201c76d2b11c789d3fca # v1.7.12
|
||||||
|
|
||||||
- name: Run zizmor
|
- name: Run zizmor
|
||||||
uses: zizmorcore/zizmor-action@71321a20a9ded102f6e9ce5718a2fcec2c4f70d8 # v0.5.2
|
uses: zizmorcore/zizmor-action@cc914d7f3750a2d13d75c7f184a1060aa0e9d482 # v0.6.4
|
||||||
with:
|
with:
|
||||||
advanced-security: false
|
advanced-security: false
|
||||||
|
|
||||||
@@ -79,15 +102,15 @@ jobs:
|
|||||||
options: --health-cmd "redis-cli ping" --health-interval 10s --health-timeout 5s --health-retries 5
|
options: --health-cmd "redis-cli ping" --health-interval 10s --health-timeout 5s --health-retries 5
|
||||||
steps:
|
steps:
|
||||||
- name: Install packages
|
- name: Install packages
|
||||||
run: sudo apt-get update && sudo apt-get install --no-install-recommends -y libsqlite3-0 libvips curl ffmpeg
|
run: sudo apt-get update && sudo apt-get install --no-install-recommends -y libsqlite3-0 sqlite3 libvips curl ffmpeg
|
||||||
|
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Set up Ruby
|
- name: Set up Ruby
|
||||||
uses: ruby/setup-ruby@319994f95fa847cf3fb3cd3dbe89f6dcde9f178f # v1.295.0
|
uses: ruby/setup-ruby@14594264cd68ce8a2345dd349bc3d138a4ef85c8 # v1.327.0
|
||||||
env:
|
env:
|
||||||
REDIS_URL: redis://localhost:6379/0
|
REDIS_URL: redis://localhost:6379/0
|
||||||
with:
|
with:
|
||||||
@@ -97,6 +120,9 @@ jobs:
|
|||||||
- name: Run tests
|
- name: Run tests
|
||||||
run: bin/rails db:setup test
|
run: bin/rails db:setup test
|
||||||
|
|
||||||
|
- name: Test benchmark response validation
|
||||||
|
run: bundle exec ruby bench/test_http_client.rb
|
||||||
|
|
||||||
test_system:
|
test_system:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
permissions:
|
permissions:
|
||||||
@@ -109,15 +135,15 @@ jobs:
|
|||||||
options: --health-cmd "redis-cli ping" --health-interval 10s --health-timeout 5s --health-retries 5
|
options: --health-cmd "redis-cli ping" --health-interval 10s --health-timeout 5s --health-retries 5
|
||||||
steps:
|
steps:
|
||||||
- name: Install packages
|
- name: Install packages
|
||||||
run: sudo apt-get update && sudo apt-get install --no-install-recommends -y libsqlite3-0 libvips curl ffmpeg
|
run: sudo apt-get update && sudo apt-get install --no-install-recommends -y libsqlite3-0 sqlite3 libvips curl ffmpeg
|
||||||
|
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Set up Ruby
|
- name: Set up Ruby
|
||||||
uses: ruby/setup-ruby@319994f95fa847cf3fb3cd3dbe89f6dcde9f178f # v1.295.0
|
uses: ruby/setup-ruby@14594264cd68ce8a2345dd349bc3d138a4ef85c8 # v1.327.0
|
||||||
env:
|
env:
|
||||||
REDIS_URL: redis://localhost:6379/0
|
REDIS_URL: redis://localhost:6379/0
|
||||||
with:
|
with:
|
||||||
|
|||||||
@@ -44,15 +44,15 @@ jobs:
|
|||||||
IMAGE_NAME: ${{ github.repository }}
|
IMAGE_NAME: ${{ github.repository }}
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
|
uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
|
||||||
|
|
||||||
- name: Log in to GHCR
|
- name: Log in to GHCR
|
||||||
uses: docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1 # v3.5.0
|
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||||
with:
|
with:
|
||||||
registry: ${{ env.REGISTRY }}
|
registry: ${{ env.REGISTRY }}
|
||||||
username: ${{ github.actor }}
|
username: ${{ github.actor }}
|
||||||
@@ -80,7 +80,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Extract Docker metadata (tags, labels) with arch suffix
|
- name: Extract Docker metadata (tags, labels) with arch suffix
|
||||||
id: meta
|
id: meta
|
||||||
uses: docker/metadata-action@c1e51972afc2121e065aed6d45c65596fe445f3f # v5.8.0
|
uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0
|
||||||
with:
|
with:
|
||||||
images: ${{ steps.vars.outputs.canonical }}
|
images: ${{ steps.vars.outputs.canonical }}
|
||||||
tags: |
|
tags: |
|
||||||
@@ -98,7 +98,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Build and push (${{ matrix.platform }})
|
- name: Build and push (${{ matrix.platform }})
|
||||||
id: build
|
id: build
|
||||||
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
|
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
file: Dockerfile
|
file: Dockerfile
|
||||||
@@ -118,7 +118,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Attest image provenance (per-arch)
|
- name: Attest image provenance (per-arch)
|
||||||
if: github.event_name != 'pull_request'
|
if: github.event_name != 'pull_request'
|
||||||
uses: actions/attest-build-provenance@977bb373ede98d70efdf65b84cb5f73e068dcc2a # v3.0.0
|
uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2
|
||||||
with:
|
with:
|
||||||
subject-name: ${{ steps.vars.outputs.canonical }}
|
subject-name: ${{ steps.vars.outputs.canonical }}
|
||||||
subject-digest: ${{ steps.build.outputs.digest }}
|
subject-digest: ${{ steps.build.outputs.digest }}
|
||||||
@@ -139,10 +139,10 @@ jobs:
|
|||||||
IMAGE_NAME: ${{ github.repository }}
|
IMAGE_NAME: ${{ github.repository }}
|
||||||
steps:
|
steps:
|
||||||
- name: Set up Docker Buildx (for imagetools)
|
- name: Set up Docker Buildx (for imagetools)
|
||||||
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
|
uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
|
||||||
|
|
||||||
- name: Log in to GHCR
|
- name: Log in to GHCR
|
||||||
uses: docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1 # v3.5.0
|
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||||
with:
|
with:
|
||||||
registry: ${{ env.REGISTRY }}
|
registry: ${{ env.REGISTRY }}
|
||||||
username: ${{ github.actor }}
|
username: ${{ github.actor }}
|
||||||
@@ -159,7 +159,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Compute base tags (no suffix)
|
- name: Compute base tags (no suffix)
|
||||||
id: meta
|
id: meta
|
||||||
uses: docker/metadata-action@c1e51972afc2121e065aed6d45c65596fe445f3f # v5.8.0
|
uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0
|
||||||
with:
|
with:
|
||||||
images: ${{ steps.vars.outputs.canonical }}
|
images: ${{ steps.vars.outputs.canonical }}
|
||||||
tags: |
|
tags: |
|
||||||
@@ -207,7 +207,7 @@ jobs:
|
|||||||
done <<< "$tags"
|
done <<< "$tags"
|
||||||
|
|
||||||
- name: Install Cosign
|
- name: Install Cosign
|
||||||
uses: sigstore/cosign-installer@d58896d6a1865668819e1d91763c7751a165e159 # v3.9.2
|
uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2
|
||||||
|
|
||||||
- name: Cosign sign all tags (keyless OIDC)
|
- name: Cosign sign all tags (keyless OIDC)
|
||||||
shell: bash
|
shell: bash
|
||||||
|
|||||||
+1
-1
@@ -1 +1 @@
|
|||||||
3.4.5
|
3.4.10
|
||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
# syntax = docker/dockerfile:1
|
# syntax = docker/dockerfile:1
|
||||||
|
|
||||||
# Make sure it matches the Ruby version in .ruby-version and Gemfile
|
# Make sure it matches the Ruby version in .ruby-version and Gemfile
|
||||||
ARG RUBY_VERSION=3.4.5
|
ARG RUBY_VERSION=3.4.10
|
||||||
FROM docker.io/library/ruby:$RUBY_VERSION-slim AS base
|
FROM docker.io/library/ruby:$RUBY_VERSION-slim AS base
|
||||||
|
|
||||||
# Rails app lives here
|
# Rails app lives here
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
# syntax = docker/dockerfile:1
|
# syntax = docker/dockerfile:1
|
||||||
|
|
||||||
# Make sure it matches the Ruby version in .ruby-version and Gemfile
|
# Make sure it matches the Ruby version in .ruby-version and Gemfile
|
||||||
ARG RUBY_VERSION=3.4.7
|
ARG RUBY_VERSION=3.4.10
|
||||||
FROM ruby:$RUBY_VERSION-slim as base
|
FROM ruby:$RUBY_VERSION-slim as base
|
||||||
|
|
||||||
# Install the tools we need
|
# Install the tools we need
|
||||||
|
|||||||
@@ -26,6 +26,9 @@ gem "importmap-rails", github: "rails/importmap-rails"
|
|||||||
gem "turbo-rails", github: "hotwired/turbo-rails"
|
gem "turbo-rails", github: "hotwired/turbo-rails"
|
||||||
gem "stimulus-rails"
|
gem "stimulus-rails"
|
||||||
|
|
||||||
|
# Rich text
|
||||||
|
gem "lexxy", "~> 0.9.24"
|
||||||
|
|
||||||
# Media handling
|
# Media handling
|
||||||
gem "image_processing", ">= 1.2"
|
gem "image_processing", ">= 1.2"
|
||||||
|
|
||||||
@@ -40,7 +43,7 @@ gem "rqrcode"
|
|||||||
gem "rails_autolink"
|
gem "rails_autolink"
|
||||||
gem "geared_pagination"
|
gem "geared_pagination"
|
||||||
gem "jbuilder"
|
gem "jbuilder"
|
||||||
gem "net-http-persistent"
|
gem "surfguard", github: "basecamp/surfguard" # The SSRF address policy behind RestrictedHTTP
|
||||||
gem "kredis"
|
gem "kredis"
|
||||||
gem "platform_agent"
|
gem "platform_agent"
|
||||||
gem "thruster"
|
gem "thruster"
|
||||||
|
|||||||
+78
-54
@@ -1,3 +1,9 @@
|
|||||||
|
GIT
|
||||||
|
remote: https://github.com/basecamp/surfguard.git
|
||||||
|
revision: 59e278c01a537755f22791429051891949231ead
|
||||||
|
specs:
|
||||||
|
surfguard (0.2.0)
|
||||||
|
|
||||||
GIT
|
GIT
|
||||||
remote: https://github.com/hotwired/turbo-rails.git
|
remote: https://github.com/hotwired/turbo-rails.git
|
||||||
revision: 30cd8fcc6f82c1ad4edd1ed6069ba878f21f02b3
|
revision: 30cd8fcc6f82c1ad4edd1ed6069ba878f21f02b3
|
||||||
@@ -17,16 +23,16 @@ GIT
|
|||||||
|
|
||||||
GIT
|
GIT
|
||||||
remote: https://github.com/rails/propshaft.git
|
remote: https://github.com/rails/propshaft.git
|
||||||
revision: e49a9de659ff27462015e54dd832e86e762a6ddc
|
revision: dc979db89cd07c72ee4d11d415ae1cb4fd072623
|
||||||
specs:
|
specs:
|
||||||
propshaft (1.2.1)
|
propshaft (1.3.2)
|
||||||
actionpack (>= 7.0.0)
|
actionpack (>= 7.0.0)
|
||||||
activesupport (>= 7.0.0)
|
activesupport (>= 7.0.0)
|
||||||
rack
|
rack
|
||||||
|
|
||||||
GIT
|
GIT
|
||||||
remote: https://github.com/rails/rails.git
|
remote: https://github.com/rails/rails.git
|
||||||
revision: 1a02651ac37fb64b4de2a2b73461d86acf9c98fb
|
revision: e3d5c569d217c56c022b04acb127a8c24dbe9685
|
||||||
branch: main
|
branch: main
|
||||||
specs:
|
specs:
|
||||||
actioncable (8.2.0.alpha)
|
actioncable (8.2.0.alpha)
|
||||||
@@ -57,10 +63,10 @@ GIT
|
|||||||
rack-session (>= 1.0.1)
|
rack-session (>= 1.0.1)
|
||||||
rack-test (>= 0.6.3)
|
rack-test (>= 0.6.3)
|
||||||
rails-dom-testing (~> 2.2)
|
rails-dom-testing (~> 2.2)
|
||||||
rails-html-sanitizer (~> 1.6)
|
rails-html-sanitizer (~> 1.7)
|
||||||
useragent (~> 0.16)
|
useragent (~> 0.16)
|
||||||
actiontext (8.2.0.alpha)
|
actiontext (8.2.0.alpha)
|
||||||
action_text-trix (~> 2.1.15)
|
action_text-trix (~> 2.1.16)
|
||||||
actionpack (= 8.2.0.alpha)
|
actionpack (= 8.2.0.alpha)
|
||||||
activerecord (= 8.2.0.alpha)
|
activerecord (= 8.2.0.alpha)
|
||||||
activestorage (= 8.2.0.alpha)
|
activestorage (= 8.2.0.alpha)
|
||||||
@@ -71,11 +77,12 @@ GIT
|
|||||||
activesupport (= 8.2.0.alpha)
|
activesupport (= 8.2.0.alpha)
|
||||||
builder (~> 3.1)
|
builder (~> 3.1)
|
||||||
erubi (~> 1.11)
|
erubi (~> 1.11)
|
||||||
|
herb (>= 0.10)
|
||||||
rails-dom-testing (~> 2.2)
|
rails-dom-testing (~> 2.2)
|
||||||
rails-html-sanitizer (~> 1.6)
|
rails-html-sanitizer (~> 1.7)
|
||||||
activejob (8.2.0.alpha)
|
activejob (8.2.0.alpha)
|
||||||
activesupport (= 8.2.0.alpha)
|
activesupport (= 8.2.0.alpha)
|
||||||
globalid (>= 0.3.6)
|
globalid (>= 1.4.0)
|
||||||
activemodel (8.2.0.alpha)
|
activemodel (8.2.0.alpha)
|
||||||
activesupport (= 8.2.0.alpha)
|
activesupport (= 8.2.0.alpha)
|
||||||
activerecord (8.2.0.alpha)
|
activerecord (8.2.0.alpha)
|
||||||
@@ -87,7 +94,7 @@ GIT
|
|||||||
activejob (= 8.2.0.alpha)
|
activejob (= 8.2.0.alpha)
|
||||||
activerecord (= 8.2.0.alpha)
|
activerecord (= 8.2.0.alpha)
|
||||||
activesupport (= 8.2.0.alpha)
|
activesupport (= 8.2.0.alpha)
|
||||||
marcel (~> 1.0)
|
marcel (~> 2.0)
|
||||||
activesupport (8.2.0.alpha)
|
activesupport (8.2.0.alpha)
|
||||||
base64
|
base64
|
||||||
bigdecimal
|
bigdecimal
|
||||||
@@ -98,6 +105,8 @@ GIT
|
|||||||
json
|
json
|
||||||
logger (>= 1.4.2)
|
logger (>= 1.4.2)
|
||||||
minitest (>= 5.1)
|
minitest (>= 5.1)
|
||||||
|
psych (>= 4)
|
||||||
|
ractor-dispatch (>= 0.3.0)
|
||||||
securerandom (>= 0.3)
|
securerandom (>= 0.3)
|
||||||
tzinfo (~> 2.0, >= 2.0.5)
|
tzinfo (~> 2.0, >= 2.0.5)
|
||||||
uri (>= 0.13.1)
|
uri (>= 0.13.1)
|
||||||
@@ -136,8 +145,8 @@ GEM
|
|||||||
base64 (0.3.0)
|
base64 (0.3.0)
|
||||||
bcrypt (3.1.22)
|
bcrypt (3.1.22)
|
||||||
benchmark (0.5.0)
|
benchmark (0.5.0)
|
||||||
bigdecimal (3.3.1)
|
bigdecimal (4.1.3)
|
||||||
brakeman (8.0.5)
|
brakeman (8.1.0)
|
||||||
racc
|
racc
|
||||||
builder (3.3.0)
|
builder (3.3.0)
|
||||||
bundler-audit (0.9.3)
|
bundler-audit (0.9.3)
|
||||||
@@ -153,7 +162,7 @@ GEM
|
|||||||
regexp_parser (>= 1.5, < 3.0)
|
regexp_parser (>= 1.5, < 3.0)
|
||||||
xpath (~> 3.2)
|
xpath (~> 3.2)
|
||||||
chunky_png (1.4.0)
|
chunky_png (1.4.0)
|
||||||
concurrent-ruby (1.3.7)
|
concurrent-ruby (1.3.8)
|
||||||
connection_pool (2.5.5)
|
connection_pool (2.5.5)
|
||||||
crack (1.0.0)
|
crack (1.0.0)
|
||||||
bigdecimal
|
bigdecimal
|
||||||
@@ -164,10 +173,11 @@ GEM
|
|||||||
irb (~> 1.10)
|
irb (~> 1.10)
|
||||||
reline (>= 0.3.8)
|
reline (>= 0.3.8)
|
||||||
drb (2.2.3)
|
drb (2.2.3)
|
||||||
erb (6.0.4)
|
erb (6.0.7)
|
||||||
erubi (1.13.1)
|
erubi (1.13.1)
|
||||||
faker (3.5.2)
|
faker (3.8.0)
|
||||||
i18n (>= 1.8.11, < 2)
|
i18n (>= 1.8.11, < 2)
|
||||||
|
ffi (1.17.2)
|
||||||
ffi (1.17.2-aarch64-linux-gnu)
|
ffi (1.17.2-aarch64-linux-gnu)
|
||||||
ffi (1.17.2-arm64-darwin)
|
ffi (1.17.2-arm64-darwin)
|
||||||
ffi (1.17.2-x86_64-darwin)
|
ffi (1.17.2-x86_64-darwin)
|
||||||
@@ -175,23 +185,28 @@ GEM
|
|||||||
geared_pagination (1.2.0)
|
geared_pagination (1.2.0)
|
||||||
activesupport (>= 5.0)
|
activesupport (>= 5.0)
|
||||||
addressable (>= 2.5.0)
|
addressable (>= 2.5.0)
|
||||||
globalid (1.3.0)
|
globalid (1.4.0)
|
||||||
activesupport (>= 6.1)
|
activesupport (>= 6.1)
|
||||||
hashdiff (1.2.0)
|
hashdiff (1.2.0)
|
||||||
i18n (1.14.7)
|
herb (0.11.0-aarch64-linux-gnu)
|
||||||
|
herb (0.11.0-arm64-darwin)
|
||||||
|
herb (0.11.0-x86_64-darwin)
|
||||||
|
herb (0.11.0-x86_64-linux-gnu)
|
||||||
|
i18n (1.15.2)
|
||||||
concurrent-ruby (~> 1.0)
|
concurrent-ruby (~> 1.0)
|
||||||
image_processing (1.14.0)
|
image_processing (1.14.0)
|
||||||
mini_magick (>= 4.9.5, < 6)
|
mini_magick (>= 4.9.5, < 6)
|
||||||
ruby-vips (>= 2.0.17, < 3)
|
ruby-vips (>= 2.0.17, < 3)
|
||||||
io-console (0.8.1)
|
io-console (0.9.4)
|
||||||
irb (1.15.3)
|
irb (1.18.0)
|
||||||
pp (>= 0.6.0)
|
pp (>= 0.6.0)
|
||||||
|
prism (>= 1.3.0)
|
||||||
rdoc (>= 4.0.0)
|
rdoc (>= 4.0.0)
|
||||||
reline (>= 0.4.2)
|
reline (>= 0.4.2)
|
||||||
jbuilder (2.14.1)
|
jbuilder (2.14.1)
|
||||||
actionview (>= 7.0.0)
|
actionview (>= 7.0.0)
|
||||||
activesupport (>= 7.0.0)
|
activesupport (>= 7.0.0)
|
||||||
json (2.20.0)
|
json (2.21.2)
|
||||||
jwt (3.2.0)
|
jwt (3.2.0)
|
||||||
base64
|
base64
|
||||||
kredis (1.8.0)
|
kredis (1.8.0)
|
||||||
@@ -199,39 +214,41 @@ GEM
|
|||||||
activesupport (>= 6.0.0)
|
activesupport (>= 6.0.0)
|
||||||
redis (>= 4.2, < 6)
|
redis (>= 4.2, < 6)
|
||||||
language_server-protocol (3.17.0.5)
|
language_server-protocol (3.17.0.5)
|
||||||
|
lexxy (0.9.24)
|
||||||
|
rails (>= 8.0.2)
|
||||||
lint_roller (1.1.0)
|
lint_roller (1.1.0)
|
||||||
logger (1.7.0)
|
logger (1.7.0)
|
||||||
loofah (2.25.2)
|
loofah (2.25.2)
|
||||||
crass (~> 1.0.2)
|
crass (~> 1.0.2)
|
||||||
nokogiri (>= 1.12.0)
|
nokogiri (>= 1.12.0)
|
||||||
mail (2.9.0)
|
mail (2.9.1)
|
||||||
logger
|
logger
|
||||||
mini_mime (>= 0.1.1)
|
mini_mime (>= 0.1.1)
|
||||||
net-imap
|
net-imap
|
||||||
net-pop
|
net-pop
|
||||||
net-smtp
|
net-smtp
|
||||||
marcel (1.1.0)
|
marcel (2.1.0)
|
||||||
matrix (0.4.3)
|
matrix (0.4.3)
|
||||||
mini_magick (5.3.1)
|
mini_magick (5.3.1)
|
||||||
logger
|
logger
|
||||||
mini_mime (1.1.5)
|
mini_mime (1.1.5)
|
||||||
minitest (5.26.2)
|
minitest (6.0.6)
|
||||||
mocha (2.7.1)
|
drb (~> 2.0)
|
||||||
|
prism (~> 1.5)
|
||||||
|
mocha (3.1.0)
|
||||||
ruby2_keywords (>= 0.0.5)
|
ruby2_keywords (>= 0.0.5)
|
||||||
mono_logger (1.1.2)
|
mono_logger (1.1.2)
|
||||||
multi_json (1.17.0)
|
multi_json (1.17.0)
|
||||||
mustermann (3.0.4)
|
mustermann (3.0.4)
|
||||||
ruby2_keywords (~> 0.0.1)
|
ruby2_keywords (~> 0.0.1)
|
||||||
net-http-persistent (4.0.6)
|
net-imap (0.6.7)
|
||||||
connection_pool (~> 2.2, >= 2.2.4)
|
|
||||||
net-imap (0.6.4.1)
|
|
||||||
date
|
date
|
||||||
net-protocol
|
net-protocol
|
||||||
net-pop (0.1.2)
|
net-pop (0.1.2)
|
||||||
net-protocol
|
net-protocol
|
||||||
net-protocol (0.2.2)
|
net-protocol (0.4.0)
|
||||||
timeout
|
timeout
|
||||||
net-smtp (0.5.1)
|
net-smtp (0.5.2)
|
||||||
net-protocol
|
net-protocol
|
||||||
nio4r (2.7.5)
|
nio4r (2.7.5)
|
||||||
nokogiri (1.19.4-aarch64-linux-gnu)
|
nokogiri (1.19.4-aarch64-linux-gnu)
|
||||||
@@ -242,7 +259,7 @@ GEM
|
|||||||
racc (~> 1.4)
|
racc (~> 1.4)
|
||||||
nokogiri (1.19.4-x86_64-linux-gnu)
|
nokogiri (1.19.4-x86_64-linux-gnu)
|
||||||
racc (~> 1.4)
|
racc (~> 1.4)
|
||||||
openssl (3.3.0)
|
openssl (4.0.2)
|
||||||
ostruct (0.6.3)
|
ostruct (0.6.3)
|
||||||
parallel (1.27.0)
|
parallel (1.27.0)
|
||||||
parser (3.3.9.0)
|
parser (3.3.9.0)
|
||||||
@@ -251,18 +268,18 @@ GEM
|
|||||||
platform_agent (1.0.1)
|
platform_agent (1.0.1)
|
||||||
activesupport (>= 5.2.0)
|
activesupport (>= 5.2.0)
|
||||||
useragent (~> 0.16.3)
|
useragent (~> 0.16.3)
|
||||||
pp (0.6.3)
|
pp (0.6.4)
|
||||||
prettyprint
|
prettyprint
|
||||||
prettyprint (0.2.0)
|
prettyprint (0.2.0)
|
||||||
prism (1.4.0)
|
prism (1.9.0)
|
||||||
psych (5.2.6)
|
psych (5.5.0)
|
||||||
date
|
date
|
||||||
stringio
|
stringio
|
||||||
public_suffix (6.0.2)
|
public_suffix (6.0.2)
|
||||||
puma (7.2.1)
|
puma (7.2.1)
|
||||||
nio4r (~> 2.0)
|
nio4r (~> 2.0)
|
||||||
racc (1.8.1)
|
racc (1.8.1)
|
||||||
rack (3.2.6)
|
rack (3.2.7)
|
||||||
rack-protection (4.2.1)
|
rack-protection (4.2.1)
|
||||||
base64 (>= 0.1.0)
|
base64 (>= 0.1.0)
|
||||||
logger (>= 1.6.0)
|
logger (>= 1.6.0)
|
||||||
@@ -272,8 +289,9 @@ GEM
|
|||||||
rack (>= 3.0.0)
|
rack (>= 3.0.0)
|
||||||
rack-test (2.2.0)
|
rack-test (2.2.0)
|
||||||
rack (>= 1.3)
|
rack (>= 1.3)
|
||||||
rackup (2.2.1)
|
rackup (2.3.1)
|
||||||
rack (>= 3)
|
rack (>= 3)
|
||||||
|
ractor-dispatch (0.3.0)
|
||||||
rails-dom-testing (2.3.0)
|
rails-dom-testing (2.3.0)
|
||||||
activesupport (>= 5.0.0)
|
activesupport (>= 5.0.0)
|
||||||
minitest
|
minitest
|
||||||
@@ -286,19 +304,24 @@ GEM
|
|||||||
activesupport (> 3.1)
|
activesupport (> 3.1)
|
||||||
railties (> 3.1)
|
railties (> 3.1)
|
||||||
rainbow (3.1.1)
|
rainbow (3.1.1)
|
||||||
rake (13.3.1)
|
rake (13.4.2)
|
||||||
rdoc (6.16.1)
|
rbs (4.2.0)
|
||||||
|
logger
|
||||||
|
prism (>= 1.6.0)
|
||||||
|
tsort
|
||||||
|
rdoc (8.1.0)
|
||||||
erb
|
erb
|
||||||
psych (>= 4.0.0)
|
prism (>= 1.6.0)
|
||||||
|
rbs (>= 4.0.0)
|
||||||
tsort
|
tsort
|
||||||
redis (5.4.1)
|
redis (5.4.1)
|
||||||
redis-client (>= 0.22.0)
|
redis-client (>= 0.22.0)
|
||||||
redis-client (0.25.2)
|
redis-client (0.30.1)
|
||||||
connection_pool
|
connection_pool
|
||||||
redis-namespace (1.11.0)
|
redis-namespace (1.11.0)
|
||||||
redis (>= 4)
|
redis (>= 4)
|
||||||
regexp_parser (2.11.2)
|
regexp_parser (2.11.2)
|
||||||
reline (0.6.3)
|
reline (0.7.0)
|
||||||
io-console (~> 0.5)
|
io-console (~> 0.5)
|
||||||
resque (2.7.0)
|
resque (2.7.0)
|
||||||
mono_logger (~> 1)
|
mono_logger (~> 1)
|
||||||
@@ -346,9 +369,9 @@ GEM
|
|||||||
ffi (~> 1.12)
|
ffi (~> 1.12)
|
||||||
logger
|
logger
|
||||||
ruby2_keywords (0.0.5)
|
ruby2_keywords (0.0.5)
|
||||||
rubyzip (3.0.2)
|
rubyzip (3.7.0)
|
||||||
securerandom (0.4.1)
|
securerandom (0.4.1)
|
||||||
selenium-webdriver (4.35.0)
|
selenium-webdriver (4.49.0)
|
||||||
base64 (~> 0.2)
|
base64 (~> 0.2)
|
||||||
logger (~> 1.4)
|
logger (~> 1.4)
|
||||||
rexml (~> 3.2, >= 3.2.5)
|
rexml (~> 3.2, >= 3.2.5)
|
||||||
@@ -367,18 +390,18 @@ GEM
|
|||||||
rack-protection (= 4.2.1)
|
rack-protection (= 4.2.1)
|
||||||
rack-session (>= 2.0.0, < 3)
|
rack-session (>= 2.0.0, < 3)
|
||||||
tilt (~> 2.0)
|
tilt (~> 2.0)
|
||||||
sqlite3 (2.9.5-aarch64-linux-gnu)
|
sqlite3 (2.9.6-aarch64-linux-gnu)
|
||||||
sqlite3 (2.9.5-arm64-darwin)
|
sqlite3 (2.9.6-arm64-darwin)
|
||||||
sqlite3 (2.9.5-x86_64-darwin)
|
sqlite3 (2.9.6-x86_64-darwin)
|
||||||
sqlite3 (2.9.5-x86_64-linux-gnu)
|
sqlite3 (2.9.6-x86_64-linux-gnu)
|
||||||
stimulus-rails (1.3.4)
|
stimulus-rails (1.3.4)
|
||||||
railties (>= 6.0.0)
|
railties (>= 6.0.0)
|
||||||
stringio (3.1.8)
|
stringio (3.2.0)
|
||||||
thor (1.4.0)
|
thor (1.5.0)
|
||||||
thruster (0.1.15-aarch64-linux)
|
thruster (0.1.23-aarch64-linux)
|
||||||
thruster (0.1.15-arm64-darwin)
|
thruster (0.1.23-arm64-darwin)
|
||||||
thruster (0.1.15-x86_64-darwin)
|
thruster (0.1.23-x86_64-darwin)
|
||||||
thruster (0.1.15-x86_64-linux)
|
thruster (0.1.23-x86_64-linux)
|
||||||
tilt (2.6.1)
|
tilt (2.6.1)
|
||||||
timeout (0.6.1)
|
timeout (0.6.1)
|
||||||
tsort (0.2.0)
|
tsort (0.2.0)
|
||||||
@@ -389,9 +412,9 @@ GEM
|
|||||||
unicode-emoji (4.2.0)
|
unicode-emoji (4.2.0)
|
||||||
uri (1.1.1)
|
uri (1.1.1)
|
||||||
useragent (0.16.11)
|
useragent (0.16.11)
|
||||||
web-push (3.0.2)
|
web-push (3.1.0)
|
||||||
jwt (~> 3.0)
|
jwt (~> 3.0)
|
||||||
openssl (~> 3.0)
|
openssl (>= 3.0)
|
||||||
webmock (3.25.1)
|
webmock (3.25.1)
|
||||||
addressable (>= 2.8.0)
|
addressable (>= 2.8.0)
|
||||||
crack (>= 0.3.2)
|
crack (>= 0.3.2)
|
||||||
@@ -403,7 +426,7 @@ GEM
|
|||||||
websocket-extensions (0.1.5)
|
websocket-extensions (0.1.5)
|
||||||
xpath (3.2.0)
|
xpath (3.2.0)
|
||||||
nokogiri (~> 1.8)
|
nokogiri (~> 1.8)
|
||||||
zeitwerk (2.7.3)
|
zeitwerk (2.8.3)
|
||||||
|
|
||||||
PLATFORMS
|
PLATFORMS
|
||||||
aarch64-linux
|
aarch64-linux
|
||||||
@@ -424,8 +447,8 @@ DEPENDENCIES
|
|||||||
importmap-rails!
|
importmap-rails!
|
||||||
jbuilder
|
jbuilder
|
||||||
kredis
|
kredis
|
||||||
|
lexxy (~> 0.9.24)
|
||||||
mocha
|
mocha
|
||||||
net-http-persistent
|
|
||||||
ostruct
|
ostruct
|
||||||
platform_agent
|
platform_agent
|
||||||
propshaft!
|
propshaft!
|
||||||
@@ -442,6 +465,7 @@ DEPENDENCIES
|
|||||||
sentry-ruby
|
sentry-ruby
|
||||||
sqlite3
|
sqlite3
|
||||||
stimulus-rails
|
stimulus-rails
|
||||||
|
surfguard!
|
||||||
thruster
|
thruster
|
||||||
turbo-rails!
|
turbo-rails!
|
||||||
web-push
|
web-push
|
||||||
|
|||||||
@@ -49,6 +49,23 @@ If you'd rather run the Docker image yourself, you can read more about that in t
|
|||||||
> that people have someone to contact if they need help with their account. If that bothers you, put in any
|
> that people have someone to contact if they need help with their account. If that bothers you, put in any
|
||||||
> email address you want and create yourself a new admin account.
|
> email address you want and create yourself a new admin account.
|
||||||
|
|
||||||
|
## Other implementations
|
||||||
|
|
||||||
|
Campfire also has implementations in Django, Laravel, Express, Elixir, Go, Rust and C:
|
||||||
|
|
||||||
|
| HTTP workload (requests/sec) | Rails | [Django](https://github.com/basecamp/once-campfire-django) | [Laravel](https://github.com/basecamp/once-campfire-laravel) | [Express](https://github.com/basecamp/once-campfire-express) | [Elixir](https://github.com/basecamp/once-campfire-elixir) | [Go](https://github.com/basecamp/once-campfire-go) | [Rust](https://github.com/basecamp/once-campfire-rust) | [C](https://github.com/basecamp/once-campfire-c) |
|
||||||
|
|---|---:|---:|---:|---:|---:|---:|---:|---:|
|
||||||
|
| Room page | 230 | 62 | 760 | 2,622 | 942 | 31,673 | 35,484 | 141,834 |
|
||||||
|
| Messages page | 402 | 70 | 924 | 3,245 | 1,267 | 30,746 | 40,674 | 151,564 |
|
||||||
|
| Sidebar | 468 | 229 | 1,383 | 34,938 | 2,515 | 18,586 | 34,479 | 159,850 |
|
||||||
|
| Search | 399 | 118 | 1,135 | 6,613 | 1,814 | 29,765 | 34,432 | 155,456 |
|
||||||
|
| Post a message | 248 | 112 | 498 | 2,088 | 1,400 | 9,073 | 8,998 | 7,460 |
|
||||||
|
|
||||||
|
[Shared verification](https://github.com/basecamp/once-campfire-verification) · [Detailed results](https://github.com/basecamp/once-campfire-verification/blob/main/docs/performance-review.md).
|
||||||
|
|
||||||
|
Measured with 16 concurrent clients on an AMD Ryzen AI MAX+ 395 with 32 GB RAM,
|
||||||
|
with four hardware cores allocated to each app.
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
You are welcome - and encouraged - to modify Campfire to your liking.
|
You are welcome - and encouraged - to modify Campfire to your liking.
|
||||||
|
|||||||
@@ -1,165 +1,160 @@
|
|||||||
trix-toolbar {
|
/* Resolve Lexxy's palette to Campfire's, in the editor and in rendered messages,
|
||||||
display: none;
|
so everything follows Campfire's light/dark color scheme */
|
||||||
inset: auto 0 0 0;
|
.lexxy-content {
|
||||||
min-width: 0;
|
--lexxy-color-canvas: transparent;
|
||||||
position: sticky;
|
--lexxy-color-text: var(--color-text);
|
||||||
z-index: 3;
|
--lexxy-color-text-subtle: var(--color-text);
|
||||||
|
--lexxy-color-link: var(--color-link);
|
||||||
|
--lexxy-color-ink: var(--color-text);
|
||||||
|
--lexxy-color-ink-inverted: var(--color-text-reversed);
|
||||||
|
--lexxy-color-ink-medium: var(--color-text);
|
||||||
|
--lexxy-color-ink-lighter: var(--color-border-darker);
|
||||||
|
--lexxy-color-ink-lightest: var(--color-bg);
|
||||||
|
--lexxy-color-selected: var(--color-selected, var(--color-bg));
|
||||||
|
--lexxy-color-code-bg: var(--color-bg);
|
||||||
|
|
||||||
.trix-dialog {
|
/* Campfire's message lines stack without extra block margins */
|
||||||
bottom: 3rem !important;
|
--lexxy-content-margin: 0;
|
||||||
top: auto !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.trix-button--icon {
|
|
||||||
height: auto !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.trix-dialog {
|
|
||||||
background-color: var(--color-bg) !important;
|
|
||||||
border-radius: 1rem !important;
|
|
||||||
border: 2px solid var(--color-text) !important;
|
|
||||||
box-shadow: none !important;
|
|
||||||
color: var(--color-text) !important;
|
|
||||||
gap: var(--inline-space);
|
|
||||||
padding: 0 !important;
|
|
||||||
overflow: clip;
|
|
||||||
|
|
||||||
.trix-input {
|
|
||||||
background: transparent !important;
|
|
||||||
border: 0 !important;
|
|
||||||
color: var(--color-text) !important;
|
|
||||||
font-size: max(16px, 1em) !important;
|
|
||||||
padding: var(--input-padding, 0.5em 0.8em) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.trix-button {
|
|
||||||
color: var(--color-text) !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.trix-dialog--link {
|
|
||||||
.trix-dialog__link-fields {
|
|
||||||
align-items: center;
|
|
||||||
padding-right: 0.3em;
|
|
||||||
}
|
|
||||||
|
|
||||||
[value="Link"],
|
|
||||||
[value="Unlink"] {
|
|
||||||
width: 2.6em;
|
|
||||||
aspect-ratio: 1 / 1;
|
|
||||||
background-position: center center;
|
|
||||||
background-repeat: no-repeat;
|
|
||||||
text-indent: -9999%;
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
[value="Link"] { background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' fill='none'%3E%3Cpath fill='%23000' fill-rule='evenodd' d='M18.948 5.258a4.337 4.337 0 0 0-6.108 0L11.217 6.87a.993.993 0 0 0 0 1.41c.392.39 1.027.39 1.418 0l1.623-1.613a2.323 2.323 0 0 1 3.271 0 2.29 2.29 0 0 1 0 3.251l-2.393 2.38a3.021 3.021 0 0 1-4.255 0l-.05-.049a1.007 1.007 0 0 0-1.418 0 .993.993 0 0 0 0 1.41l.05.049a5.036 5.036 0 0 0 7.091 0l2.394-2.38a4.275 4.275 0 0 0 0-6.072Zm-13.683 13.6a4.337 4.337 0 0 0 6.108 0l1.262-1.255a.993.993 0 0 0 0-1.41 1.007 1.007 0 0 0-1.418 0L9.954 17.45a2.323 2.323 0 0 1-3.27 0 2.29 2.29 0 0 1 0-3.251l2.344-2.331a2.579 2.579 0 0 1 3.631 0c.392.39 1.027.39 1.419 0a.993.993 0 0 0 0-1.41 4.593 4.593 0 0 0-6.468 0l-2.345 2.33a4.275 4.275 0 0 0 0 6.072Z' clip-rule='evenodd'/%3E%3C/svg%3E"); }
|
|
||||||
|
|
||||||
[value="Unlink"] {
|
|
||||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' fill='none'%3E%3Cpath fill='%23000' fill-rule='evenodd' d='M18.954 5.264a3.58 3.58 0 0 0-5.04 0l-2.157 2.144a.993.993 0 0 0 0 1.41c.392.39 1.027.39 1.418 0l2.157-2.144a1.565 1.565 0 0 1 2.204 0c.607.603.609 1.58.005 2.186l-2.348 2.355a.993.993 0 0 0 .006 1.41 1.007 1.007 0 0 0 1.419-.007l2.347-2.354a3.528 3.528 0 0 0-.01-5ZM5.257 18.852a3.59 3.59 0 0 0 5.041.015l2.6-2.554a.993.993 0 0 0 .01-1.41 1.007 1.007 0 0 0-1.419-.009l-2.6 2.554c-.615.603-1.603.6-2.214-.006a1.554 1.554 0 0 1-.012-2.195l2.539-2.58a.993.993 0 0 0-.016-1.41 1.007 1.007 0 0 0-1.418.015l-2.539 2.58a3.54 3.54 0 0 0 .028 5Z' clip-rule='evenodd'/%3E%3Cpath stroke='%23000' stroke-linecap='round' stroke-width='2' d='M10.233 5.78V3.769M5.968 9.802H4.21M7.343 7.107 5.968 5.781M14.348 17.523v2.013M18.613 13.502h1.758M17.238 16.196l1.375 1.327'/%3E%3C/svg%3E%0A");
|
|
||||||
border-top-right-radius: 1em !important;
|
|
||||||
border-bottom-right-radius: 1em !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
trix-editor {
|
lexxy-editor {
|
||||||
&.input {
|
&.input {
|
||||||
|
--lexxy-editor-padding: 0;
|
||||||
|
|
||||||
|
/* One line high when empty. Lexxy derives this from attr(rows lh, 8lh),
|
||||||
|
whose resolution differs between Chrome versions — pin it instead of
|
||||||
|
risking the 8-line fallback */
|
||||||
|
--lexxy-editor-rows: 1lh;
|
||||||
|
|
||||||
|
background: transparent;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 0;
|
||||||
min-height: 24px;
|
min-height: 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.attachment--content {
|
.attachment--content {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
|
|
||||||
.attachment__toolbar {
|
|
||||||
top: 0;
|
|
||||||
z-index: 2;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.trix-button-group--file-tools,
|
/* The mentions menu needs a solid surface — the editor's transparent canvas
|
||||||
.trix-button-group--history-tools {
|
would let messages show through it */
|
||||||
display: none !important;
|
.lexxy-prompt-menu {
|
||||||
|
--lexxy-color-canvas: var(--color-bg);
|
||||||
|
|
||||||
|
border: 1px solid var(--color-border-dark);
|
||||||
}
|
}
|
||||||
|
|
||||||
.trix-button-group,
|
/* Same for the code block language picker: with a transparent canvas the
|
||||||
.trix-button {
|
native dropdown renders white-on-white in dark mode */
|
||||||
border: 0 !important;
|
lexxy-editor lexxy-code-language-picker select {
|
||||||
margin: 0 !important;
|
background-color: var(--color-bg);
|
||||||
border-radius: 0.3em !important;
|
color: var(--color-text);
|
||||||
box-shadow: none;
|
|
||||||
|
|
||||||
&::before {
|
option {
|
||||||
opacity: 1 !important;
|
background-color: var(--color-bg);
|
||||||
}
|
color: var(--color-text);
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
|
||||||
&::before {
|
|
||||||
filter: invert(100);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&.trix-active {
|
|
||||||
background-color: var(--color-text) !important;
|
|
||||||
|
|
||||||
&::before {
|
|
||||||
filter: invert(100);
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
|
||||||
&::before {
|
|
||||||
filter: invert(0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&.trix-button--remove {
|
|
||||||
background-color: var(--color-negative);
|
|
||||||
border-radius: 1.8em !important;
|
|
||||||
|
|
||||||
&::before {
|
|
||||||
filter: invert(100);
|
|
||||||
background-size: 70%;
|
|
||||||
background-image: url("minus.svg");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-trix-content-type~='application/vnd.campfire.mention'] {
|
/* The toolbar only shows when rich text mode is toggled on (see composer.css) */
|
||||||
.trix-button-group {
|
lexxy-toolbar {
|
||||||
display: inline;
|
/* Wide, airy button spacing like the Trix toolbar */
|
||||||
|
--lexxy-toolbar-gap: 1.3em;
|
||||||
|
|
||||||
|
/* Match the compact size of the Trix toolbar buttons */
|
||||||
|
--lexxy-toolbar-button-size: 1.7em;
|
||||||
|
--lexxy-toolbar-icon-size: 1.125em;
|
||||||
|
|
||||||
|
display: none;
|
||||||
|
border-block-end: 0;
|
||||||
|
min-width: 0;
|
||||||
|
z-index: 3;
|
||||||
|
|
||||||
|
/* Tools Campfire doesn't offer, matching the Trix toolbar: underline,
|
||||||
|
color highlights, tables, dividers and undo/redo (history tools were
|
||||||
|
hidden under Trix as well) */
|
||||||
|
button[name="underline"],
|
||||||
|
button[name="table"],
|
||||||
|
button[name="divider"],
|
||||||
|
button[name="undo"],
|
||||||
|
button[name="redo"],
|
||||||
|
lexxy-highlight-dropdown {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* No group separator pipes, like the Trix toolbar */
|
||||||
|
.lexxy-editor__toolbar-button.lexxy-editor__toolbar-group-end::after {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dropdown panels: a solid surface with a uniform radius. Lexxy derives
|
||||||
|
the panel's radius and gap from --lexxy-toolbar-gap, which Campfire
|
||||||
|
repurposes for button spacing */
|
||||||
|
.lexxy-editor__toolbar-dropdown [data-dropdown-panel] {
|
||||||
|
--lexxy-color-canvas: var(--color-bg);
|
||||||
|
--lexxy-color-ink-lightest: var(--color-message-bg);
|
||||||
|
--lexxy-color-selected: var(--color-selected);
|
||||||
|
--lexxy-color-selected-hover: var(--color-message-bg);
|
||||||
|
|
||||||
|
border: 1px solid var(--color-border-dark);
|
||||||
|
border-radius: 0.5em;
|
||||||
|
gap: 0.25em;
|
||||||
|
padding: 0.25em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lexxy-editor__toolbar-dropdown > .lexxy-editor__toolbar-button[aria-expanded="true"] {
|
||||||
|
border-radius: var(--lexxy-radius);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-trix-content-type~="application/vnd.actiontext.opengraph-embed"] {
|
/* Attachment markup contains formatting whitespace that must not turn into
|
||||||
* {
|
line breaks under the editor's white-space: pre-wrap */
|
||||||
text-decoration: none;
|
lexxy-editor action-text-attachment {
|
||||||
}
|
white-space: normal;
|
||||||
|
}
|
||||||
|
|
||||||
trix-editor & {
|
action-text-attachment[content-type~="application/vnd.campfire.mention"] {
|
||||||
|
display: inline-flex;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
action-text-attachment[content-type~="application/vnd.actiontext.opengraph-embed"] {
|
||||||
|
lexxy-editor & {
|
||||||
display: block;
|
display: block;
|
||||||
inline-size: fit-content;
|
inline-size: fit-content;
|
||||||
max-inline-size: 100%;
|
max-inline-size: 100%;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
.trix-button:not(.trix-button--remove) {
|
/* No selection background — it peeks through the card's rounded corners.
|
||||||
@media (any-hover: hover) {
|
The lexxy-editor prefix out-specifies Lexxy's own selection rule. */
|
||||||
&:where(:not(:active):hover) {
|
lexxy-editor &.node--selected {
|
||||||
filter: brightness(0.9);
|
--lexxy-attachment-bg-color: transparent;
|
||||||
background-color: var(--color-bg);
|
}
|
||||||
|
|
||||||
|
/* A prominent remove button on the card's top edge, horizontally
|
||||||
|
centered, like the Trix one. The lexxy-editor prefix out-specifies
|
||||||
|
Lexxy's own placement rules. */
|
||||||
|
lexxy-editor & lexxy-node-delete-button {
|
||||||
|
inset-block-start: 0;
|
||||||
|
inset-inline-start: 50%;
|
||||||
|
inset-inline-end: auto;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
|
||||||
|
.lexxy-floating-controls__group {
|
||||||
|
--button-size: 2.2em;
|
||||||
|
|
||||||
|
background-color: var(--color-negative);
|
||||||
|
border-radius: 50%;
|
||||||
|
color: white;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&:disabled {
|
|
||||||
opacity: 0.33;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
trix-editor,
|
.lexxy-content {
|
||||||
.trix-content {
|
|
||||||
pre {
|
pre {
|
||||||
background-color: var(--color-bg) !important;
|
background-color: var(--color-bg) !important;
|
||||||
border-radius: 0.5em;
|
border-radius: 0.5em;
|
||||||
@@ -179,11 +174,13 @@ trix-editor,
|
|||||||
blockquote {
|
blockquote {
|
||||||
border: 0 solid var(--color-border-darker) !important;
|
border: 0 solid var(--color-border-darker) !important;
|
||||||
border-inline-start-width: var(--blockquote-border) !important;
|
border-inline-start-width: var(--blockquote-border) !important;
|
||||||
|
font-style: normal;
|
||||||
margin: 0 var(--blockquote-margin) !important;
|
margin: 0 var(--blockquote-margin) !important;
|
||||||
padding-inline-start: var(--blockquote-padding) !important;
|
padding-inline-start: var(--blockquote-padding) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
cite {
|
cite {
|
||||||
|
display: block;
|
||||||
margin-inline-start: calc(var(--blockquote-padding));
|
margin-inline-start: calc(var(--blockquote-padding));
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
@@ -199,13 +196,14 @@ trix-editor,
|
|||||||
color: var(--color-link);
|
color: var(--color-link);
|
||||||
}
|
}
|
||||||
|
|
||||||
ul,
|
/* The composer's mention menu is a list inside the editor too, and keeps Lexxy's own spacing */
|
||||||
|
ul:not(.lexxy-prompt-menu),
|
||||||
ol {
|
ol {
|
||||||
margin: 0 !important;
|
margin: 0 !important;
|
||||||
padding: 0 !important;
|
padding: 0 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
li {
|
li:not(.lexxy-prompt-menu__item) {
|
||||||
margin: 0 0 0 var(--inline-space-double) !important;
|
margin: 0 0 0 var(--inline-space-double) !important;
|
||||||
padding: 0 !important;
|
padding: 0 !important;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,7 +33,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-trix-content-type~='application/vnd.campfire.mention'] {
|
action-text-attachment[content-type~='application/vnd.campfire.mention'] {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
|
|||||||
@@ -100,16 +100,42 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.composer--rich-text {
|
.composer--rich-text {
|
||||||
trix-toolbar {
|
lexxy-toolbar {
|
||||||
display: block;
|
display: flex;
|
||||||
}
|
}
|
||||||
|
|
||||||
trix-editor.input {
|
lexxy-editor.input {
|
||||||
margin-block-start: var(--block-space-half);
|
margin-block-start: var(--block-space-half);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.composer--edit trix-toolbar {
|
/* In the message composer the toolbar goes below the text, on the same
|
||||||
|
row as the surrounding action buttons, like the Trix toolbar did.
|
||||||
|
The edit composer keeps it on top. */
|
||||||
|
#composer.composer--rich-text lexxy-editor.input {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
/* Sit the toolbar row on the same baseline as the composer's round
|
||||||
|
buttons, which pull themselves down with negative margins */
|
||||||
|
margin-block-end: -0.25em;
|
||||||
|
|
||||||
|
.lexxy-editor__content {
|
||||||
|
order: -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
lexxy-toolbar {
|
||||||
|
margin-block-start: 0.4em;
|
||||||
|
|
||||||
|
/* Dropdowns open upward — the toolbar sits at the bottom of the screen */
|
||||||
|
[data-dropdown-panel] {
|
||||||
|
inset-block-start: auto;
|
||||||
|
inset-block-end: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.composer--edit lexxy-toolbar {
|
||||||
border-block-end: 1px solid var(--color-border);
|
border-block-end: 1px solid var(--color-border);
|
||||||
margin-inline: -0.8em;
|
margin-inline: -0.8em;
|
||||||
padding-inline: 0.3em;
|
padding-inline: 0.3em;
|
||||||
@@ -117,6 +143,16 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* The editor's container-type prevents it from sizing the message bubble by
|
||||||
|
its content, so give the bubble the full available width while editing */
|
||||||
|
.message__body:has(.composer--edit) {
|
||||||
|
inline-size: 100%;
|
||||||
|
|
||||||
|
.message__body-content--editing {
|
||||||
|
flex-grow: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* Typing indicator */
|
/* Typing indicator */
|
||||||
.typing-indicator {
|
.typing-indicator {
|
||||||
inset-block-start: var(--indicator-position, 0.15rem);
|
inset-block-start: var(--indicator-position, 0.15rem);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
trix-editor,
|
|
||||||
.trix-content {
|
.lexxy-content {
|
||||||
.og-embed {
|
.og-embed {
|
||||||
background: var(--color-bg);
|
background: var(--color-bg);
|
||||||
border: 1px solid var(--color-border-dark);
|
border: 1px solid var(--color-border-dark);
|
||||||
@@ -15,7 +15,7 @@ trix-editor,
|
|||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
}
|
}
|
||||||
|
|
||||||
.cf-twitter-avatar & {
|
&.og-embed--twitter-avatar {
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -35,10 +35,11 @@ trix-editor,
|
|||||||
|
|
||||||
.og-embed__title {
|
.og-embed__title {
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
text-decoration: underline;
|
|
||||||
|
|
||||||
|
/* Underline the link itself so the line is link-colored */
|
||||||
a {
|
a {
|
||||||
color: var(--color-link);
|
color: var(--color-link);
|
||||||
|
text-decoration: underline;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,7 +54,11 @@ trix-editor,
|
|||||||
inline-size: 15dvw;
|
inline-size: 15dvw;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* block-size/border-radius guard against Lexxy's 1em round avatar
|
||||||
|
styling for custom attachment images */
|
||||||
> * {
|
> * {
|
||||||
|
block-size: auto;
|
||||||
|
border-radius: 0;
|
||||||
object-position: top right;
|
object-position: top right;
|
||||||
inline-size: 100%;
|
inline-size: 100%;
|
||||||
object-fit: contain;
|
object-fit: contain;
|
||||||
@@ -63,7 +68,7 @@ trix-editor,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.cf-twitter-avatar & {
|
.og-embed--twitter-avatar & {
|
||||||
aspect-ratio: 1;
|
aspect-ratio: 1;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
inline-size: 2lh;
|
inline-size: 2lh;
|
||||||
|
|||||||
@@ -537,7 +537,7 @@ img.message__attachment {
|
|||||||
padding: var(--content-padding-block) 0 var(--content-padding-block) var(--content-padding-inline);
|
padding: var(--content-padding-block) 0 var(--content-padding-block) var(--content-padding-inline);
|
||||||
text-shadow: 0 0 3px var(--color-text-reversed);
|
text-shadow: 0 0 3px var(--color-text-reversed);
|
||||||
|
|
||||||
.trix-content {
|
.lexxy-content {
|
||||||
font-size: 2.4em;
|
font-size: 2.4em;
|
||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
# Prepended onto Turbo::StreamsChannel. The subscriber names the channel it wants, so
|
||||||
|
# authorizing room messages only in RoomMessagesChannel would leave the stock channel as
|
||||||
|
# a way around it: same signed stream name, no membership check. Turn those names away
|
||||||
|
# here and RoomMessagesChannel becomes the only door.
|
||||||
|
module RoomStreamsAreAuthorized
|
||||||
|
def subscribed
|
||||||
|
if RoomMessagesChannel.guarded_stream?(verified_stream_name_from_params)
|
||||||
|
reject
|
||||||
|
else
|
||||||
|
super
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -22,6 +22,6 @@ class PresenceChannel < RoomChannel
|
|||||||
end
|
end
|
||||||
|
|
||||||
def broadcast_read_room
|
def broadcast_read_room
|
||||||
ActionCable.server.broadcast "user_#{current_user.id}_reads", { room_id: membership.room_id }
|
ActionCable.server.broadcast "user_#{current_user.id}_reads", { room_id: membership.room_id, at: Time.current.to_f }
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
# Authorizes the room message stream when the subscription is made, so that revoking a
|
||||||
|
# membership actually stops delivery.
|
||||||
|
#
|
||||||
|
# Turbo's stock channel verifies only the signature on the stream name. That name carries
|
||||||
|
# no expiry and no binding to a user, so one harvested while a member keeps working after
|
||||||
|
# the membership is gone. Reconnecting makes it worse rather than better: revoking a
|
||||||
|
# membership disconnects the user with reconnect: true, and the client then replays its
|
||||||
|
# subscriptions on the fresh socket.
|
||||||
|
#
|
||||||
|
# The room is derived from the verified stream name rather than taken as a parameter, so
|
||||||
|
# there's nothing for a subscriber to point somewhere else. The subscriber also doesn't
|
||||||
|
# get to choose the channel: Turbo::StreamsChannel turns these stream names away, so this
|
||||||
|
# is the only way onto them. See config/initializers/turbo_streams_authorization.rb.
|
||||||
|
class RoomMessagesChannel < ApplicationCable::Channel
|
||||||
|
extend Turbo::Streams::StreamName
|
||||||
|
include Turbo::Streams::StreamName::ClassMethods
|
||||||
|
|
||||||
|
STREAM_SUFFIX = "messages"
|
||||||
|
|
||||||
|
class << self
|
||||||
|
# True for the stream names this channel exists to guard, whoever is asking.
|
||||||
|
def guarded_stream?(stream_name)
|
||||||
|
stream_name.to_s.split(":", 2).second == STREAM_SUFFIX
|
||||||
|
end
|
||||||
|
|
||||||
|
def subscribable_room(user, stream_name)
|
||||||
|
gid_param, suffix = stream_name.to_s.split(":", 2)
|
||||||
|
|
||||||
|
if suffix == STREAM_SUFFIX && room = room_from(gid_param)
|
||||||
|
user.rooms.find_by(id: room.id)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
def room_from(gid_param)
|
||||||
|
GlobalID::Locator.locate gid_param, only: Room
|
||||||
|
rescue ActiveRecord::RecordNotFound
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def subscribed
|
||||||
|
if stream_name = authorized_stream_name
|
||||||
|
stream_from stream_name
|
||||||
|
else
|
||||||
|
reject
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
def authorized_stream_name
|
||||||
|
stream_name = verified_stream_name_from_params
|
||||||
|
stream_name if stream_name.present? && self.class.subscribable_room(current_user, stream_name)
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -1,5 +1,12 @@
|
|||||||
class UnreadRoomsChannel < ApplicationCable::Channel
|
class UnreadRoomsChannel < ApplicationCable::Channel
|
||||||
|
# Scoped per user, like ReadRoomsChannel. A single global stream would tell every
|
||||||
|
# authenticated connection when any room on the account is active, including closed
|
||||||
|
# rooms and direct conversations they're not part of.
|
||||||
|
def self.stream_name_for(user_id)
|
||||||
|
"user_#{user_id}_unreads"
|
||||||
|
end
|
||||||
|
|
||||||
def subscribed
|
def subscribed
|
||||||
stream_from "unread_rooms"
|
stream_from self.class.stream_name_for(current_user.id)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -2,7 +2,7 @@ class Accounts::UsersController < ApplicationController
|
|||||||
before_action :ensure_can_administer, :set_user, only: %i[ update destroy ]
|
before_action :ensure_can_administer, :set_user, only: %i[ update destroy ]
|
||||||
|
|
||||||
def index
|
def index
|
||||||
set_page_and_extract_portion_from User.active.ordered.without_bots, per_page: 500
|
set_page_and_extract_portion_from User.visible_to(Current.user).ordered.member, per_page: 500
|
||||||
end
|
end
|
||||||
|
|
||||||
def update
|
def update
|
||||||
|
|||||||
@@ -3,9 +3,9 @@ class AccountsController < ApplicationController
|
|||||||
before_action :set_account
|
before_action :set_account
|
||||||
|
|
||||||
def edit
|
def edit
|
||||||
users = account_users.ordered.without_bots
|
users = User.visible_to(Current.user).ordered
|
||||||
@administrators, @members = users.partition(&:administrator?)
|
@administrators = users.administrator
|
||||||
set_page_and_extract_portion_from users, per_page: 500
|
set_page_and_extract_portion_from users.member, per_page: 500
|
||||||
end
|
end
|
||||||
|
|
||||||
def update
|
def update
|
||||||
@@ -21,12 +21,4 @@ class AccountsController < ApplicationController
|
|||||||
def account_params
|
def account_params
|
||||||
params.require(:account).permit(:name, :logo, settings: {})
|
params.require(:account).permit(:name, :logo, settings: {})
|
||||||
end
|
end
|
||||||
|
|
||||||
def account_users
|
|
||||||
if Current.user.can_administer?
|
|
||||||
User.where(status: [ :active, :banned ])
|
|
||||||
else
|
|
||||||
User.active
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
class ApplicationController < ActionController::Base
|
class ApplicationController < ActionController::Base
|
||||||
include AllowBrowser, Authentication, Authorization, BlockBannedRequests, SetCurrentRequest, SetPlatform, TrackedRoomVisit, VersionHeaders
|
include AllowBrowser, Authentication, Authorization, BlockBannedRequests, SetCurrentRequest, SetPlatform, TrackedRoomVisit, VersionHeaders, InlineScriptHashes
|
||||||
include Turbo::Streams::Broadcasts, Turbo::Streams::StreamName
|
include Turbo::Streams::Broadcasts, Turbo::Streams::StreamName
|
||||||
end
|
end
|
||||||
@@ -1,11 +1,26 @@
|
|||||||
class Autocompletable::UsersController < ApplicationController
|
class Autocompletable::UsersController < ApplicationController
|
||||||
def index
|
def index
|
||||||
set_page_and_extract_portion_from find_autocompletable_users.with_attached_avatar.ordered, per_page: 20
|
set_page_and_extract_portion_from find_autocompletable_users.with_attached_avatar.ordered, per_page: 20
|
||||||
|
|
||||||
|
respond_to do |format|
|
||||||
|
format.html { render layout: false } # <lexxy-prompt-item> elements for the mentions prompt
|
||||||
|
format.json
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
def find_autocompletable_users
|
def find_autocompletable_users
|
||||||
params[:query].present? ? users_scope.active.filtered_by(params[:query]) : users_scope.active
|
if query.present?
|
||||||
|
users_scope.active.filtered_by(query)
|
||||||
|
else
|
||||||
|
users_scope.active
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# The rich text editor's mentions prompt filters with `filter`, the
|
||||||
|
# autocomplete inputs with `query`
|
||||||
|
def query
|
||||||
|
params[:filter].presence || params[:query].presence
|
||||||
end
|
end
|
||||||
|
|
||||||
def users_scope
|
def users_scope
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
module ActiveStorageAuthentication
|
||||||
|
extend ActiveSupport::Concern
|
||||||
|
include Authentication::SessionLookup
|
||||||
|
|
||||||
|
private
|
||||||
|
def require_active_storage_authentication
|
||||||
|
head :unauthorized unless find_session_by_cookie
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -71,6 +71,13 @@ module Authentication
|
|||||||
Current.session&.destroy!
|
Current.session&.destroy!
|
||||||
reset_session
|
reset_session
|
||||||
remove_authentication_cookie
|
remove_authentication_cookie
|
||||||
|
disconnect_remote_connections
|
||||||
|
end
|
||||||
|
|
||||||
|
def disconnect_remote_connections
|
||||||
|
Current.user&.reset_remote_connections
|
||||||
|
rescue => error
|
||||||
|
Rails.logger.warn "Could not disconnect remote connections on sign out: #{error.class}"
|
||||||
end
|
end
|
||||||
|
|
||||||
def authenticated_as(session)
|
def authenticated_as(session)
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
# Allows the scripts the importmap helper emits and nothing else: the inline
|
||||||
|
# importmap and its entry-point module by content hash, and the asset
|
||||||
|
# directory its URLs point into. Not the whole origin, because uploads are
|
||||||
|
# served from this origin too. The hashes change only when the importmap does
|
||||||
|
# (a deploy), so cached HTML stays valid, unlike a per-request nonce.
|
||||||
|
module InlineScriptHashes
|
||||||
|
extend ActiveSupport::Concern
|
||||||
|
|
||||||
|
ENTRY_POINT_SCRIPT = %(import "application")
|
||||||
|
|
||||||
|
included do
|
||||||
|
# With the policy turned off there's nothing to add to, so leave the
|
||||||
|
# request without one rather than starting an empty policy
|
||||||
|
content_security_policy if: -> { request.content_security_policy } do |policy|
|
||||||
|
if sources = policy.directives["script-src"]
|
||||||
|
importmap = Rails.application.importmap.to_json(resolver: helpers)
|
||||||
|
policy.directives["script-src"] = (sources - [ "'self'" ]) | script_locations(importmap) | script_hashes(importmap, ENTRY_POINT_SCRIPT)
|
||||||
|
policy.worker_src "#{request.host_with_port}#{service_worker_path(format: :js)}"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
# The importmap is resolved by Rails' asset helpers, so its URLs already
|
||||||
|
# carry any relative root and asset host, however the host is written:
|
||||||
|
# with or without a scheme, protocol-relative, a %d set, or a proc. Each
|
||||||
|
# is allowed up to its asset directory, the last one in its path, since an
|
||||||
|
# asset host's own path may contain one too; a URL outside it is allowed
|
||||||
|
# exactly.
|
||||||
|
#
|
||||||
|
# This origin's own sources leave out the scheme, so they match the page's
|
||||||
|
# scheme even behind a proxy that terminates TLS without saying so.
|
||||||
|
#
|
||||||
|
# A path only limits the first request: browsers check just the origin
|
||||||
|
# after a redirect. Nothing under the asset directory redirects.
|
||||||
|
def script_locations(importmap)
|
||||||
|
assets = File.join(config.relative_url_root.to_s, Rails.application.config.assets.prefix, "")
|
||||||
|
|
||||||
|
JSON.parse(importmap)["imports"].values.map do |url|
|
||||||
|
url = URI.join(request.base_url, url)
|
||||||
|
origin = url.origin == request.base_url ? request.host_with_port : url.origin
|
||||||
|
directory = url.path[0, url.path.rindex(assets) + assets.length] if url.path.include?(assets)
|
||||||
|
"#{origin}#{directory || url.path}"
|
||||||
|
end.uniq
|
||||||
|
end
|
||||||
|
|
||||||
|
def script_hashes(*scripts)
|
||||||
|
scripts.map { |script| "'sha256-#{Digest::SHA256.base64digest(script)}'" }
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
module RawRequestBody
|
||||||
|
extend ActiveSupport::Concern
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def raw_request_body
|
||||||
|
request.body.rewind
|
||||||
|
request.body.read.force_encoding("UTF-8")
|
||||||
|
ensure
|
||||||
|
request.body.rewind
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -8,6 +8,6 @@ module SetCurrentRequest
|
|||||||
end
|
end
|
||||||
|
|
||||||
def default_url_options
|
def default_url_options
|
||||||
{ host: Current.request_host, protocol: Current.request_protocol }.compact_blank
|
{ host: Current.request_host, port: Current.request&.optional_port, protocol: Current.request_protocol }.compact_blank
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
class Messages::Boosts::ByBotsController < Messages::BoostsController
|
||||||
|
include RawRequestBody
|
||||||
|
|
||||||
|
allow_bot_access only: %i[ create destroy ]
|
||||||
|
|
||||||
|
before_action :ensure_content_present, only: :create
|
||||||
|
|
||||||
|
def create
|
||||||
|
@boost = @message.boosts.create!(boost_params)
|
||||||
|
|
||||||
|
broadcast_create
|
||||||
|
render :show, status: :created
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
def set_message
|
||||||
|
if room = Current.user.rooms.find_by(id: params[:room_id])
|
||||||
|
@message = room.messages.find_by(id: params[:message_id])
|
||||||
|
end
|
||||||
|
|
||||||
|
head :not_found unless @message
|
||||||
|
end
|
||||||
|
|
||||||
|
def set_boost
|
||||||
|
super
|
||||||
|
rescue ActiveRecord::RecordNotFound
|
||||||
|
head :not_found
|
||||||
|
end
|
||||||
|
|
||||||
|
def ensure_content_present
|
||||||
|
if raw_request_body.blank?
|
||||||
|
head :unprocessable_content
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def boost_params
|
||||||
|
{ content: raw_request_body }
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
class Messages::BoostsController < ApplicationController
|
class Messages::BoostsController < ApplicationController
|
||||||
before_action :set_message
|
before_action :set_message
|
||||||
|
before_action :set_boost, only: :destroy
|
||||||
|
|
||||||
def index
|
def index
|
||||||
end
|
end
|
||||||
@@ -15,7 +16,6 @@ class Messages::BoostsController < ApplicationController
|
|||||||
end
|
end
|
||||||
|
|
||||||
def destroy
|
def destroy
|
||||||
@boost = Current.user.boosts.find(params[:id])
|
|
||||||
@boost.destroy!
|
@boost.destroy!
|
||||||
|
|
||||||
broadcast_remove
|
broadcast_remove
|
||||||
@@ -26,13 +26,17 @@ class Messages::BoostsController < ApplicationController
|
|||||||
@message = Current.user.reachable_messages.find(params[:message_id])
|
@message = Current.user.reachable_messages.find(params[:message_id])
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def set_boost
|
||||||
|
@boost = @message.boosts.find_by!(id: params[:id], booster: Current.user)
|
||||||
|
end
|
||||||
|
|
||||||
def boost_params
|
def boost_params
|
||||||
params.require(:boost).permit(:content)
|
params.require(:boost).permit(:content)
|
||||||
end
|
end
|
||||||
|
|
||||||
def broadcast_create
|
def broadcast_create
|
||||||
@boost.broadcast_append_to @boost.message.room, :messages,
|
@boost.broadcast_append_to @boost.message.room, :messages,
|
||||||
target: "boosts_message_#{@boost.message.client_message_id}", partial: "messages/boosts/boost", attributes: { maintain_scroll: true }
|
target: helpers.dom_id(@boost.message, :boosts), partial: "messages/boosts/boost", attributes: { maintain_scroll: true }
|
||||||
end
|
end
|
||||||
|
|
||||||
def broadcast_remove
|
def broadcast_remove
|
||||||
|
|||||||
@@ -1,24 +1,64 @@
|
|||||||
class Messages::ByBotsController < MessagesController
|
class Messages::ByBotsController < MessagesController
|
||||||
allow_bot_access only: :create
|
include RawRequestBody
|
||||||
|
|
||||||
|
allow_bot_access only: %i[ index create update destroy ]
|
||||||
|
|
||||||
|
before_action :set_room
|
||||||
|
before_action :set_message, only: %i[ update destroy ]
|
||||||
|
before_action :ensure_can_administer, only: %i[ update destroy ]
|
||||||
|
before_action :ensure_body_or_attachment_present, only: :create
|
||||||
|
|
||||||
|
def index
|
||||||
|
@messages = find_paged_messages
|
||||||
|
set_pagination_headers
|
||||||
|
end
|
||||||
|
|
||||||
def create
|
def create
|
||||||
super
|
super
|
||||||
head :created, location: message_url(@message)
|
head :created, location: message_url(@message)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def destroy
|
||||||
|
super
|
||||||
|
head :no_content
|
||||||
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
def set_room
|
||||||
|
@room = Current.user.rooms.find_by(id: params[:room_id])
|
||||||
|
|
||||||
|
head :not_found unless @room
|
||||||
|
end
|
||||||
|
|
||||||
|
def ensure_body_or_attachment_present
|
||||||
|
if params[:attachment].blank? && raw_request_body.blank?
|
||||||
|
head :unprocessable_content
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def set_pagination_headers
|
||||||
|
headers["X-Total-Count"] = @room.messages.count.to_s
|
||||||
|
|
||||||
|
if next_page = next_page_params
|
||||||
|
headers["Link"] = %(<#{room_bot_messages_url(@room, params[:bot_key], **next_page)}>; rel="next")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def next_page_params
|
||||||
|
if @messages.any?
|
||||||
|
if params[:after].present?
|
||||||
|
{ after: @messages.last.id } if @room.messages.after(@messages.last).exists?
|
||||||
|
else
|
||||||
|
{ before: @messages.first.id } if @room.messages.before(@messages.first).exists?
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
def message_params
|
def message_params
|
||||||
if params[:attachment]
|
if params[:attachment]
|
||||||
params.permit(:attachment)
|
params.permit(:attachment)
|
||||||
else
|
else
|
||||||
reading(request.body) { |body| { body: body } }
|
{ body: raw_request_body }
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def reading(io)
|
|
||||||
io.rewind
|
|
||||||
yield io.read.force_encoding("UTF-8")
|
|
||||||
ensure
|
|
||||||
io.rewind
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
@@ -37,7 +37,11 @@ class MessagesController < ApplicationController
|
|||||||
@message.update!(message_params)
|
@message.update!(message_params)
|
||||||
|
|
||||||
@message.broadcast_replace_to @room, :messages, target: [ @message, :presentation ], partial: "messages/presentation", attributes: { maintain_scroll: true }
|
@message.broadcast_replace_to @room, :messages, target: [ @message, :presentation ], partial: "messages/presentation", attributes: { maintain_scroll: true }
|
||||||
redirect_to room_message_url(@room, @message)
|
|
||||||
|
respond_to do |format|
|
||||||
|
format.html { redirect_to room_message_url(@room, @message) }
|
||||||
|
format.json { render :show }
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def destroy
|
def destroy
|
||||||
@@ -58,11 +62,11 @@ class MessagesController < ApplicationController
|
|||||||
def find_paged_messages
|
def find_paged_messages
|
||||||
case
|
case
|
||||||
when params[:before].present?
|
when params[:before].present?
|
||||||
@room.messages.with_creator.page_before(@room.messages.find(params[:before]))
|
@room.messages.with_presentation.page_before(@room.messages.find(params[:before]))
|
||||||
when params[:after].present?
|
when params[:after].present?
|
||||||
@room.messages.with_creator.page_after(@room.messages.find(params[:after]))
|
@room.messages.with_presentation.page_after(@room.messages.find(params[:after]))
|
||||||
else
|
else
|
||||||
@room.messages.with_creator.last_page
|
@room.messages.with_presentation.last_page
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -42,6 +42,12 @@ class Rooms::ClosedsController < RoomsController
|
|||||||
@room = @room.becomes!(Rooms::Closed)
|
@room = @room.becomes!(Rooms::Closed)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Open and closed rooms convert into each other, so both are in reach here. Direct
|
||||||
|
# rooms never are: converting one would let its creator revise who's in it.
|
||||||
|
def room_scope
|
||||||
|
Current.user.rooms.without_directs
|
||||||
|
end
|
||||||
|
|
||||||
def grantees
|
def grantees
|
||||||
User.where(id: grantee_ids)
|
User.where(id: grantee_ids)
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -29,8 +29,13 @@ class Rooms::DirectsController < RoomsController
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
# All users in a direct room can administer it
|
# All users in a direct room can administer it. Only direct rooms, though: this
|
||||||
|
# relaxation is why room_scope below has to keep every other type out of reach.
|
||||||
def ensure_can_administer
|
def ensure_can_administer
|
||||||
true
|
true
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def room_scope
|
||||||
|
Current.user.rooms.directs
|
||||||
|
end
|
||||||
end
|
end
|
||||||
@@ -40,6 +40,12 @@ class Rooms::OpensController < RoomsController
|
|||||||
@room = @room.becomes!(Rooms::Open)
|
@room = @room.becomes!(Rooms::Open)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Open and closed rooms convert into each other, so both are in reach here. Direct
|
||||||
|
# rooms never are: promoting one would republish its history to the whole account.
|
||||||
|
def room_scope
|
||||||
|
Current.user.rooms.without_directs
|
||||||
|
end
|
||||||
|
|
||||||
def broadcast_create_room(room)
|
def broadcast_create_room(room)
|
||||||
broadcast_prepend_to :rooms, target: :shared_rooms, partial: "users/sidebars/rooms/shared", locals: { room: room }
|
broadcast_prepend_to :rooms, target: :shared_rooms, partial: "users/sidebars/rooms/shared", locals: { room: room }
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ class Rooms::RefreshesController < ApplicationController
|
|||||||
before_action :set_last_updated_at
|
before_action :set_last_updated_at
|
||||||
|
|
||||||
def show
|
def show
|
||||||
@new_messages = @room.messages.with_creator.page_created_since(@last_updated_at)
|
@new_messages = @room.messages.with_presentation.page_created_since(@last_updated_at)
|
||||||
@updated_messages = @room.messages.without(@new_messages).with_creator.page_updated_since(@last_updated_at)
|
@updated_messages = @room.messages.without(@new_messages).with_presentation.page_updated_since(@last_updated_at)
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ class RoomsController < ApplicationController
|
|||||||
end
|
end
|
||||||
|
|
||||||
def destroy
|
def destroy
|
||||||
@room.destroy
|
@room.destroy_later
|
||||||
|
|
||||||
broadcast_remove_room
|
broadcast_remove_room
|
||||||
redirect_to root_url
|
redirect_to root_url
|
||||||
@@ -20,13 +20,19 @@ class RoomsController < ApplicationController
|
|||||||
|
|
||||||
private
|
private
|
||||||
def set_room
|
def set_room
|
||||||
if room = Current.user.rooms.find_by(id: params[:room_id] || params[:id])
|
if room = room_scope.find_by(id: params[:room_id] || params[:id])
|
||||||
@room = room
|
@room = room
|
||||||
else
|
else
|
||||||
redirect_to root_url, alert: "Room not found or inaccessible"
|
redirect_to root_url, alert: "Room not found or inaccessible"
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Subclasses narrow this to the room types they're allowed to act on, so that one
|
||||||
|
# room namespace can't be used to reach another's rooms.
|
||||||
|
def room_scope
|
||||||
|
Current.user.rooms
|
||||||
|
end
|
||||||
|
|
||||||
def ensure_can_administer
|
def ensure_can_administer
|
||||||
head :forbidden unless Current.user.can_administer?(@room)
|
head :forbidden unless Current.user.can_administer?(@room)
|
||||||
end
|
end
|
||||||
@@ -38,9 +44,9 @@ class RoomsController < ApplicationController
|
|||||||
end
|
end
|
||||||
|
|
||||||
def find_messages
|
def find_messages
|
||||||
messages = @room.messages.with_creator.with_attachment_details.with_boosts
|
messages = @room.messages.with_presentation
|
||||||
|
|
||||||
if show_first_message = messages.find_by(id: params[:message_id])
|
if params[:message_id].present? && (show_first_message = messages.find_by(id: params[:message_id]))
|
||||||
@messages = messages.page_around(show_first_message)
|
@messages = messages.page_around(show_first_message)
|
||||||
else
|
else
|
||||||
@messages = messages.last_page
|
@messages = messages.last_page
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ class SearchesController < ApplicationController
|
|||||||
private
|
private
|
||||||
def set_messages
|
def set_messages
|
||||||
if query.present?
|
if query.present?
|
||||||
@messages = Current.user.reachable_messages.search(query).last(100)
|
@messages = Message.search_reachable(Current.user, query)
|
||||||
else
|
else
|
||||||
@messages = Message.none
|
@messages = Message.none
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
class UnfurlLinksController < ApplicationController
|
class UnfurlLinksController < ApplicationController
|
||||||
def create
|
# The pasted link decides which hosts an unfurl looks up and fetches from, and how quickly they answer.
|
||||||
opengraph = Opengraph::Metadata.from_url(url_param)
|
DEADLINE = 10.seconds
|
||||||
|
|
||||||
if opengraph.valid?
|
def create
|
||||||
|
if opengraph = unfurl(url_param)
|
||||||
render json: opengraph
|
render json: opengraph
|
||||||
else
|
else
|
||||||
head :no_content
|
head :no_content
|
||||||
@@ -13,4 +14,14 @@ class UnfurlLinksController < ApplicationController
|
|||||||
def url_param
|
def url_param
|
||||||
params.require(:url)
|
params.require(:url)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# One deadline for everything the link leads to: DNS lookups, redirects, the page, the image check.
|
||||||
|
def unfurl(url)
|
||||||
|
Timeout.timeout(DEADLINE) do
|
||||||
|
Opengraph::Metadata.from_url(url).then { |opengraph| opengraph if opengraph.valid? }
|
||||||
|
end
|
||||||
|
rescue Timeout::Error
|
||||||
|
Rails.logger.warn "Gave up unfurling #{url} after #{DEADLINE.inspect}"
|
||||||
|
nil
|
||||||
|
end
|
||||||
end
|
end
|
||||||
@@ -1,14 +1,23 @@
|
|||||||
class Users::ProfilesController < ApplicationController
|
class Users::ProfilesController < ApplicationController
|
||||||
before_action :set_user
|
before_action :set_user
|
||||||
|
|
||||||
|
# Limited like signing in, so the current password can't be guessed here instead.
|
||||||
|
rate_limit to: 10, within: 3.minutes, only: :update, if: :answering_password_challenge?,
|
||||||
|
with: -> { redirect_to user_profile_url, alert: "Too many attempts. Try again in a few minutes." }
|
||||||
|
|
||||||
def show
|
def show
|
||||||
@direct_memberships, @shared_memberships =
|
@direct_memberships, @shared_memberships =
|
||||||
Current.user.memberships.with_ordered_room.partition { |m| m.room.direct? }
|
Current.user.memberships.with_ordered_room.partition { |m| m.room.direct? }
|
||||||
end
|
end
|
||||||
|
|
||||||
def update
|
def update
|
||||||
@user.update user_params
|
if @user.update user_params
|
||||||
redirect_to user_profile_url, notice: update_notice
|
start_new_session_for @user if @user.saved_change_to_password_digest?
|
||||||
|
|
||||||
|
redirect_to user_profile_url, notice: update_notice
|
||||||
|
else
|
||||||
|
redirect_to user_profile_url, alert: update_alert
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
@@ -16,11 +25,35 @@ class Users::ProfilesController < ApplicationController
|
|||||||
@user = Current.user
|
@user = Current.user
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Always with the challenge, so the model checks it whenever the save would change the password, however the
|
||||||
|
# request spelled the new one.
|
||||||
def user_params
|
def user_params
|
||||||
params.require(:user).permit(:name, :avatar, :email_address, :password, :bio).compact
|
params.require(:user).permit(:name, :avatar, :email_address, :password, :bio).compact
|
||||||
|
.merge(password_challenge: password_challenge)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Only a string is a challenge, so the rate limit counts exactly what the model checks.
|
||||||
|
def password_challenge
|
||||||
|
challenge = params.dig(:user, :password_challenge)
|
||||||
|
challenge.is_a?(String) ? challenge : ""
|
||||||
|
end
|
||||||
|
|
||||||
|
# Guessing the current password means sending one, so this is what the rate limit counts.
|
||||||
|
def answering_password_challenge?
|
||||||
|
!password_challenge.empty?
|
||||||
end
|
end
|
||||||
|
|
||||||
def update_notice
|
def update_notice
|
||||||
params[:user][:avatar] ? "It may take up to 30 minutes to change everywhere." : "✓"
|
params[:user][:avatar] ? "It may take up to 30 minutes to change everywhere." : "✓"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def update_alert
|
||||||
|
if !@user.errors.include?(:password_challenge)
|
||||||
|
@user.errors.full_messages.to_sentence
|
||||||
|
elsif password_challenge.empty?
|
||||||
|
"Enter your current password to change it."
|
||||||
|
else
|
||||||
|
"That isn't your current password."
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
@@ -5,13 +5,15 @@ class Users::PushSubscriptionsController < ApplicationController
|
|||||||
end
|
end
|
||||||
|
|
||||||
def create
|
def create
|
||||||
if subscription = @push_subscriptions.find_by(push_subscription_params)
|
subscription = @push_subscriptions.find_or_initialize_by(push_subscription_params)
|
||||||
subscription.touch
|
subscription.user_agent = request.user_agent if subscription.new_record?
|
||||||
else
|
|
||||||
@push_subscriptions.create! push_subscription_params.merge(user_agent: request.user_agent)
|
|
||||||
end
|
|
||||||
|
|
||||||
head :ok
|
# Existing endpoints must pass current validations too
|
||||||
|
if subscription.valid?
|
||||||
|
head register_while_signed_in(subscription) ? :ok : :unauthorized
|
||||||
|
else
|
||||||
|
head :unprocessable_entity
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def destroy
|
def destroy
|
||||||
@@ -24,6 +26,24 @@ class Users::PushSubscriptionsController < ApplicationController
|
|||||||
@push_subscriptions = Current.user.push_subscriptions
|
@push_subscriptions = Current.user.push_subscriptions
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# A password change ends every session and subscription. A request that authenticated just before it mustn't
|
||||||
|
# put one back, so the session is checked under the same write lock as the save, and the endpoint is looked up
|
||||||
|
# again there, past the query cache, so two tabs posting at once register it once. Validation, which resolves
|
||||||
|
# the endpoint's address, runs before taking the lock.
|
||||||
|
def register_while_signed_in(subscription)
|
||||||
|
Push::Subscription.uncached do
|
||||||
|
Push::Subscription.transaction do
|
||||||
|
if Session.exists?(Current.session.id)
|
||||||
|
if existing = @push_subscriptions.find_by(push_subscription_params)
|
||||||
|
existing.touch
|
||||||
|
else
|
||||||
|
subscription.save!(validate: false)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
def push_subscription_params
|
def push_subscription_params
|
||||||
params.require(:push_subscription).permit(:endpoint, :p256dh_key, :auth_key)
|
params.require(:push_subscription).permit(:endpoint, :p256dh_key, :auth_key)
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -2,18 +2,14 @@ class Users::SidebarsController < ApplicationController
|
|||||||
DIRECT_PLACEHOLDERS = 20
|
DIRECT_PLACEHOLDERS = 20
|
||||||
|
|
||||||
def show
|
def show
|
||||||
all_memberships = Current.user.memberships.visible.with_ordered_room
|
visible_memberships = Current.user.memberships.visible
|
||||||
@direct_memberships = extract_direct_memberships(all_memberships)
|
@direct_memberships = visible_memberships.with_direct_rooms
|
||||||
@other_memberships = all_memberships.without(@direct_memberships)
|
@other_memberships = visible_memberships.with_ordered_room.without_direct_rooms
|
||||||
|
|
||||||
@direct_placeholder_users = find_direct_placeholder_users
|
@direct_placeholder_users = find_direct_placeholder_users
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
def extract_direct_memberships(all_memberships)
|
|
||||||
all_memberships.select { |m| m.room.direct? }.sort_by { |m| m.room.updated_at }.reverse
|
|
||||||
end
|
|
||||||
|
|
||||||
def find_direct_placeholder_users
|
def find_direct_placeholder_users
|
||||||
exclude_user_ids = user_ids_already_in_direct_rooms_with_current_user.including(Current.user.id)
|
exclude_user_ids = user_ids_already_in_direct_rooms_with_current_user.including(Current.user.id)
|
||||||
User.active.where.not(id: exclude_user_ids).order(:created_at).limit([ DIRECT_PLACEHOLDERS - exclude_user_ids.count, 0 ].max)
|
User.active.where.not(id: exclude_user_ids).order(:created_at).limit([ DIRECT_PLACEHOLDERS - exclude_user_ids.count, 0 ].max)
|
||||||
|
|||||||
@@ -1,3 +1,13 @@
|
|||||||
module ContentFilters
|
module ContentFilters
|
||||||
TextMessagePresentationFilters = ActionText::Content::Filters.new(RemoveSoloUnfurledLinkText, StyleUnfurledTwitterAvatars, SanitizeTags)
|
# Formatting the rich text editor produces that Rails' sanitizers don't
|
||||||
|
# allow by default. A message passes through three sanitization layers on
|
||||||
|
# its way to the screen, and each builds on this list:
|
||||||
|
#
|
||||||
|
# * SanitizeTags strips disallowed markup from the message body
|
||||||
|
# * Action Text sanitizes the rendered content (lib/rails_ext/action_text_allowed_tags.rb)
|
||||||
|
# * the final html is re-sanitized before its text is linked (MessagesHelper#auto_link_text)
|
||||||
|
EDITOR_FORMATTING_TAGS = %w[ s u mark table thead tbody tfoot tr th td ]
|
||||||
|
EDITOR_FORMATTING_ATTRIBUTES = %w[ data-language ]
|
||||||
|
|
||||||
|
TextMessagePresentationFilters = ActionText::Content::Filters.new(RemoveSoloUnfurledLinkText, SanitizeTags, SanitizeAttributes)
|
||||||
end
|
end
|
||||||
@@ -1,18 +1,22 @@
|
|||||||
class ContentFilters::RemoveSoloUnfurledLinkText < ActionText::Content::Filter
|
class ContentFilters::RemoveSoloUnfurledLinkText < ActionText::Content::Filter
|
||||||
|
TWITTER_DOMAINS = %w[ x.com twitter.com ]
|
||||||
|
TWITTER_DOMAIN_MAPPING = { "x.com" => "twitter.com" }
|
||||||
|
|
||||||
def applicable?
|
def applicable?
|
||||||
normalize_tweet_url(solo_unfurled_url) == normalize_tweet_url(content.to_plain_text)
|
normalize_tweet_url(solo_unfurled_url) == normalize_tweet_url(content.to_plain_text)
|
||||||
end
|
end
|
||||||
|
|
||||||
def apply
|
def apply
|
||||||
fragment.replace("div") { |node| node.tap { |n| n.inner_html = unfurled_links.first.to_s } }
|
if trix_body?
|
||||||
|
remove_link_text_from_wrapping_div
|
||||||
|
else
|
||||||
|
remove_link_paragraphs
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
TWITTER_DOMAINS = %w[ x.com twitter.com ]
|
|
||||||
TWITTER_DOMAIN_MAPPING = { "x.com" => "twitter.com" }
|
|
||||||
|
|
||||||
def solo_unfurled_url
|
def solo_unfurled_url
|
||||||
unfurled_links.first["href"] if unfurled_links.size == 1
|
ActionText::Attachment::OpengraphEmbed.from_node(unfurled_links.first)&.href if unfurled_links.size == 1
|
||||||
end
|
end
|
||||||
|
|
||||||
def unfurled_links
|
def unfurled_links
|
||||||
@@ -35,4 +39,20 @@ class ContentFilters::RemoveSoloUnfurledLinkText < ActionText::Content::Filter
|
|||||||
def twitter_url?(url)
|
def twitter_url?(url)
|
||||||
url.present? && TWITTER_DOMAINS.any? { |domain| url.strip.include?(domain) }
|
url.present? && TWITTER_DOMAINS.any? { |domain| url.strip.include?(domain) }
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def trix_body?
|
||||||
|
fragment.find_all("div").any?
|
||||||
|
end
|
||||||
|
|
||||||
|
def remove_link_text_from_wrapping_div
|
||||||
|
fragment.replace("div") { |node| node.tap { |n| n.inner_html = unfurled_links.first.to_s } }
|
||||||
|
end
|
||||||
|
|
||||||
|
def remove_link_paragraphs
|
||||||
|
fragment.replace("p") do |node|
|
||||||
|
if node.at_css("action-text-attachment")
|
||||||
|
node
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
class ContentFilters::SanitizeAttributes < ActionText::Content::Filter
|
||||||
|
def applicable?
|
||||||
|
true
|
||||||
|
end
|
||||||
|
|
||||||
|
# Scrub attributes on the tags that SanitizeTags allows, using Rails' safe-list
|
||||||
|
# sanitizer so unsafe URI schemes and event-handler attributes are stripped.
|
||||||
|
# Runs after SanitizeTags, so passing the same allowed tags makes the tag pass
|
||||||
|
# a no-op and only attributes are scrubbed.
|
||||||
|
def apply
|
||||||
|
sanitizer.sanitize fragment.to_html, tags: allowed_tags, attributes: allowed_attributes
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
# Presentation styling relies on class attributes (e.g. unfurled link embeds),
|
||||||
|
# which the standard ActionText set doesn't include.
|
||||||
|
EXTRA_ALLOWED_ATTRIBUTES = %w[ class ]
|
||||||
|
|
||||||
|
# ActionText::ContentHelper.sanitizer is a single process-wide instance, and
|
||||||
|
# rails-html-sanitizer reuses one mutable permit scrubber that it reconfigures
|
||||||
|
# from the tags/attributes on every #sanitize call. Sharing it here would race
|
||||||
|
# our stricter tag set against ActionText's default rendering on concurrent
|
||||||
|
# requests, so use a dedicated instance of the same sanitizer class to keep the
|
||||||
|
# scrubber isolated.
|
||||||
|
def sanitizer
|
||||||
|
sanitizer_class.new
|
||||||
|
end
|
||||||
|
|
||||||
|
def sanitizer_class
|
||||||
|
ActionText::ContentHelper.sanitizer.class
|
||||||
|
end
|
||||||
|
|
||||||
|
def allowed_tags
|
||||||
|
ContentFilters::SanitizeTags::ALLOWED_TAGS
|
||||||
|
end
|
||||||
|
|
||||||
|
def allowed_attributes
|
||||||
|
standard_allowed_attributes + EXTRA_ALLOWED_ATTRIBUTES
|
||||||
|
end
|
||||||
|
|
||||||
|
# Mirrors ActionText::ContentHelper#sanitizer_allowed_attributes, which isn't
|
||||||
|
# exposed at the module level.
|
||||||
|
def standard_allowed_attributes
|
||||||
|
ActionText::ContentHelper.allowed_attributes ||
|
||||||
|
(sanitizer_class.allowed_attributes + ActionText::Attachment::ATTRIBUTES).to_a
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -1,4 +1,8 @@
|
|||||||
class ContentFilters::SanitizeTags < ActionText::Content::Filter
|
class ContentFilters::SanitizeTags < ActionText::Content::Filter
|
||||||
|
ALLOWED_TAGS = %w[ a abbr acronym address b big blockquote br cite code dd del dfn div dl dt em h1 h2 h3 h4 h5 h6 hr i ins kbd li ol
|
||||||
|
p pre samp small span strong sub sup time tt ul var ] + ContentFilters::EDITOR_FORMATTING_TAGS +
|
||||||
|
[ ActionText::Attachment.tag_name, "figure", "figcaption" ]
|
||||||
|
|
||||||
def applicable?
|
def applicable?
|
||||||
true
|
true
|
||||||
end
|
end
|
||||||
@@ -8,9 +12,6 @@ class ContentFilters::SanitizeTags < ActionText::Content::Filter
|
|||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
ALLOWED_TAGS = %w[ a abbr acronym address b big blockquote br cite code dd del dfn div dl dt em h1 h2 h3 h4 h5 h6 hr i ins kbd li ol
|
|
||||||
p pre samp small span strong sub sup time tt ul var ] + [ ActionText::Attachment.tag_name, "figure", "figcaption" ]
|
|
||||||
|
|
||||||
def not_allowed_tags_css_selector
|
def not_allowed_tags_css_selector
|
||||||
ALLOWED_TAGS.map { |tag| ":not(#{tag})" }.join("")
|
ALLOWED_TAGS.map { |tag| ":not(#{tag})" }.join("")
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,24 +0,0 @@
|
|||||||
class ContentFilters::StyleUnfurledTwitterAvatars < ActionText::Content::Filter
|
|
||||||
def applicable?
|
|
||||||
unfurled_twitter_avatars.present?
|
|
||||||
end
|
|
||||||
|
|
||||||
def apply
|
|
||||||
fragment.update do |source|
|
|
||||||
div = source.at_css("div")
|
|
||||||
div["class"] = UNFURLED_TWITTER_AVATAR_CSS_CLASS
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
private
|
|
||||||
UNFURLED_TWITTER_AVATAR_CSS_CLASS = "cf-twitter-avatar"
|
|
||||||
TWITTER_AVATAR_URL_PREFIX = "https://pbs.twimg.com/profile_images"
|
|
||||||
|
|
||||||
def unfurled_twitter_avatars
|
|
||||||
fragment.find_all("#{opengraph_css_selector}[url*='#{TWITTER_AVATAR_URL_PREFIX}']")
|
|
||||||
end
|
|
||||||
|
|
||||||
def opengraph_css_selector
|
|
||||||
"action-text-attachment[@content-type='#{ActionText::Attachment::OpengraphEmbed::OPENGRAPH_EMBED_CONTENT_TYPE}']"
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -17,11 +17,16 @@ class Messages::AttachmentPresentation
|
|||||||
attr_reader :message, :context
|
attr_reader :message, :context
|
||||||
delegate :tag, :link_to, :broadcast_image_tag, :rails_blob_path, :url_for, to: :context
|
delegate :tag, :link_to, :broadcast_image_tag, :rails_blob_path, :url_for, to: :context
|
||||||
|
|
||||||
|
# Previews are made when the message is posted, within limits. One that wasn't, because the file was too large or
|
||||||
|
# the preview failed, isn't attempted again from here: its URL would make it on every view.
|
||||||
def render_preview
|
def render_preview
|
||||||
if message.attachment.video?
|
case
|
||||||
|
when message.attachment.video?
|
||||||
video_preview_tag
|
video_preview_tag
|
||||||
else
|
when message.attachment.representation(:thumb).processed?
|
||||||
lightboxed_image_preview_tag
|
lightboxed_image_preview_tag
|
||||||
|
else
|
||||||
|
render_link
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -30,11 +35,17 @@ class Messages::AttachmentPresentation
|
|||||||
|
|
||||||
inline_media_dimension_constraints(width, height) do
|
inline_media_dimension_constraints(width, height) do
|
||||||
tag.video \
|
tag.video \
|
||||||
src: rails_blob_path(message.attachment), poster: url_for(message.attachment.preview(format: :webp, resize_to_limit: [ Message::THUMBNAIL_MAX_WIDTH, Message::THUMBNAIL_MAX_HEIGHT ])),
|
src: rails_blob_path(message.attachment), poster: video_poster_url,
|
||||||
controls: true, preload: :none, width: "100%", height: "100%", class: "message__attachment"
|
controls: true, preload: :none, width: "100%", height: "100%", class: "message__attachment"
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# A preview is processed once the frame is drawn, but the poster is a variant of that frame, which can fail on its own.
|
||||||
|
def video_poster_url
|
||||||
|
poster = message.attachment.preview(:poster)
|
||||||
|
url_for(poster) if poster.processed? && poster.image.variant(poster.variation).processed?
|
||||||
|
end
|
||||||
|
|
||||||
def lightboxed_image_preview_tag
|
def lightboxed_image_preview_tag
|
||||||
width, height = preview_dimensions
|
width, height = preview_dimensions
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,12 @@
|
|||||||
module MessagesHelper
|
module MessagesHelper
|
||||||
|
# Rendered text is re-sanitized before linking. Rails' default safe list
|
||||||
|
# lacks the formatting the rich text editor produces
|
||||||
|
AUTO_LINK_ALLOWED_TAGS = Rails::HTML5::SafeListSanitizer.allowed_tags + ContentFilters::EDITOR_FORMATTING_TAGS
|
||||||
|
AUTO_LINK_ALLOWED_ATTRIBUTES = Rails::HTML5::SafeListSanitizer.allowed_attributes + ContentFilters::EDITOR_FORMATTING_ATTRIBUTES
|
||||||
|
|
||||||
|
# Text auto_link could turn into a link: a URL has a scheme or www., an email address an @
|
||||||
|
LINKABLE_TEXT = /:|@|www\./i
|
||||||
|
|
||||||
def message_area_tag(room, &)
|
def message_area_tag(room, &)
|
||||||
tag.div id: "message-area", class: "message-area", contents: true, data: {
|
tag.div id: "message-area", class: "message-area", contents: true, data: {
|
||||||
controller: "messages presence drop-target",
|
controller: "messages presence drop-target",
|
||||||
@@ -31,6 +39,7 @@ module MessagesHelper
|
|||||||
controller: "reply",
|
controller: "reply",
|
||||||
user_id: message.creator_id,
|
user_id: message.creator_id,
|
||||||
message_id: message.id,
|
message_id: message.id,
|
||||||
|
client_message_id: message.client_message_id,
|
||||||
message_timestamp: message_timestamp_milliseconds,
|
message_timestamp: message_timestamp_milliseconds,
|
||||||
message_updated_at: message.updated_at.to_fs(:epoch),
|
message_updated_at: message.updated_at.to_fs(:epoch),
|
||||||
sort_value: message_timestamp_milliseconds,
|
sort_value: message_timestamp_milliseconds,
|
||||||
@@ -57,7 +66,7 @@ module MessagesHelper
|
|||||||
when "sound"
|
when "sound"
|
||||||
message_sound_presentation(message)
|
message_sound_presentation(message)
|
||||||
else
|
else
|
||||||
auto_link h(ContentFilters::TextMessagePresentationFilters.apply(message.body.body)), html: { target: "_blank" }
|
auto_link_text h(ContentFilters::TextMessagePresentationFilters.apply(message.body.body))
|
||||||
end
|
end
|
||||||
rescue Exception => e
|
rescue Exception => e
|
||||||
Sentry.capture_exception(e, extra: { message: message })
|
Sentry.capture_exception(e, extra: { message: message })
|
||||||
@@ -67,6 +76,24 @@ module MessagesHelper
|
|||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
# auto_link finds URLs and email addresses with regular expressions, which
|
||||||
|
# can't tell an attribute value from text. Hand it one text node at a time,
|
||||||
|
# escaped as the serializer writes text, so a link can only land between tags.
|
||||||
|
def auto_link_text(html)
|
||||||
|
fragment = Nokogiri::HTML5.fragment(sanitize(html, tags: AUTO_LINK_ALLOWED_TAGS, attributes: AUTO_LINK_ALLOWED_ATTRIBUTES))
|
||||||
|
|
||||||
|
fragment.xpath(".//text()[not(ancestor::a)]").each do |node|
|
||||||
|
if node.text.match?(LINKABLE_TEXT)
|
||||||
|
text = node.to_html
|
||||||
|
linked = auto_link(text, html: { target: "_blank" })
|
||||||
|
node.replace Nokogiri::HTML5.fragment(linked) unless linked == text
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# A <pre> starting with a newline needs an extra one written out to survive the next parse
|
||||||
|
fragment.to_html(preserve_newline: true).html_safe
|
||||||
|
end
|
||||||
|
|
||||||
def messages_actions
|
def messages_actions
|
||||||
"turbo:before-stream-render@document->messages#beforeStreamRender keydown.up@document->messages#editMyLastMessage"
|
"turbo:before-stream-render@document->messages#beforeStreamRender keydown.up@document->messages#editMyLastMessage"
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,11 +1,33 @@
|
|||||||
module RichTextHelper
|
module RichTextHelper
|
||||||
def rich_text_data_actions
|
def rich_text_data_actions
|
||||||
default_actions =
|
# submitByKeyboard runs in the capture phase so it can submit on Enter
|
||||||
"trix-change->typing-notifications#start keydown->composer#submitByKeyboard"
|
# before the editor turns the keystroke into a newline
|
||||||
|
"lexxy:change->typing-notifications#start keydown->composer#submitByKeyboard:capture"
|
||||||
|
end
|
||||||
|
|
||||||
autocomplete_actions =
|
def mention_prompt_tag(room)
|
||||||
"trix-focus->rich-autocomplete#focus trix-change->rich-autocomplete#search trix-blur->rich-autocomplete#blur"
|
tag.lexxy_prompt trigger: "@", name: "mention", src: autocompletable_users_path(room_id: room.id),
|
||||||
|
"remote-filtering": true, "empty-results": "No matches"
|
||||||
|
end
|
||||||
|
|
||||||
[ default_actions, autocomplete_actions ].join(" ")
|
# The editor keeps an attachment's content as it finds it, so every
|
||||||
|
# attachment is rebuilt from its attachable before editing: a Trix-era
|
||||||
|
# embed carries its details as node attributes the editor doesn't
|
||||||
|
# round-trip, a mention edited under Trix carries the generic content type
|
||||||
|
# the editor doesn't permit, and a hand-written embed carries whatever
|
||||||
|
# markup the author put there.
|
||||||
|
def editable_body(message)
|
||||||
|
fragment = ActionText::Fragment.wrap(message.body.body_before_type_cast)
|
||||||
|
|
||||||
|
transformed = fragment.replace(ActionText::Attachment.tag_name) do |node|
|
||||||
|
attachment = ActionText::Attachment.from_node(node)
|
||||||
|
|
||||||
|
node.tap do |n|
|
||||||
|
n["content-type"] = attachment.attachable.attachable_content_type
|
||||||
|
n["content"] = render_action_text_attachment(attachment)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
ActionText::RichText.new(body: transformed.to_html)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -73,12 +73,12 @@ module RoomsHelper
|
|||||||
def composer_data_actions
|
def composer_data_actions
|
||||||
drag_and_drop_actions = "drop-target:drop@window->composer#dropFiles"
|
drag_and_drop_actions = "drop-target:drop@window->composer#dropFiles"
|
||||||
|
|
||||||
trix_attachment_actions =
|
attachment_actions =
|
||||||
"trix-file-accept->composer#preventAttachment refresh-room:online@window->composer#online"
|
"lexxy:file-accept->composer#preventAttachment refresh-room:online@window->composer#online"
|
||||||
|
|
||||||
remaining_actions =
|
remaining_actions =
|
||||||
"typing-notifications#stop paste->composer#pasteFiles turbo:submit-end->composer#submitEnd refresh-room:offline@window->composer#offline"
|
"typing-notifications#stop paste->composer#pasteFiles turbo:submit-end->composer#submitEnd refresh-room:offline@window->composer#offline"
|
||||||
|
|
||||||
[ drop_target_actions, drag_and_drop_actions, trix_attachment_actions, remaining_actions ].join(" ")
|
[ drop_target_actions, drag_and_drop_actions, attachment_actions, remaining_actions ].join(" ")
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -2,6 +2,7 @@ module TranslationsHelper
|
|||||||
TRANSLATIONS = {
|
TRANSLATIONS = {
|
||||||
email_address: { "🇺🇸": "Enter your email address", "🇪🇸": "Introduce tu correo electrónico", "🇫🇷": "Entrez votre adresse courriel", "🇮🇳": "अपना ईमेल पता दर्ज करें", "🇩🇪": "Geben Sie Ihre E-Mail-Adresse ein", "🇧🇷": "Insira seu endereço de email", "🇯🇵": "メールアドレスを入力してください" },
|
email_address: { "🇺🇸": "Enter your email address", "🇪🇸": "Introduce tu correo electrónico", "🇫🇷": "Entrez votre adresse courriel", "🇮🇳": "अपना ईमेल पता दर्ज करें", "🇩🇪": "Geben Sie Ihre E-Mail-Adresse ein", "🇧🇷": "Insira seu endereço de email", "🇯🇵": "メールアドレスを入力してください" },
|
||||||
password: { "🇺🇸": "Enter your password", "🇪🇸": "Introduce tu contraseña", "🇫🇷": "Saisissez votre mot de passe", "🇮🇳": "अपना पासवर्ड दर्ज करें", "🇩🇪": "Geben Sie Ihr Passwort ein", "🇧🇷": "Insira sua senha", "🇯🇵": "パスワードを入力してください" },
|
password: { "🇺🇸": "Enter your password", "🇪🇸": "Introduce tu contraseña", "🇫🇷": "Saisissez votre mot de passe", "🇮🇳": "अपना पासवर्ड दर्ज करें", "🇩🇪": "Geben Sie Ihr Passwort ein", "🇧🇷": "Insira sua senha", "🇯🇵": "パスワードを入力してください" },
|
||||||
|
current_password: { "🇺🇸": "Enter your current password", "🇪🇸": "Introduce tu contraseña actual", "🇫🇷": "Saisissez votre mot de passe actuel", "🇮🇳": "अपना वर्तमान पासवर्ड दर्ज करें", "🇩🇪": "Geben Sie Ihr aktuelles Passwort ein", "🇧🇷": "Insira sua senha atual", "🇯🇵": "現在のパスワードを入力してください" },
|
||||||
update_password: { "🇺🇸": "Change password", "🇪🇸": "Cambiar contraseña", "🇫🇷": "Changer le mot de passe", "🇮🇳": "पासवर्ड बदलें", "🇩🇪": "Passwort ändern", "🇧🇷": "Alterar senha", "🇯🇵": "パスワードを変更" },
|
update_password: { "🇺🇸": "Change password", "🇪🇸": "Cambiar contraseña", "🇫🇷": "Changer le mot de passe", "🇮🇳": "पासवर्ड बदलें", "🇩🇪": "Passwort ändern", "🇧🇷": "Alterar senha", "🇯🇵": "パスワードを変更" },
|
||||||
user_name: { "🇺🇸": "Enter your name", "🇪🇸": "Introduce tu nombre", "🇫🇷": "Entrez votre nom", "🇮🇳": "अपना नाम दर्ज करें", "🇩🇪": "Geben Sie Ihren Namen ein", "🇧🇷": "Insira seu nome", "🇯🇵": "お名前を入力してください" },
|
user_name: { "🇺🇸": "Enter your name", "🇪🇸": "Introduce tu nombre", "🇫🇷": "Entrez votre nom", "🇮🇳": "अपना नाम दर्ज करें", "🇩🇪": "Geben Sie Ihren Namen ein", "🇧🇷": "Insira seu nome", "🇯🇵": "お名前を入力してください" },
|
||||||
account_name: { "🇺🇸": "Name this account", "🇪🇸": "Nombre de esta cuenta", "🇫🇷": "Nommez ce compte", "🇮🇳": "इस खाते का नाम दें", "🇩🇪": "Benennen Sie dieses Konto", "🇧🇷": "Dê um nome a essa conta", "🇯🇵": "アカウントに名前を付ける" },
|
account_name: { "🇺🇸": "Name this account", "🇪🇸": "Nombre de esta cuenta", "🇫🇷": "Nommez ce compte", "🇮🇳": "इस खाते का नाम दें", "🇩🇪": "Benennen Sie dieses Konto", "🇧🇷": "Dê um nome a essa conta", "🇯🇵": "アカウントに名前を付ける" },
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
module Users::PushSubscriptionsHelper
|
||||||
|
# Names the session without revealing its token, so the browser can tell when it's in a new one. The creation
|
||||||
|
# time keeps an id reused by a new database from looking like the same session.
|
||||||
|
def push_subscription_session_marker
|
||||||
|
Digest::SHA256.hexdigest("push-subscription-session-#{Current.session.id}-#{Current.session.created_at.to_f}") if Current.session
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -1,6 +1,4 @@
|
|||||||
// Configure your import map in config/importmap.rb. Read more: https://github.com/rails/importmap-rails
|
// Configure your import map in config/importmap.rb. Read more: https://github.com/rails/importmap-rails
|
||||||
import "@hotwired/turbo-rails"
|
import "@hotwired/turbo-rails"
|
||||||
import "trix"
|
|
||||||
import "@rails/actiontext"
|
|
||||||
import "initializers"
|
import "initializers"
|
||||||
import "controllers"
|
import "controllers"
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Controller } from "@hotwired/stimulus"
|
import { Controller } from "@hotwired/stimulus"
|
||||||
import FileUploader from "models/file_uploader"
|
import FileUploader from "models/file_uploader"
|
||||||
import { onNextEventLoopTick, nextFrame } from "helpers/timing_helpers"
|
import { onNextEventLoopTick, nextFrame } from "helpers/timing_helpers"
|
||||||
import { escapeHTML } from "helpers/dom_helpers"
|
import { escapeHTML } from "helpers/string_helpers"
|
||||||
|
|
||||||
export default class extends Controller {
|
export default class extends Controller {
|
||||||
static classes = ["toolbar"]
|
static classes = ["toolbar"]
|
||||||
@@ -12,11 +12,21 @@ export default class extends Controller {
|
|||||||
#files = []
|
#files = []
|
||||||
|
|
||||||
connect() {
|
connect() {
|
||||||
|
this.#restoreDraft()
|
||||||
|
|
||||||
if (!this.#usingTouchDevice) {
|
if (!this.#usingTouchDevice) {
|
||||||
onNextEventLoopTick(() => this.textTarget.focus())
|
onNextEventLoopTick(() => this.textTarget.focus())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
saveDraft() {
|
||||||
|
if (this.textTarget.isBlank) {
|
||||||
|
localStorage.removeItem(this.#draftKey)
|
||||||
|
} else {
|
||||||
|
localStorage.setItem(this.#draftKey, this.textTarget.value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
submit(event) {
|
submit(event) {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
|
|
||||||
@@ -44,21 +54,20 @@ export default class extends Controller {
|
|||||||
}
|
}
|
||||||
|
|
||||||
replaceMessageContent(content) {
|
replaceMessageContent(content) {
|
||||||
const editor = this.textTarget.editor
|
this.textTarget.value = content
|
||||||
|
this.textTarget.focus()
|
||||||
editor.recordUndoEntry("Format reply")
|
this.textTarget.selection.placeCursorAtTheEnd()
|
||||||
editor.setSelectedRange([0, editor.getDocument().toString().length])
|
|
||||||
editor.deleteInDirection("forward")
|
|
||||||
editor.insertHTML(content)
|
|
||||||
editor.setSelectedRange([editor.getDocument().toString().length - 1])
|
|
||||||
}
|
}
|
||||||
|
|
||||||
submitByKeyboard(event) {
|
submitByKeyboard(event) {
|
||||||
|
if (event.key != "Enter" || this.textTarget.hasOpenPrompt) return
|
||||||
|
|
||||||
const toolbarVisible = this.element.classList.contains(this.toolbarClass)
|
const toolbarVisible = this.element.classList.contains(this.toolbarClass)
|
||||||
const metaEnter = event.key == "Enter" && (event.metaKey || event.ctrlKey)
|
const metaEnter = event.metaKey || event.ctrlKey
|
||||||
const plainEnter = event.keyCode == 13 && !event.shiftKey && !event.isComposing
|
const plainEnter = !event.shiftKey && !event.isComposing
|
||||||
|
|
||||||
if (!this.#usingTouchDevice && (metaEnter || (plainEnter && !toolbarVisible))) {
|
if (!this.#usingTouchDevice && (metaEnter || (plainEnter && !toolbarVisible))) {
|
||||||
|
event.stopPropagation()
|
||||||
this.submit(event)
|
this.submit(event)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -108,6 +117,19 @@ export default class extends Controller {
|
|||||||
this.fieldsTarget.disabled = true
|
this.fieldsTarget.disabled = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#restoreDraft() {
|
||||||
|
const draft = localStorage.getItem(this.#draftKey)
|
||||||
|
|
||||||
|
if (draft) {
|
||||||
|
this.textTarget.value = draft
|
||||||
|
this.textTarget.selection.placeCursorAtTheEnd()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
get #draftKey() {
|
||||||
|
return `composer-draft-${this.roomIdValue}`
|
||||||
|
}
|
||||||
|
|
||||||
get #usingTouchDevice() {
|
get #usingTouchDevice() {
|
||||||
return 'ontouchstart' in window || navigator.maxTouchPoints > 0 || navigator.msMaxTouchPoints > 0;
|
return 'ontouchstart' in window || navigator.maxTouchPoints > 0 || navigator.msMaxTouchPoints > 0;
|
||||||
}
|
}
|
||||||
@@ -126,7 +148,7 @@ export default class extends Controller {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#validInput() {
|
#validInput() {
|
||||||
return this.textTarget.textContent.trim().length > 0
|
return !this.textTarget.isBlank
|
||||||
}
|
}
|
||||||
|
|
||||||
async #submitFiles() {
|
async #submitFiles() {
|
||||||
@@ -159,6 +181,7 @@ export default class extends Controller {
|
|||||||
|
|
||||||
#reset() {
|
#reset() {
|
||||||
this.textTarget.value = ""
|
this.textTarget.value = ""
|
||||||
|
localStorage.removeItem(this.#draftKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
#updateFileList() {
|
#updateFileList() {
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ export default class extends Controller {
|
|||||||
}
|
}
|
||||||
|
|
||||||
messageTargetConnected(target) {
|
messageTargetConnected(target) {
|
||||||
|
this.#reconcilePendingMessage(target)
|
||||||
this.#formatter.format(target, ThreadStyle.thread)
|
this.#formatter.format(target, ThreadStyle.thread)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -89,7 +90,7 @@ export default class extends Controller {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async editMyLastMessage() {
|
async editMyLastMessage() {
|
||||||
const editorEmpty = document.querySelector("#composer trix-editor").matches(":empty")
|
const editorEmpty = document.querySelector("#composer lexxy-editor").isBlank
|
||||||
|
|
||||||
if (editorEmpty && this.#paginator.upToDate) {
|
if (editorEmpty && this.#paginator.upToDate) {
|
||||||
this.#myLastMessage?.querySelector(".message__edit-btn")?.click()
|
this.#myLastMessage?.querySelector(".message__edit-btn")?.click()
|
||||||
@@ -125,6 +126,25 @@ export default class extends Controller {
|
|||||||
|
|
||||||
// Internal
|
// Internal
|
||||||
|
|
||||||
|
// The composer inserts a pending message client-side using the client_message_id
|
||||||
|
// as its DOM id. The server broadcast now renders the real message with a DOM id
|
||||||
|
// derived from the record's primary key, so it no longer replaces the pending
|
||||||
|
// element by id. Reconcile by matching the server message's client_message_id and
|
||||||
|
// dropping our own pending placeholder. Only elements flagged data-pending-message
|
||||||
|
// (client-side placeholders, never broadcast) are removed, so a message another
|
||||||
|
// member posted reusing this client_message_id can never displace a real one.
|
||||||
|
#reconcilePendingMessage(target) {
|
||||||
|
const clientMessageId = target.dataset.clientMessageId
|
||||||
|
|
||||||
|
if (target.dataset.messageId && clientMessageId) {
|
||||||
|
const selector = `[data-pending-message][data-client-message-id="${CSS.escape(clientMessageId)}"]`
|
||||||
|
|
||||||
|
this.messagesTarget.querySelectorAll(selector).forEach((pending) => {
|
||||||
|
if (pending !== target) pending.remove()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async #ensureUpToDate() {
|
async #ensureUpToDate() {
|
||||||
if (!this.#paginator.upToDate) {
|
if (!this.#paginator.upToDate) {
|
||||||
await this.#paginator.resetToLastPage()
|
await this.#paginator.resetToLastPage()
|
||||||
|
|||||||
@@ -106,7 +106,8 @@ export default class extends Controller {
|
|||||||
|
|
||||||
async #syncPushSubscription(subscription) {
|
async #syncPushSubscription(subscription) {
|
||||||
const response = await post(this.subscriptionsUrlValue, { body: this.#extractJsonPayloadAsString(subscription), responseKind: "turbo-stream" })
|
const response = await post(this.subscriptionsUrlValue, { body: this.#extractJsonPayloadAsString(subscription), responseKind: "turbo-stream" })
|
||||||
if (!response.ok) subscription.unsubscribe()
|
// Only a refused endpoint is for good. A session signed out mid-request posts it again after signing in.
|
||||||
|
if (response.unprocessableEntity) subscription.unsubscribe()
|
||||||
}
|
}
|
||||||
|
|
||||||
async #requestPermissionAndSubscribe(registration) {
|
async #requestPermissionAndSubscribe(registration) {
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { Controller } from "@hotwired/stimulus"
|
||||||
|
import { post } from "@rails/request.js"
|
||||||
|
|
||||||
|
const SYNCED_SESSION_KEY = "push-subscription-synced-session"
|
||||||
|
|
||||||
|
// A password change removes the account's push subscriptions, the one this browser holds included. Post it again
|
||||||
|
// once per session, so a browser that's still signed in keeps getting notifications. A failure here leaves the
|
||||||
|
// browser's own subscription alone: the next session tries again.
|
||||||
|
export default class extends Controller {
|
||||||
|
static values = { url: String, session: String }
|
||||||
|
|
||||||
|
async connect() {
|
||||||
|
if (this.#allowed && this.sessionValue && localStorage.getItem(SYNCED_SESSION_KEY) !== this.sessionValue) {
|
||||||
|
const registration = await navigator.serviceWorker.getRegistration(window.location.origin)
|
||||||
|
const subscription = await registration?.pushManager?.getSubscription()
|
||||||
|
|
||||||
|
if (Notification.permission === "granted" && subscription) {
|
||||||
|
await post(this.urlValue, { body: this.#payload(subscription), responseKind: "turbo-stream" })
|
||||||
|
localStorage.setItem(SYNCED_SESSION_KEY, this.sessionValue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
get #allowed() {
|
||||||
|
return navigator.serviceWorker && window.Notification
|
||||||
|
}
|
||||||
|
|
||||||
|
#payload(subscription) {
|
||||||
|
const { endpoint, keys: { p256dh, auth } } = subscription.toJSON()
|
||||||
|
return JSON.stringify({ push_subscription: { endpoint, p256dh_key: p256dh, auth_key: auth } })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,7 +16,7 @@ export default class extends Controller {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
#read = ({ room_id }) => {
|
#read = ({ room_id, at }) => {
|
||||||
this.dispatch("read", { detail: { roomId: room_id } })
|
this.dispatch("read", { detail: { roomId: room_id, at } })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -11,7 +11,7 @@ export default class extends Controller {
|
|||||||
}
|
}
|
||||||
|
|
||||||
reply() {
|
reply() {
|
||||||
const content = `<blockquote>${this.#bodyContent}</blockquote><cite>${this.authorTarget.innerHTML} ${this.#linkToOriginal}</cite><br>`
|
const content = `<blockquote>${this.#bodyContent}</blockquote><cite>${this.authorTarget.innerHTML} ${this.#linkToOriginal}</cite><p><br></p>`
|
||||||
this.composerOutlet.replaceMessageContent(content)
|
this.composerOutlet.replaceMessageContent(content)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -23,12 +23,12 @@ export default class extends Controller {
|
|||||||
}
|
}
|
||||||
|
|
||||||
get #bodyContent() {
|
get #bodyContent() {
|
||||||
const body = this.bodyTarget.querySelector(".trix-content").cloneNode(true)
|
const body = this.bodyTarget.querySelector(".lexxy-content").cloneNode(true)
|
||||||
return this.#stripMentionAttachments(this.#stripUnfurledAttachments(body)).innerHTML
|
return this.#stripMentionAttachments(this.#stripUnfurledAttachments(body)).innerHTML
|
||||||
}
|
}
|
||||||
|
|
||||||
#stripMentionAttachments(node) {
|
#stripMentionAttachments(node) {
|
||||||
node.querySelectorAll(".mention").forEach(mention => mention.outerHTML = mention.textContent.trim())
|
node.querySelectorAll(".mention").forEach(mention => mention.replaceWith(mention.textContent.trim()))
|
||||||
return node
|
return node
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,46 +0,0 @@
|
|||||||
import { Controller } from "@hotwired/stimulus"
|
|
||||||
import MentionsAutocompleteHandler from "lib/autocomplete/mentions_autocomplete_handler"
|
|
||||||
import { debounce } from "helpers/timing_helpers"
|
|
||||||
|
|
||||||
export default class extends Controller {
|
|
||||||
static values = { url: String }
|
|
||||||
|
|
||||||
initialize() {
|
|
||||||
this.handlers = []
|
|
||||||
this.search = debounce(this.search.bind(this), 300)
|
|
||||||
}
|
|
||||||
|
|
||||||
connect() {
|
|
||||||
if (this.element == document.activeElement) {
|
|
||||||
this.#installHandlers()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
focus(event) {
|
|
||||||
this.#installHandlers()
|
|
||||||
}
|
|
||||||
|
|
||||||
search(event) {
|
|
||||||
const content = this.editor.getDocument().toString()
|
|
||||||
const position = this.editor.getPosition()
|
|
||||||
this.handlers.forEach(handler => handler.updateWithContentAndPosition(content, position))
|
|
||||||
}
|
|
||||||
|
|
||||||
blur(event) {
|
|
||||||
this.#uninstallHandlers()
|
|
||||||
}
|
|
||||||
|
|
||||||
#installHandlers() {
|
|
||||||
this.#uninstallHandlers()
|
|
||||||
this.handlers = [ new MentionsAutocompleteHandler(this.element, this.urlValue) ]
|
|
||||||
}
|
|
||||||
|
|
||||||
#uninstallHandlers() {
|
|
||||||
this.handlers.forEach(handler => handler.destroy())
|
|
||||||
this.handlers = []
|
|
||||||
}
|
|
||||||
|
|
||||||
get editor() {
|
|
||||||
return this.element.editor
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -7,8 +7,13 @@ export default class extends Controller {
|
|||||||
static classes = [ "unread" ]
|
static classes = [ "unread" ]
|
||||||
|
|
||||||
#disconnected = true
|
#disconnected = true
|
||||||
|
#connection = 0
|
||||||
|
#discardDirectEditor = false
|
||||||
|
#readAt = new Map()
|
||||||
|
|
||||||
async connect() {
|
async connect() {
|
||||||
|
this.element.addEventListener("turbo:click", this.#directNavigation)
|
||||||
|
this.element.addEventListener("turbo:before-frame-render", this.#preserveDirectEditor)
|
||||||
this.channel ??= await cable.subscribeTo({ channel: "UnreadRoomsChannel" }, {
|
this.channel ??= await cable.subscribeTo({ channel: "UnreadRoomsChannel" }, {
|
||||||
connected: this.#channelConnected.bind(this),
|
connected: this.#channelConnected.bind(this),
|
||||||
disconnected: this.#channelDisconnected.bind(this),
|
disconnected: this.#channelDisconnected.bind(this),
|
||||||
@@ -17,7 +22,11 @@ export default class extends Controller {
|
|||||||
}
|
}
|
||||||
|
|
||||||
disconnect() {
|
disconnect() {
|
||||||
|
this.element.removeEventListener("turbo:click", this.#directNavigation)
|
||||||
|
this.element.removeEventListener("turbo:before-frame-render", this.#preserveDirectEditor)
|
||||||
|
this.#discardDirectEditor = false
|
||||||
ignoringBriefDisconnects(this.element, () => {
|
ignoringBriefDisconnects(this.element, () => {
|
||||||
|
this.#channelDisconnected()
|
||||||
this.channel?.unsubscribe()
|
this.channel?.unsubscribe()
|
||||||
this.channel = null
|
this.channel = null
|
||||||
})
|
})
|
||||||
@@ -27,19 +36,58 @@ export default class extends Controller {
|
|||||||
this.read({ detail: { roomId: Current.room.id } })
|
this.read({ detail: { roomId: Current.room.id } })
|
||||||
}
|
}
|
||||||
|
|
||||||
read({ detail: { roomId } }) {
|
read({ detail: { roomId, at } }) {
|
||||||
const room = this.#findRoomTarget(roomId)
|
const room = this.#findRoomTarget(roomId)
|
||||||
|
|
||||||
|
if (at) {
|
||||||
|
this.#readAt.set(Number(roomId), Math.max(Number(at), this.#readAt.get(Number(roomId)) ?? 0))
|
||||||
|
}
|
||||||
|
|
||||||
if (room) {
|
if (room) {
|
||||||
room.classList.remove(this.unreadClass)
|
room.classList.remove(this.unreadClass)
|
||||||
this.dispatch("read", { detail: { targetId: roomId } })
|
this.dispatch("read", { detail: { targetId: roomId } })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#channelConnected() {
|
// Preserve the live picker through background refreshes, but allow deliberate navigation.
|
||||||
|
#directNavigation = event => {
|
||||||
|
if (event.target.closest("a")?.dataset.turboFrame === this.element.id) {
|
||||||
|
this.#discardDirectEditor = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#preserveDirectEditor = event => {
|
||||||
|
if (event.target !== this.element) return
|
||||||
|
const discard = this.#discardDirectEditor
|
||||||
|
this.#discardDirectEditor = false
|
||||||
|
if (discard) return
|
||||||
|
|
||||||
|
const current = this.element.querySelector("#direct_rooms_control")
|
||||||
|
const incoming = event.detail.newFrame.querySelector("#direct_rooms_control")
|
||||||
|
if (!current?.querySelector('[data-autocomplete-target="input"]') || !incoming) return
|
||||||
|
|
||||||
|
// Turbo's Bardo moves the actual element, keeping input, selections and controllers.
|
||||||
|
const controls = [ current, incoming ].map(element => [ element, element.hasAttribute("data-turbo-permanent") ])
|
||||||
|
for (const [ element ] of controls) element.setAttribute("data-turbo-permanent", "")
|
||||||
|
const render = event.detail.render
|
||||||
|
event.detail.render = (...args) => {
|
||||||
|
try {
|
||||||
|
return render(...args)
|
||||||
|
} finally {
|
||||||
|
for (const [ element, permanent ] of controls) element.toggleAttribute("data-turbo-permanent", permanent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async #channelConnected() {
|
||||||
if (this.#disconnected) {
|
if (this.#disconnected) {
|
||||||
this.#disconnected = false
|
this.#disconnected = false
|
||||||
this.element.reload()
|
const connection = ++this.#connection
|
||||||
|
// Reloading an unfinished frame aborts its response body reader.
|
||||||
|
await this.element.loaded
|
||||||
|
if (this.element.isConnected && !this.#disconnected && connection === this.#connection) {
|
||||||
|
this.element.reload()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -47,11 +95,11 @@ export default class extends Controller {
|
|||||||
this.#disconnected = true
|
this.#disconnected = true
|
||||||
}
|
}
|
||||||
|
|
||||||
#unread({ roomId }) {
|
#unread({ roomId, at }) {
|
||||||
const unreadRoom = this.#findRoomTarget(roomId)
|
const unreadRoom = this.#findRoomTarget(roomId)
|
||||||
|
|
||||||
if (unreadRoom) {
|
if (unreadRoom) {
|
||||||
if (Current.room.id != roomId) {
|
if (Current.room.id != roomId && !this.#readSince(roomId, at)) {
|
||||||
unreadRoom.classList.add(this.unreadClass)
|
unreadRoom.classList.add(this.unreadClass)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,6 +107,12 @@ export default class extends Controller {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Notices fan out one member at a time, so one can arrive after the member has already
|
||||||
|
// read the room in another tab. It still reorders the room, but doesn't mark it unread.
|
||||||
|
#readSince(roomId, at) {
|
||||||
|
return Number(at) <= this.#readAt.get(Number(roomId))
|
||||||
|
}
|
||||||
|
|
||||||
#findRoomTarget(roomId) {
|
#findRoomTarget(roomId) {
|
||||||
return this.roomTargets.find(roomTarget => roomTarget.dataset.roomId == roomId)
|
return this.roomTargets.find(roomTarget => roomTarget.dataset.roomId == roomId)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import { Controller } from "@hotwired/stimulus"
|
||||||
|
import { post } from "@rails/request.js"
|
||||||
|
import { escapeHTML, truncateString } from "helpers/string_helpers"
|
||||||
|
|
||||||
|
const OPENGRAPH_EMBED_CONTENT_TYPE = "application/vnd.actiontext.opengraph-embed"
|
||||||
|
|
||||||
|
const UNFURLED_TWITTER_AVATAR_CSS_CLASS = "og-embed--twitter-avatar"
|
||||||
|
const TWITTER_AVATAR_URL_PREFIX = "https://pbs.twimg.com/profile_images"
|
||||||
|
|
||||||
|
export default class extends Controller {
|
||||||
|
#abortController
|
||||||
|
|
||||||
|
disconnect() {
|
||||||
|
this.#abortController?.abort()
|
||||||
|
}
|
||||||
|
|
||||||
|
async unfurl(event) {
|
||||||
|
if (!this.element.permitsAttachmentContentType(OPENGRAPH_EMBED_CONTENT_TYPE)) return
|
||||||
|
|
||||||
|
const { url, insertBelowLink } = event.detail
|
||||||
|
|
||||||
|
const metadata = await this.#fetchOpengraphMetadata(url)
|
||||||
|
|
||||||
|
if (metadata) {
|
||||||
|
insertBelowLink(this.#opengraphEmbedHTML(metadata), { attachment: { contentType: OPENGRAPH_EMBED_CONTENT_TYPE } })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async #fetchOpengraphMetadata(url) {
|
||||||
|
this.#abortController?.abort()
|
||||||
|
this.#abortController = new AbortController()
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await post("/unfurl_link", {
|
||||||
|
body: { url },
|
||||||
|
contentType: "application/json",
|
||||||
|
signal: this.#abortController.signal
|
||||||
|
})
|
||||||
|
|
||||||
|
if (response.ok && response.statusCode !== 204) {
|
||||||
|
const { title, url: href, image, description } = await response.json
|
||||||
|
if (title && href) return { title, href, image, description }
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Ignore aborted and failed requests
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
#opengraphEmbedHTML({ title, href, image, description }) {
|
||||||
|
return `<actiontext-opengraph-embed>
|
||||||
|
<div class="og-embed gap ${this.#embedClass(image)}">
|
||||||
|
<div class="og-embed__content">
|
||||||
|
<div class="og-embed__title">
|
||||||
|
<a href="${escapeHTML(href)}" rel="noreferrer" target="_blank">${escapeHTML(truncateString(title, 280))}</a>
|
||||||
|
</div>
|
||||||
|
<div class="og-embed__description">${escapeHTML(truncateString(description, 560))}</div>
|
||||||
|
</div>
|
||||||
|
${this.#imageHTML(image)}
|
||||||
|
</div>
|
||||||
|
</actiontext-opengraph-embed>`
|
||||||
|
}
|
||||||
|
|
||||||
|
#embedClass(image) {
|
||||||
|
if (this.#isTwitterAvatar(image)) {
|
||||||
|
return UNFURLED_TWITTER_AVATAR_CSS_CLASS
|
||||||
|
} else {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#isTwitterAvatar(image) {
|
||||||
|
return Boolean(image) && image.startsWith(TWITTER_AVATAR_URL_PREFIX)
|
||||||
|
}
|
||||||
|
|
||||||
|
#imageHTML(image) {
|
||||||
|
if (image) {
|
||||||
|
return `<div class="og-embed__image"><img src="${escapeHTML(image)}" class="image center" alt="" /></div>`
|
||||||
|
} else {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,12 +4,6 @@ export function scrollToBottom(container) {
|
|||||||
container.scrollTop = container.scrollHeight
|
container.scrollTop = container.scrollHeight
|
||||||
}
|
}
|
||||||
|
|
||||||
export function escapeHTML(html) {
|
|
||||||
const div = document.createElement("div")
|
|
||||||
div.textContent = html
|
|
||||||
return div.innerHTML
|
|
||||||
}
|
|
||||||
|
|
||||||
export function parseHTMLFragment(html) {
|
export function parseHTMLFragment(html) {
|
||||||
const template = document.createElement("template")
|
const template = document.createElement("template")
|
||||||
template.innerHTML = html
|
template.innerHTML = html
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
const HTML_ESCAPES = { "&": "&", "<": "<", ">": ">", "\"": """, "'": "'" }
|
||||||
|
|
||||||
export function truncateString(string, length, omission = "…") {
|
export function truncateString(string, length, omission = "…") {
|
||||||
if (string.length <= length) {
|
if (string.length <= length) {
|
||||||
return string
|
return string
|
||||||
@@ -5,3 +7,7 @@ export function truncateString(string, length, omission = "…") {
|
|||||||
return string.slice(0, length - omission.length) + omission
|
return string.slice(0, length - omission.length) + omission
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function escapeHTML(string) {
|
||||||
|
return String(string ?? "").replace(/[&<>"']/g, character => HTML_ESCAPES[character])
|
||||||
|
}
|
||||||
@@ -1,10 +1,19 @@
|
|||||||
import Unfurler from "lib/rich_text/unfurl/unfurler"
|
import * as Lexxy from "lexxy"
|
||||||
|
import CampfireRichTextExtension from "lib/rich_text/campfire_extension"
|
||||||
|
|
||||||
// Support a `cite` block for attribution links
|
Lexxy.configure({
|
||||||
Trix.config.blockAttributes.cite = {
|
global: {
|
||||||
tagName: "cite",
|
// Keep the content type of mention attachments (application/vnd.campfire.mention)
|
||||||
inheritable: false,
|
// that Campfire has used since its Trix days
|
||||||
}
|
attachmentContentTypeNamespace: "campfire",
|
||||||
|
extensions: [ CampfireRichTextExtension ]
|
||||||
|
},
|
||||||
|
default: {
|
||||||
|
// Campfire sends files as separate messages via the composer's own
|
||||||
|
// attach button, never through the editor
|
||||||
|
toolbar: { attachments: false },
|
||||||
|
|
||||||
const unfurler = new Unfurler()
|
// Trix offered a single heading level, rendered as h1
|
||||||
unfurler.install()
|
headings: [ "h1" ]
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -120,7 +120,7 @@ export default class BaseAutocompleteHandler {
|
|||||||
|
|
||||||
#fetchAutocompletables(url) {
|
#fetchAutocompletables(url) {
|
||||||
if (url) {
|
if (url) {
|
||||||
return fetch(url, { as: "json" }).then(response => response.json())
|
return fetch(url, { headers: { Accept: "application/json" } }).then(response => response.json())
|
||||||
} else {
|
} else {
|
||||||
return Promise.resolve()
|
return Promise.resolve()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,48 +0,0 @@
|
|||||||
import BaseAutocompleteHandler from "lib/autocomplete/base_autocomplete_handler"
|
|
||||||
import { PUNCTUATION_PATTERN } from "lib/autocomplete/constants"
|
|
||||||
|
|
||||||
export default class extends BaseAutocompleteHandler {
|
|
||||||
get pattern() {
|
|
||||||
return new RegExp(`^@(.*?)(${PUNCTUATION_PATTERN.source}*)$`)
|
|
||||||
}
|
|
||||||
|
|
||||||
insertAutocompletable(autocompletable, range, terminator, options = {}) {
|
|
||||||
const attachment = this.#createAttachmentForAutocompletable(autocompletable)
|
|
||||||
this.#insertAttachmentAndTerminatorIntoEditorAtRange(attachment, terminator, range, options)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Override to set selector's position relative to the cursor in the editor
|
|
||||||
getOffsetsAtPosition(position) {
|
|
||||||
return this.#getOffsetsFromEditorAtPosition(this.#editor, position)
|
|
||||||
}
|
|
||||||
|
|
||||||
#createAttachmentForAutocompletable(mentionable) {
|
|
||||||
const mention = `
|
|
||||||
<span class="mention" sgid=${mentionable.sgid}>
|
|
||||||
<img src="${mentionable.avatar_url}" class="avatar" alt="${mentionable.name}">
|
|
||||||
${mentionable.name}
|
|
||||||
</span>
|
|
||||||
`
|
|
||||||
|
|
||||||
return new Trix.Attachment({
|
|
||||||
content: mention,
|
|
||||||
contentType: "application/vnd.campfire.mention",
|
|
||||||
sgid: mentionable.sgid
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
#insertAttachmentAndTerminatorIntoEditorAtRange(attachment, terminator, range) {
|
|
||||||
if (range) { this.#editor.setSelectedRange(range) }
|
|
||||||
this.#editor.insertAttachment(attachment)
|
|
||||||
this.#editor.insertString(terminator)
|
|
||||||
}
|
|
||||||
|
|
||||||
get #editor() {
|
|
||||||
return this.element.editor
|
|
||||||
}
|
|
||||||
|
|
||||||
#getOffsetsFromEditorAtPosition(editor, position) {
|
|
||||||
const rect = this.#editor.getClientRectAtPosition(position)
|
|
||||||
return rect ? rect : {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import * as Lexxy from "lexxy"
|
||||||
|
import CiteNode from "lib/rich_text/cite_node"
|
||||||
|
|
||||||
|
export default class CampfireRichTextExtension extends Lexxy.Extension {
|
||||||
|
get allowedElements() {
|
||||||
|
return [
|
||||||
|
"cite",
|
||||||
|
"figure",
|
||||||
|
"figcaption",
|
||||||
|
"actiontext-opengraph-embed",
|
||||||
|
{ tag: "div", attributes: [ "sgid" ] },
|
||||||
|
{ tag: "span", attributes: [ "sgid" ] },
|
||||||
|
{ tag: "img", attributes: [ "alt" ] },
|
||||||
|
{ tag: "a", attributes: [ "rel", "target" ] }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
get lexicalExtension() {
|
||||||
|
return this.defineExtension({
|
||||||
|
name: "campfire/rich-text",
|
||||||
|
nodes: [ CiteNode ]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import * as Lexxy from "lexxy"
|
||||||
|
|
||||||
|
const { ElementNode } = Lexxy.Lexical
|
||||||
|
|
||||||
|
export default class CiteNode extends ElementNode {
|
||||||
|
static getType() {
|
||||||
|
return "cite"
|
||||||
|
}
|
||||||
|
|
||||||
|
static clone(node) {
|
||||||
|
return new CiteNode(node.__key)
|
||||||
|
}
|
||||||
|
|
||||||
|
static importJSON(serializedNode) {
|
||||||
|
return new CiteNode().updateFromJSON(serializedNode)
|
||||||
|
}
|
||||||
|
|
||||||
|
static importDOM() {
|
||||||
|
return {
|
||||||
|
cite: () => ({ conversion: () => ({ node: new CiteNode() }), priority: 1 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
exportJSON() {
|
||||||
|
return { ...super.exportJSON(), type: "cite" }
|
||||||
|
}
|
||||||
|
|
||||||
|
createDOM() {
|
||||||
|
return document.createElement("cite")
|
||||||
|
}
|
||||||
|
|
||||||
|
updateDOM() {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
exportDOM() {
|
||||||
|
return { element: document.createElement("cite") }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,80 +0,0 @@
|
|||||||
import { post } from "@rails/request.js"
|
|
||||||
import { truncateString } from "helpers/string_helpers"
|
|
||||||
|
|
||||||
const UNFURLED_TWITTER_AVATAR_CSS_CLASS = "cf-twitter-avatar"
|
|
||||||
const TWITTER_AVATAR_URL_PREFIX = "https://pbs.twimg.com/profile_images"
|
|
||||||
|
|
||||||
export default class OpengraphEmbedOperation {
|
|
||||||
constructor(paste) {
|
|
||||||
this.paste = paste
|
|
||||||
this.editor = this.paste.editor
|
|
||||||
this.url = this.paste.string
|
|
||||||
|
|
||||||
this.abortController = new AbortController()
|
|
||||||
}
|
|
||||||
|
|
||||||
perform() {
|
|
||||||
return this.#createOpenGraphMetadataRequest()
|
|
||||||
.then(response => response.json)
|
|
||||||
.then(this.#insertOpengraphAttachment.bind(this))
|
|
||||||
.catch(() => null)
|
|
||||||
}
|
|
||||||
|
|
||||||
abort() {
|
|
||||||
this.abortController.abort()
|
|
||||||
}
|
|
||||||
|
|
||||||
#createOpenGraphMetadataRequest() {
|
|
||||||
return post("/unfurl_link", {
|
|
||||||
body: { url: this.url },
|
|
||||||
contentType: "application/json",
|
|
||||||
signal: this.abortController.signal
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
#insertOpengraphAttachment(response) {
|
|
||||||
if (this.#shouldInsertOpengraphPreview) {
|
|
||||||
const currentRange = this.editor.getSelectedRange()
|
|
||||||
this.editor.setSelectedRange(this.editor.getSelectedRange())
|
|
||||||
this.editor.recordUndoEntry("Insert Opengraph preview for Pasted URL")
|
|
||||||
this.editor.insertAttachment(this.#createOpengraphAttachment(response))
|
|
||||||
this.editor.setSelectedRange(currentRange)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
get #shouldInsertOpengraphPreview() {
|
|
||||||
return this.editor.getDocument().toString().includes(this.url)
|
|
||||||
}
|
|
||||||
|
|
||||||
#createOpengraphAttachment(response) {
|
|
||||||
const { title, url, image, description } = response
|
|
||||||
const html = this.#generateOpengraphEmbedHTML({ title, url, image, description })
|
|
||||||
|
|
||||||
return new Trix.Attachment({
|
|
||||||
contentType: "application/vnd.actiontext.opengraph-embed",
|
|
||||||
content: html,
|
|
||||||
filename: title,
|
|
||||||
href: url,
|
|
||||||
url: image,
|
|
||||||
caption: description
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
#generateOpengraphEmbedHTML(embed) {
|
|
||||||
return `<actiontext-opengraph-embed class="${this.#isTwitterAvatar(embed) ? UNFURLED_TWITTER_AVATAR_CSS_CLASS : ''}">
|
|
||||||
<div class="og-embed">
|
|
||||||
<div class="og-embed__content">
|
|
||||||
<div class="og-embed__title">${truncateString(embed.title, 560)}</div>
|
|
||||||
<div class="og-embed__description">${truncateString(embed.description, 560)}</div>
|
|
||||||
</div>
|
|
||||||
<div class="og-embed__image">
|
|
||||||
<img src="${embed.image}" class="image" alt="" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</actiontext-opengraph-embed>`
|
|
||||||
}
|
|
||||||
|
|
||||||
#isTwitterAvatar(embed) {
|
|
||||||
return embed.image.startsWith(TWITTER_AVATAR_URL_PREFIX)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
export default class Paste {
|
|
||||||
constructor(range, editor, document) {
|
|
||||||
this.range = range
|
|
||||||
this.editor = editor
|
|
||||||
this.document = document
|
|
||||||
if (this.document == null) { this.document = this.editor.getDocument() }
|
|
||||||
this.string = this.document.getStringAtRange(this.range)
|
|
||||||
}
|
|
||||||
|
|
||||||
isURL() {
|
|
||||||
return /^(?:[a-z0-9]+:\/\/|www\.)[^\s]+$/.test(this.string)
|
|
||||||
}
|
|
||||||
|
|
||||||
getPathname() {
|
|
||||||
const a = document.createElement("a")
|
|
||||||
a.href = this.string
|
|
||||||
return a.pathname
|
|
||||||
}
|
|
||||||
|
|
||||||
isLinked() {
|
|
||||||
const {href} = this.getCommonAttributes()
|
|
||||||
return (href != null) && (href !== this.string)
|
|
||||||
}
|
|
||||||
|
|
||||||
getCommonAttributes() {
|
|
||||||
return this.document.getCommonAttributesAtRange(this.range)
|
|
||||||
}
|
|
||||||
|
|
||||||
getSignificantPaste() {
|
|
||||||
return new this.constructor(this.getSignificantRange(), this.editor, this.document)
|
|
||||||
}
|
|
||||||
|
|
||||||
getSignificantRange() {
|
|
||||||
const significantString = this.string.trim()
|
|
||||||
const startOffset = this.range[0] + this.string.indexOf(significantString)
|
|
||||||
const endOffset = startOffset + significantString.length
|
|
||||||
return [startOffset, endOffset]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
import OpengraphEmbedOperation from "lib/rich_text/unfurl/lib/opengraph_embed_operation"
|
|
||||||
import Paste from "lib/rich_text/unfurl/lib/paste"
|
|
||||||
|
|
||||||
const performOperation = (function() {
|
|
||||||
let operation = null
|
|
||||||
let requestId = null
|
|
||||||
|
|
||||||
return function(operationToPerform) {
|
|
||||||
operation?.abort()
|
|
||||||
cancelAnimationFrame(requestId)
|
|
||||||
|
|
||||||
requestId = requestAnimationFrame(function() {
|
|
||||||
operation = operationToPerform
|
|
||||||
operation.perform().then(() => operation = null)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})()
|
|
||||||
|
|
||||||
export default class Unfurler {
|
|
||||||
install() {
|
|
||||||
this.#addEventListeners()
|
|
||||||
}
|
|
||||||
|
|
||||||
#addEventListeners() {
|
|
||||||
addEventListener("trix-initialize", function(event) {
|
|
||||||
if (this.#editorElementPermitsAttribute(event.target, "href")) {
|
|
||||||
return event.target.addEventListener("trix-paste", this.#didPaste.bind(this))
|
|
||||||
}
|
|
||||||
}.bind(this))
|
|
||||||
}
|
|
||||||
|
|
||||||
#didPaste(event) {
|
|
||||||
const {range} = event.paste
|
|
||||||
const {editor} = event.target
|
|
||||||
|
|
||||||
if (range != null) {
|
|
||||||
const paste = new Paste(range, editor).getSignificantPaste()
|
|
||||||
|
|
||||||
if (paste.isURL()) {
|
|
||||||
if (this.#editorElementPermitsOpengraphAttachment(event.target)) {
|
|
||||||
performOperation(new OpengraphEmbedOperation(paste))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#editorElementPermitsAttribute(element, attributeName) {
|
|
||||||
if (element.hasAttribute("data-permitted-attributes")) {
|
|
||||||
return Array.from(element.getAttribute("data-permitted-attributes").split(" ")).includes(attributeName)
|
|
||||||
} else {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#editorElementPermitsOpengraphAttachment(element) {
|
|
||||||
const permittedAttachmentTypes = element.getAttribute("data-permitted-attachment-types")
|
|
||||||
return permittedAttachmentTypes && permittedAttachmentTypes.includes("application/vnd.actiontext.opengraph-embed")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -18,7 +18,7 @@ export default class ClientMessage {
|
|||||||
body,
|
body,
|
||||||
messageTimestamp: Math.floor(now.getTime()),
|
messageTimestamp: Math.floor(now.getTime()),
|
||||||
messageDatetime: now.toISOString(),
|
messageDatetime: now.toISOString(),
|
||||||
messageClasses: this.#containsOnlyEmoji(node.textContent) ? "message--emoji" : "",
|
messageClasses: this.#messageClassesFromNode(node),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,26 +52,37 @@ export default class ClientMessage {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#messageClassesFromNode(node) {
|
||||||
|
if (this.#containsOnlyEmoji(this.#plainTextFromNode(node))) {
|
||||||
|
return "message--emoji"
|
||||||
|
} else {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
#isPlayCommand(node) {
|
#isPlayCommand(node) {
|
||||||
return this.#matchPlayCommand(node)
|
return this.#matchPlayCommand(node)
|
||||||
}
|
}
|
||||||
|
|
||||||
#matchPlayCommand(node) {
|
#matchPlayCommand(node) {
|
||||||
return this.#stripWrapperElement(node)?.match(new RegExp(`^/play (${SOUND_NAMES.join("|")})`))?.[1]
|
return this.#plainTextFromNode(node)?.match(new RegExp(`^/play (${SOUND_NAMES.join("|")})`))?.[1]
|
||||||
}
|
}
|
||||||
|
|
||||||
#stripWrapperElement(node) {
|
#plainTextFromNode(node) {
|
||||||
return node.innerHTML?.replace(/<div>(?:<!--[\s\S]*?-->)*([\s\S]*?)<\/div>/i, '$1')
|
if (this.#isRichText(node)) {
|
||||||
|
return node.toString().trim()
|
||||||
|
} else {
|
||||||
|
return node
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
#isRichText(node) {
|
#isRichText(node) {
|
||||||
return typeof(node) != "string"
|
return typeof(node) != "string"
|
||||||
}
|
}
|
||||||
|
|
||||||
#richTextContent(node) {
|
#richTextContent(node) {
|
||||||
return `<div class="trix-content">${node.innerHTML}</div>`
|
return `<div class="lexxy-content">${node.value}</div>`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -76,7 +76,21 @@ export default class MessageFormatter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#highlightCodeBlock(block) {
|
#highlightCodeBlock(block) {
|
||||||
if (this.#isPlainText(block)) window.hljs.highlightElement(block)
|
this.#normalizeLineBreaks(block)
|
||||||
|
|
||||||
|
if (this.#isPlainText(block)) {
|
||||||
|
const language = block.dataset.language
|
||||||
|
if (language && window.hljs.getLanguage(language)) {
|
||||||
|
block.classList.add(`language-${language}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
window.hljs.highlightElement(block)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lexxy breaks code block lines with <br>, Trix-era blocks used newlines
|
||||||
|
#normalizeLineBreaks(block) {
|
||||||
|
block.querySelectorAll("br").forEach(br => br.replaceWith("\n"))
|
||||||
}
|
}
|
||||||
|
|
||||||
#isPlainText(element) {
|
#isPlainText(element) {
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
class Message::BroadcastUnreadRoomJob < ApplicationJob
|
||||||
|
def perform(message)
|
||||||
|
message.broadcast_unread_room
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
class Room::DestroyJob < ApplicationJob
|
||||||
|
discard_on ActiveJob::DeserializationError
|
||||||
|
|
||||||
|
def perform(room, former_member_ids)
|
||||||
|
room.reset_remote_connections_of(former_member_ids)
|
||||||
|
room.destroy_one_message_at_a_time
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -9,7 +9,8 @@ class Membership < ApplicationRecord
|
|||||||
enum :involvement, %w[ invisible nothing mentions everything ].index_by(&:itself), prefix: :involved_in
|
enum :involvement, %w[ invisible nothing mentions everything ].index_by(&:itself), prefix: :involved_in
|
||||||
|
|
||||||
scope :with_ordered_room, -> { includes(:room).joins(:room).order("LOWER(rooms.name)") }
|
scope :with_ordered_room, -> { includes(:room).joins(:room).order("LOWER(rooms.name)") }
|
||||||
scope :without_direct_rooms, -> { joins(:room).where.not(room: { type: "Rooms::Direct" }) }
|
scope :with_direct_rooms, -> { includes(:room).joins(:room).merge(Room.directs).order(rooms: { updated_at: :desc }) }
|
||||||
|
scope :without_direct_rooms, -> { joins(:room).merge(Room.without_directs) }
|
||||||
|
|
||||||
scope :visible, -> { where.not(involvement: :invisible) }
|
scope :visible, -> { where.not(involvement: :invisible) }
|
||||||
scope :unread, -> { where.not(unread_at: nil) }
|
scope :unread, -> { where.not(unread_at: nil) }
|
||||||
|
|||||||
@@ -14,20 +14,16 @@ class Message < ApplicationRecord
|
|||||||
scope :ordered, -> { order(:created_at) }
|
scope :ordered, -> { order(:created_at) }
|
||||||
scope :with_creator, -> { preload(creator: :avatar_attachment) }
|
scope :with_creator, -> { preload(creator: :avatar_attachment) }
|
||||||
scope :with_attachment_details, -> {
|
scope :with_attachment_details, -> {
|
||||||
with_rich_text_body_and_embeds
|
with_rich_text_body_and_embeds.with_attached_attachment
|
||||||
with_attached_attachment
|
|
||||||
.includes(attachment_blob: :variant_records)
|
.includes(attachment_blob: :variant_records)
|
||||||
}
|
}
|
||||||
scope :with_boosts, -> { includes(boosts: :booster) }
|
scope :with_boosts, -> { includes(boosts: { booster: :avatar_attachment }) }
|
||||||
|
scope :with_presentation, -> { with_creator.with_attachment_details.with_boosts.preload(:room) }
|
||||||
|
|
||||||
def plain_text_body
|
def plain_text_body
|
||||||
body.to_plain_text.presence || attachment&.filename&.to_s || ""
|
body.to_plain_text.presence || attachment&.filename&.to_s || ""
|
||||||
end
|
end
|
||||||
|
|
||||||
def to_key
|
|
||||||
[ client_message_id ]
|
|
||||||
end
|
|
||||||
|
|
||||||
def content_type
|
def content_type
|
||||||
case
|
case
|
||||||
when attachment? then "attachment"
|
when attachment? then "attachment"
|
||||||
|
|||||||
@@ -4,9 +4,14 @@ module Message::Attachment
|
|||||||
THUMBNAIL_MAX_WIDTH = 1200
|
THUMBNAIL_MAX_WIDTH = 1200
|
||||||
THUMBNAIL_MAX_HEIGHT = 800
|
THUMBNAIL_MAX_HEIGHT = 800
|
||||||
|
|
||||||
|
# Decoding a picture, or a video's frame, costs in proportion to its pixels, however small the file. The largest
|
||||||
|
# phone photos have 200 million.
|
||||||
|
THUMBNAIL_MAX_PIXELS = 250_000_000
|
||||||
|
|
||||||
included do
|
included do
|
||||||
has_one_attached :attachment do |attachable|
|
has_one_attached :attachment do |attachable|
|
||||||
attachable.variant :thumb, resize_to_limit: [ THUMBNAIL_MAX_WIDTH, THUMBNAIL_MAX_HEIGHT ]
|
attachable.variant :thumb, resize_to_limit: [ THUMBNAIL_MAX_WIDTH, THUMBNAIL_MAX_HEIGHT ]
|
||||||
|
attachable.variant :poster, format: :webp, resize_to_limit: [ THUMBNAIL_MAX_WIDTH, THUMBNAIL_MAX_HEIGHT ]
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -30,12 +35,25 @@ module Message::Attachment
|
|||||||
attachment&.analyze
|
attachment&.analyze
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# A file that ffmpeg or libvips can't decode is still the message: post it without a preview.
|
||||||
def process_attachment_thumbnail
|
def process_attachment_thumbnail
|
||||||
|
return if too_many_pixels_to_preview?
|
||||||
|
|
||||||
case
|
case
|
||||||
when attachment.video?
|
when attachment.video?
|
||||||
attachment.preview(format: :webp).processed
|
attachment.preview(:poster).processed
|
||||||
when attachment.representable?
|
when attachment.representable?
|
||||||
attachment.representation(:thumb).processed
|
attachment.representation(:thumb).processed
|
||||||
end
|
end
|
||||||
|
rescue ActiveStorage::PreviewError, Vips::Error => error
|
||||||
|
Rails.logger.warn "Posted #{attachment.filename} without a preview: #{error.class}: #{error.message.lines.first&.chomp}"
|
||||||
|
end
|
||||||
|
|
||||||
|
# Without a size, the analyzer couldn't read the file's header, and the previewer wouldn't either.
|
||||||
|
def too_many_pixels_to_preview?
|
||||||
|
if attachment.image? || attachment.video?
|
||||||
|
width, height = attachment.metadata.values_at(:width, :height)
|
||||||
|
width.nil? || height.nil? || width * height > THUMBNAIL_MAX_PIXELS
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -1,10 +1,29 @@
|
|||||||
module Message::Broadcasts
|
module Message::Broadcasts
|
||||||
def broadcast_create
|
def broadcast_create
|
||||||
broadcast_append_to room, :messages, target: [ room, :messages ]
|
broadcast_append_to room, :messages, target: [ room, :messages ]
|
||||||
ActionCable.server.broadcast("unread_rooms", { roomId: room.id })
|
broadcast_unread_room_later
|
||||||
end
|
end
|
||||||
|
|
||||||
def broadcast_remove
|
def broadcast_remove
|
||||||
broadcast_remove_to room, :messages
|
broadcast_remove_to room, :messages
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Fanned out to the room's members rather than published on one global stream, so
|
||||||
|
# that the timing of activity in a room only reaches people who are in it. Of those,
|
||||||
|
# only the ones who still have it unread or are in it now: by the time this runs,
|
||||||
|
# someone may have opened the room and moved on, and would see it marked unread again.
|
||||||
|
def broadcast_unread_room
|
||||||
|
payload = ActiveSupport::JSON.encode(roomId: room.id, at: created_at.to_f)
|
||||||
|
|
||||||
|
room.memberships.unread.or(room.memberships.connected).pluck(:user_id).each do |user_id|
|
||||||
|
ActionCable.server.broadcast UnreadRoomsChannel.stream_name_for(user_id), payload, coder: nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
# The fanout is one publish per member, which in a big room takes far longer than the
|
||||||
|
# rest of posting a message, so it runs in a job rather than while the poster waits.
|
||||||
|
def broadcast_unread_room_later
|
||||||
|
Message::BroadcastUnreadRoomJob.perform_later(self)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
@@ -3,9 +3,31 @@ module Message::Pagination
|
|||||||
|
|
||||||
PAGE_SIZE = 40
|
PAGE_SIZE = 40
|
||||||
|
|
||||||
|
# Expose Rails' collection-preloading hook while preserving Array pagination and
|
||||||
|
# validators. The renderer passes only cache misses to preload_associations.
|
||||||
|
class Page < Array
|
||||||
|
def self.load(relation, direction, size)
|
||||||
|
new(relation.skip_preloading!.public_send(direction, size), relation)
|
||||||
|
end
|
||||||
|
|
||||||
|
def initialize(records, relation)
|
||||||
|
super(records)
|
||||||
|
@relation = relation
|
||||||
|
end
|
||||||
|
|
||||||
|
def loaded?
|
||||||
|
true
|
||||||
|
end
|
||||||
|
|
||||||
|
def preload_associations(records)
|
||||||
|
@relation.preload_associations(records)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
included do
|
included do
|
||||||
scope :last_page, -> { ordered.last(PAGE_SIZE) }
|
# Keep presentation data lazy until the collection cache knows which messages missed.
|
||||||
scope :first_page, -> { ordered.first(PAGE_SIZE) }
|
scope :last_page, -> { last_page_of(PAGE_SIZE) }
|
||||||
|
scope :first_page, -> { Page.load(ordered, :first, PAGE_SIZE) }
|
||||||
|
|
||||||
scope :before, ->(message) { where("created_at < ?", message.created_at) }
|
scope :before, ->(message) { where("created_at < ?", message.created_at) }
|
||||||
scope :after, ->(message) { where("created_at > ?", message.created_at) }
|
scope :after, ->(message) { where("created_at > ?", message.created_at) }
|
||||||
@@ -14,16 +36,23 @@ module Message::Pagination
|
|||||||
scope :page_after, ->(message) { after(message).first_page }
|
scope :page_after, ->(message) { after(message).first_page }
|
||||||
|
|
||||||
scope :page_created_since, ->(time) { where("created_at > ?", time).first_page }
|
scope :page_created_since, ->(time) { where("created_at > ?", time).first_page }
|
||||||
scope :page_updated_since, ->(time) { where("updated_at > ?", time).last_page }
|
# Sorting on +created_at, which no index covers, has SQLite find the few messages updated since
|
||||||
|
# through (room_id, updated_at) and sort just those, instead of walking the whole room by
|
||||||
|
# (room_id, created_at) looking for them.
|
||||||
|
scope :page_updated_since, ->(time) { Page.load(where("updated_at > ?", time).reorder(Arel.sql("+messages.created_at")), :last, PAGE_SIZE) }
|
||||||
end
|
end
|
||||||
|
|
||||||
class_methods do
|
class_methods do
|
||||||
|
def last_page_of(size)
|
||||||
|
Page.load(ordered, :last, size)
|
||||||
|
end
|
||||||
|
|
||||||
def page_around(message)
|
def page_around(message)
|
||||||
page_before(message) + [ message ] + page_after(message)
|
Page.new(page_before(message) + [ message ] + page_after(message), ordered)
|
||||||
end
|
end
|
||||||
|
|
||||||
def paged?
|
def paged?
|
||||||
count > PAGE_SIZE
|
offset(PAGE_SIZE).exists?
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -3,10 +3,45 @@ module Message::Searchable
|
|||||||
|
|
||||||
included do
|
included do
|
||||||
after_create_commit :create_in_index
|
after_create_commit :create_in_index
|
||||||
after_update_commit :update_in_index
|
before_update -> { @attachment_replaced = attachment_changes.key?("attachment") }
|
||||||
|
after_update_commit :update_in_index, if: :indexed_text_changed?
|
||||||
after_destroy_commit :remove_from_index
|
after_destroy_commit :remove_from_index
|
||||||
|
|
||||||
scope :search, ->(query) { joins("join message_search_index idx on messages.id = idx.rowid").where("idx.body match ?", query).ordered }
|
scope :search, ->(query) { joins("join message_search_index idx on messages.id = idx.rowid").where("idx.body match ?", match_terms(query)).ordered }
|
||||||
|
end
|
||||||
|
|
||||||
|
class_methods do
|
||||||
|
# Quotes each word, so that FTS5 searches for AND, OR, NOT and NEAR rather than
|
||||||
|
# parsing them as operators, which fails on a query like "AND" or "salt AND".
|
||||||
|
def match_terms(query)
|
||||||
|
query.split.map { |word| %("#{word.gsub('"', '""')}") }.join(" ")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
class_methods do
|
||||||
|
def search_reachable(user, query, size: 100)
|
||||||
|
relation = user.reachable_messages.search(query).with_presentation
|
||||||
|
probe = connection.select_rows(sanitize_sql([ <<~SQL, user.id, match_terms(query) ]))
|
||||||
|
SELECT m.id, mm.user_id IS NOT NULL FROM message_search_index idx
|
||||||
|
JOIN messages m ON m.id = idx.rowid
|
||||||
|
LEFT JOIN memberships mm ON mm.room_id = m.room_id AND mm.user_id = ?
|
||||||
|
WHERE idx.body MATCH ? ORDER BY idx.rowid DESC LIMIT 1000
|
||||||
|
SQL
|
||||||
|
ids = probe.filter_map { |id, reachable| id if reachable == 1 }.first(size)
|
||||||
|
|
||||||
|
# A scoped fallback preserves older reachable hits behind a large private history.
|
||||||
|
if ids.size < size && probe.size == 1000
|
||||||
|
relation.last_page_of_matches(size)
|
||||||
|
else
|
||||||
|
Message::Pagination::Page.load(relation.where(id: ids).reorder(:id), :last, size)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Orders by the index's rowid, which is the message id, so SQLite walks the full-text index
|
||||||
|
# newest first and stops at the page. Ordering by created_at sorted every match before paging.
|
||||||
|
def last_page_of_matches(size)
|
||||||
|
Message::Pagination::Page.load(reorder("idx.rowid"), :last, size)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
@@ -14,6 +49,12 @@ module Message::Searchable
|
|||||||
execute_sql_with_binds "insert into message_search_index(rowid, body) values (?, ?)", id, plain_text_body
|
execute_sql_with_binds "insert into message_search_index(rowid, body) values (?, ?)", id, plain_text_body
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Boosts touch their message too: rewrite the index only when the body was saved or the attachment replaced.
|
||||||
|
# Active Storage clears attachment_changes in its own after_commit, before this one runs.
|
||||||
|
def indexed_text_changed?
|
||||||
|
association(:rich_text_body).target&.saved_change_to_body? || @attachment_replaced
|
||||||
|
end
|
||||||
|
|
||||||
def update_in_index
|
def update_in_index
|
||||||
execute_sql_with_binds "update message_search_index set body = ? where rowid = ?", plain_text_body, id
|
execute_sql_with_binds "update message_search_index set body = ? where rowid = ?", plain_text_body, id
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ class Opengraph::Fetch
|
|||||||
ALLOWED_DOCUMENT_CONTENT_TYPE = "text/html"
|
ALLOWED_DOCUMENT_CONTENT_TYPE = "text/html"
|
||||||
MAX_BODY_SIZE = 5.megabytes
|
MAX_BODY_SIZE = 5.megabytes
|
||||||
MAX_REDIRECTS = 10
|
MAX_REDIRECTS = 10
|
||||||
|
TIMEOUT = 7.seconds # to connect (TLS included), and for each read and write, as Webhook does
|
||||||
|
|
||||||
class TooManyRedirectsError < StandardError; end
|
class TooManyRedirectsError < StandardError; end
|
||||||
class RedirectDeniedError < StandardError; end
|
class RedirectDeniedError < StandardError; end
|
||||||
@@ -22,9 +23,14 @@ class Opengraph::Fetch
|
|||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
# The nil proxy address ignores http_proxy: a proxy would look the host up again, and the connection
|
||||||
|
# would no longer go to the checked IP.
|
||||||
|
#
|
||||||
|
# The timeouts bound each operation. A host can still send its headers or body a byte at a time,
|
||||||
|
# in as many reads as it likes: UnfurlLinksController puts the whole unfurl under one deadline.
|
||||||
def request(url, request_class, ip:)
|
def request(url, request_class, ip:)
|
||||||
MAX_REDIRECTS.times do
|
MAX_REDIRECTS.times do
|
||||||
Net::HTTP.start(url.host, url.port, ipaddr: ip, use_ssl: url.scheme == "https") do |http|
|
Net::HTTP.start(url.host, url.port, nil, ipaddr: ip, use_ssl: url.scheme == "https", **timeouts) do |http|
|
||||||
http.request request_class.new(url) do |response|
|
http.request request_class.new(url) do |response|
|
||||||
if response.is_a?(Net::HTTPRedirection)
|
if response.is_a?(Net::HTTPRedirection)
|
||||||
url, ip = resolve_redirect(response["location"])
|
url, ip = resolve_redirect(response["location"])
|
||||||
@@ -38,6 +44,11 @@ class Opengraph::Fetch
|
|||||||
raise TooManyRedirectsError
|
raise TooManyRedirectsError
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Without max_retries: 0, Net::HTTP sends a GET or HEAD that timed out a second time.
|
||||||
|
def timeouts
|
||||||
|
{ open_timeout: TIMEOUT, read_timeout: TIMEOUT, write_timeout: TIMEOUT, max_retries: 0 }
|
||||||
|
end
|
||||||
|
|
||||||
def resolve_redirect(location)
|
def resolve_redirect(location)
|
||||||
url = URI.parse(location)
|
url = URI.parse(location)
|
||||||
raise RedirectDeniedError unless url.is_a?(URI::HTTP)
|
raise RedirectDeniedError unless url.is_a?(URI::HTTP)
|
||||||
|
|||||||
@@ -1,7 +1,61 @@
|
|||||||
|
require "restricted_http/private_network_guard"
|
||||||
|
|
||||||
class Push::Subscription < ApplicationRecord
|
class Push::Subscription < ApplicationRecord
|
||||||
|
PERMITTED_ENDPOINT_HOSTS = %w[
|
||||||
|
jmt17.google.com
|
||||||
|
fcm.googleapis.com
|
||||||
|
updates.push.services.mozilla.com
|
||||||
|
web.push.apple.com
|
||||||
|
notify.windows.com
|
||||||
|
].freeze
|
||||||
|
|
||||||
belongs_to :user
|
belongs_to :user
|
||||||
|
|
||||||
def notification(**params)
|
validates :endpoint, presence: true
|
||||||
WebPush::Notification.new(**params, badge: user.memberships.unread.count, endpoint: endpoint, p256dh_key: p256dh_key, auth_key: auth_key)
|
validate :validate_endpoint_url
|
||||||
|
|
||||||
|
def notification(badge: user.memberships.unread.count, **params)
|
||||||
|
# Defer DNS lookup to the delivery worker to prevent rebinding
|
||||||
|
WebPush::Notification.new(**params, badge: badge, endpoint: endpoint, endpoint_ip_resolver: method(:resolved_endpoint_ip), p256dh_key: p256dh_key, auth_key: auth_key)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Validate at point of use, not just when saved.
|
||||||
|
def resolved_endpoint_ip
|
||||||
|
RestrictedHTTP::PrivateNetworkGuard.resolve(endpoint_uri.host) if permitted_endpoint_uri?
|
||||||
|
rescue RestrictedHTTP::Violation, Surfguard::Unresolvable
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
# Validate endpoint shape. Belt & suspenders.
|
||||||
|
def permitted_endpoint_uri?
|
||||||
|
endpoint_uri&.scheme == "https" && endpoint_uri.port == 443 && permitted_endpoint_host?
|
||||||
|
end
|
||||||
|
|
||||||
|
def endpoint_uri
|
||||||
|
URI.parse(endpoint) if endpoint.present?
|
||||||
|
rescue URI::InvalidURIError
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
|
||||||
|
def validate_endpoint_url
|
||||||
|
if endpoint_uri.nil?
|
||||||
|
errors.add(:endpoint, "is not a valid URL")
|
||||||
|
elsif endpoint_uri.scheme != "https"
|
||||||
|
errors.add(:endpoint, "must use HTTPS")
|
||||||
|
elsif endpoint_uri.port != 443
|
||||||
|
errors.add(:endpoint, "must use the default HTTPS port")
|
||||||
|
elsif !permitted_endpoint_host?
|
||||||
|
errors.add(:endpoint, "is not a permitted push service")
|
||||||
|
elsif resolved_endpoint_ip.nil?
|
||||||
|
errors.add(:endpoint, "resolves to a private or invalid IP address")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def permitted_endpoint_host?
|
||||||
|
host = endpoint_uri&.host&.downcase
|
||||||
|
host.present? && PERMITTED_ENDPOINT_HOSTS.any? do |permitted|
|
||||||
|
host == permitted || host.end_with?(".#{permitted}")
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
@@ -22,6 +22,8 @@ class Room < ApplicationRecord
|
|||||||
|
|
||||||
belongs_to :creator, class_name: "User", default: -> { Current.user }
|
belongs_to :creator, class_name: "User", default: -> { Current.user }
|
||||||
|
|
||||||
|
validate :direct_rooms_keep_their_type, on: :update
|
||||||
|
|
||||||
scope :opens, -> { where(type: "Rooms::Open") }
|
scope :opens, -> { where(type: "Rooms::Open") }
|
||||||
scope :closeds, -> { where(type: "Rooms::Closed") }
|
scope :closeds, -> { where(type: "Rooms::Closed") }
|
||||||
scope :directs, -> { where(type: "Rooms::Direct") }
|
scope :directs, -> { where(type: "Rooms::Direct") }
|
||||||
@@ -43,6 +45,34 @@ class Room < ApplicationRecord
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Takes the room away from its members at once, and leaves its messages to Room::DestroyJob. Destroying them in
|
||||||
|
# the room's own transaction held the database's write lock, and stopped every other write, until the last one.
|
||||||
|
# An open room is closed first, so that no one who joins the account before the job runs is let in.
|
||||||
|
def destroy_later
|
||||||
|
room = open? ? becomes!(Rooms::Closed) : self
|
||||||
|
|
||||||
|
former_member_ids = transaction do
|
||||||
|
room.save!
|
||||||
|
room.memberships.pluck(:user_id).tap { room.memberships.delete_all }
|
||||||
|
end
|
||||||
|
|
||||||
|
Room::DestroyJob.perform_later(room, former_member_ids)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Deleting the memberships skips the reset that revoking one does, so the former members get it here, all at once,
|
||||||
|
# before the messages go: their connections stop receiving the room's streams. It's done in the job, not the
|
||||||
|
# request, because it costs a Redis round trip per member.
|
||||||
|
def reset_remote_connections_of(user_ids)
|
||||||
|
User.where(id: user_ids).find_each(&:reset_remote_connections)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Each message is destroyed in its own transaction, so other writes get through in between. Any message posted
|
||||||
|
# meanwhile goes with the room.
|
||||||
|
def destroy_one_message_at_a_time
|
||||||
|
messages.find_each(&:destroy)
|
||||||
|
destroy
|
||||||
|
end
|
||||||
|
|
||||||
def receive(message)
|
def receive(message)
|
||||||
unread_memberships(message)
|
unread_memberships(message)
|
||||||
push_later(message)
|
push_later(message)
|
||||||
@@ -65,6 +95,15 @@ class Room < ApplicationRecord
|
|||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
# Open and closed rooms convert into each other freely. A direct room can't become
|
||||||
|
# either: its participants agreed to a private conversation, not to one whose
|
||||||
|
# audience someone else gets to widen afterwards.
|
||||||
|
def direct_rooms_keep_their_type
|
||||||
|
if type_changed? && type_was == "Rooms::Direct"
|
||||||
|
errors.add :type, "can't be changed for a direct room"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
def unread_memberships(message)
|
def unread_memberships(message)
|
||||||
memberships.visible.disconnected.where.not(user: message.creator).update_all(unread_at: message.created_at, updated_at: Time.current)
|
memberships.visible.disconnected.where.not(user: message.creator).update_all(unread_at: message.created_at, updated_at: Time.current)
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -53,9 +53,12 @@ class Room::MessagePusher
|
|||||||
relevant_subscriptions.merge(Membership.involved_in_mentions).where(user_id: mentionees.ids)
|
relevant_subscriptions.merge(Membership.involved_in_mentions).where(user_id: mentionees.ids)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Banning keeps the user's subscriptions, so that unbanning brings their notifications back,
|
||||||
|
# but nobody who can't sign in should be sent what's said in their rooms meanwhile.
|
||||||
def relevant_subscriptions
|
def relevant_subscriptions
|
||||||
Push::Subscription
|
Push::Subscription
|
||||||
.joins(user: :memberships)
|
.joins(user: :memberships)
|
||||||
|
.merge(User.active)
|
||||||
.merge(Membership.visible.disconnected.where(room: room).where.not(user: message.creator))
|
.merge(Membership.visible.disconnected.where(room: room).where.not(user: message.creator))
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -7,12 +7,15 @@ class Rooms::Direct < Room
|
|||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
# FIXME: Find a more performant algorithm that won't be a problem on accounts with 10K+ direct rooms,
|
# Among the first user's rooms, the one whose members are exactly these users: as many
|
||||||
# which could be to store the membership id list as a hash on the room, and use that for lookup.
|
# memberships as users, and all of them theirs.
|
||||||
def find_for(users)
|
def find_for(users)
|
||||||
all.joins(:users).detect do |room|
|
user_ids = users.pluck(:id).uniq
|
||||||
Set.new(room.user_ids) == Set.new(users.pluck(:id))
|
|
||||||
end
|
where(id: Membership.where(user_id: user_ids.first).select(:room_id))
|
||||||
|
.joins(:memberships).group(:id)
|
||||||
|
.having("COUNT(*) = :size AND COUNT(CASE WHEN memberships.user_id IN (:user_ids) THEN 1 END) = :size", size: user_ids.size, user_ids: user_ids)
|
||||||
|
.first
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
+38
-1
@@ -18,8 +18,10 @@ class User < ApplicationRecord
|
|||||||
enum :status, %i[ active deactivated banned ], default: :active
|
enum :status, %i[ active deactivated banned ], default: :active
|
||||||
|
|
||||||
has_secure_password validations: false
|
has_secure_password validations: false
|
||||||
|
validate :password_challenge_matches, if: :will_save_change_to_password_digest?, unless: -> { password_challenge.nil? }
|
||||||
|
|
||||||
after_create_commit :grant_membership_to_open_rooms
|
after_create_commit :grant_membership_to_open_rooms
|
||||||
|
after_update :sign_out_all_sessions, if: :saved_change_to_password_digest?
|
||||||
|
|
||||||
scope :ordered, -> { order("LOWER(name)") }
|
scope :ordered, -> { order("LOWER(name)") }
|
||||||
scope :filtered_by, ->(query) { where("name like ?", "%#{query}%") }
|
scope :filtered_by, ->(query) { where("name like ?", "%#{query}%") }
|
||||||
@@ -50,8 +52,43 @@ class User < ApplicationRecord
|
|||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
# The profile always sets password_challenge, and it's checked whenever the save would change the password; scripts
|
||||||
|
# that reset one don't set it.
|
||||||
|
def password_challenge_matches
|
||||||
|
unless password_digest_was.present? && BCrypt::Password.new(password_digest_was).is_password?(password_challenge)
|
||||||
|
errors.add :password_challenge, :invalid
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# A new password is how someone shuts out whoever else got into their account, so it ends every session, copies
|
||||||
|
# of the changing browser's cookie included, and every push subscription; browsers still signed in post theirs
|
||||||
|
# again. Connections close once that commits, registered on the transaction because a later save in it would
|
||||||
|
# replace the saved change an after_commit condition reads.
|
||||||
|
def sign_out_all_sessions
|
||||||
|
sessions.delete_all
|
||||||
|
push_subscriptions.delete_all
|
||||||
|
self.class.current_transaction.after_commit { disconnect_signed_out_sessions }
|
||||||
|
end
|
||||||
|
|
||||||
|
# The password is saved by now, so a realtime service that can't be reached mustn't fail the change.
|
||||||
|
def disconnect_signed_out_sessions
|
||||||
|
reset_remote_connections
|
||||||
|
rescue => error
|
||||||
|
Rails.logger.warn "Could not disconnect remote connections after a password change: #{error.class}"
|
||||||
|
end
|
||||||
|
|
||||||
|
# One statement, which SQLite runs under the write lock, so no room is closed between reading the open ones and
|
||||||
|
# inserting. A room that Room#destroy_later closes is either closed before, and skipped, or after, and takes this
|
||||||
|
# membership away with the others. A room that granted itself to every user when it was created is skipped too,
|
||||||
|
# as insert_all did.
|
||||||
def grant_membership_to_open_rooms
|
def grant_membership_to_open_rooms
|
||||||
Membership.insert_all(Rooms::Open.pluck(:id).collect { |room_id| { room_id: room_id, user_id: id } })
|
now = Time.current
|
||||||
|
|
||||||
|
Membership.connection.execute Membership.sanitize_sql([ <<~SQL, id, now, now ])
|
||||||
|
insert into memberships (room_id, user_id, created_at, updated_at)
|
||||||
|
select id, ?, ?, ? from rooms where type = 'Rooms::Open'
|
||||||
|
on conflict do nothing
|
||||||
|
SQL
|
||||||
end
|
end
|
||||||
|
|
||||||
def deactived_email_address
|
def deactived_email_address
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
module User::Bannable
|
module User::Bannable
|
||||||
extend ActiveSupport::Concern
|
extend ActiveSupport::Concern
|
||||||
|
|
||||||
|
included do
|
||||||
|
# Administrators still see banned users, so they can lift the ban.
|
||||||
|
scope :visible_to, ->(user) { user.can_administer? ? where(status: %i[ active banned ]) : active }
|
||||||
|
end
|
||||||
|
|
||||||
def ban
|
def ban
|
||||||
transaction do
|
transaction do
|
||||||
create_bans_from_sessions
|
create_bans_from_sessions
|
||||||
|
|||||||
@@ -1,11 +1,18 @@
|
|||||||
module User::Mentionable
|
module User::Mentionable
|
||||||
include ActionText::Attachable
|
include ActionText::Attachable
|
||||||
|
|
||||||
|
MENTION_CONTENT_TYPE = "application/vnd.campfire.mention"
|
||||||
|
|
||||||
|
def attachable_content_type
|
||||||
|
MENTION_CONTENT_TYPE
|
||||||
|
end
|
||||||
|
|
||||||
def to_attachable_partial_path
|
def to_attachable_partial_path
|
||||||
"users/mention"
|
"users/mention"
|
||||||
end
|
end
|
||||||
|
|
||||||
def to_trix_content_attachment_partial_path
|
# How a mention appears inside the editor, matching the prompt's editor template.
|
||||||
|
def to_editor_content_attachment_partial_path
|
||||||
"users/mention"
|
"users/mention"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -3,13 +3,20 @@ module User::Transferable
|
|||||||
|
|
||||||
TRANSFER_LINK_EXPIRY_DURATION = 4.hours
|
TRANSFER_LINK_EXPIRY_DURATION = 4.hours
|
||||||
|
|
||||||
|
included do
|
||||||
|
# Bound to the password, so changing it also ends sign-in links made before.
|
||||||
|
generates_token_for :transfer, expires_in: TRANSFER_LINK_EXPIRY_DURATION do
|
||||||
|
password_salt&.last(10)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
class_methods do
|
class_methods do
|
||||||
def find_by_transfer_id(id)
|
def find_by_transfer_id(id)
|
||||||
find_signed(id, purpose: :transfer)
|
find_by_token_for(:transfer, id)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def transfer_id
|
def transfer_id
|
||||||
signed_id(purpose: :transfer, expires_in: TRANSFER_LINK_EXPIRY_DURATION)
|
generate_token_for(:transfer)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -63,7 +63,7 @@ class Webhook < ApplicationRecord
|
|||||||
end
|
end
|
||||||
|
|
||||||
def extract_attachment_from(response)
|
def extract_attachment_from(response)
|
||||||
if response.content_type && mime_type = Mime::Type.lookup(response.content_type)
|
if response.code == "200" && response.content_type && mime_type = Mime::Type.lookup(response.content_type)
|
||||||
ActiveStorage::Blob.create_and_upload! \
|
ActiveStorage::Blob.create_and_upload! \
|
||||||
io: StringIO.new(response.body), filename: "attachment.#{mime_type.symbol}", content_type: mime_type.to_s
|
io: StringIO.new(response.body), filename: "attachment.#{mime_type.symbol}", content_type: mime_type.to_s
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -76,10 +76,9 @@
|
|||||||
<% end %>
|
<% end %>
|
||||||
|
|
||||||
<label class="switch">
|
<label class="switch">
|
||||||
<input type="checkbox"
|
<%= tag.input type: "checkbox", class: "switch__input",
|
||||||
class="switch__input"
|
checked: Current.account.settings.restrict_room_creation_to_administrators?,
|
||||||
<%= "checked" if Current.account.settings.restrict_room_creation_to_administrators? %>
|
data: { action: "change->form#submit" } %>
|
||||||
data-action="change->form#submit">
|
|
||||||
<span class="switch__btn round"></span>
|
<span class="switch__btn round"></span>
|
||||||
<span class="for-screen-reader">
|
<span class="for-screen-reader">
|
||||||
Must be admin to create new rooms
|
Must be admin to create new rooms
|
||||||
@@ -102,11 +101,11 @@
|
|||||||
<turbo-frame id="account_users">
|
<turbo-frame id="account_users">
|
||||||
<%= render partial: "accounts/users/user", collection: @administrators, as: :user %>
|
<%= render partial: "accounts/users/user", collection: @administrators, as: :user %>
|
||||||
|
|
||||||
<% if @administrators.any? && @members.any? %>
|
<% if @administrators.any? && @page.records.any? %>
|
||||||
<hr class="separator full-width" style="--border-style: solid">
|
<hr class="separator full-width" style="--border-style: solid">
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
||||||
<%= render partial: "accounts/users/user", collection: @members, as: :user %>
|
<%= render partial: "accounts/users/user", collection: @page.records, as: :user %>
|
||||||
<%= render "accounts/users/next_page_container", page: @page.next_param unless @page.last? %>
|
<%= render "accounts/users/next_page_container", page: @page.next_param unless @page.last? %>
|
||||||
</turbo-frame>
|
</turbo-frame>
|
||||||
</menu>
|
</menu>
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
<figure class="attachment attachment--content attachment--og">
|
<figure class="attachment attachment--content attachment--og">
|
||||||
<actiontext-opengraph-embed>
|
<actiontext-opengraph-embed>
|
||||||
<div class="og-embed gap">
|
<div class="og-embed gap <%= "og-embed--twitter-avatar" if opengraph_embed.twitter_avatar? %>">
|
||||||
<div class="og-embed__content">
|
<div class="og-embed__content">
|
||||||
<div class="og-embed__title">
|
<div class="og-embed__title">
|
||||||
<%= link_to truncate(opengraph_embed.filename, length: 280, omission: "…"), opengraph_embed.href, rel: "noreferrer", target: "_blank" %>
|
<%= link_to_if opengraph_embed.href.present?, truncate(opengraph_embed.filename, length: 280, omission: "…"), opengraph_embed.href, rel: "noreferrer", target: "_blank" %>
|
||||||
</div>
|
</div>
|
||||||
<div class="og-embed__description"><%= truncate(opengraph_embed.caption, length: 560, omission: "…").html_safe %></div>
|
<div class="og-embed__description"><%= truncate(opengraph_embed.description.to_s, length: 560, omission: "…") %></div>
|
||||||
</div>
|
</div>
|
||||||
<% if opengraph_embed.url %>
|
<% if opengraph_embed.url %>
|
||||||
<div class="og-embed__image">
|
<div class="og-embed__image">
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<%# Files are posted as a message's own attachment, which gets its preview when it's posted; the composer adds none to
|
||||||
|
the rich text. One that's there anyway is shown by its name, with no preview: Action Text's partial links to a URL
|
||||||
|
that makes one on view, and nothing else in the app would make it. %>
|
||||||
|
<figure class="attachment attachment--file attachment--<%= blob.filename.extension %>">
|
||||||
|
<figcaption class="attachment__caption">
|
||||||
|
<% if caption = blob.try(:caption) %>
|
||||||
|
<%= caption %>
|
||||||
|
<% else %>
|
||||||
|
<span class="attachment__name"><%= blob.filename %></span>
|
||||||
|
<span class="attachment__size"><%= number_to_human_size blob.byte_size %></span>
|
||||||
|
<% end %>
|
||||||
|
</figcaption>
|
||||||
|
</figure>
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<lexxy-prompt-item search="<%= user.name %>" sgid="<%= user.attachable_sgid %>">
|
||||||
|
<template type="menu">
|
||||||
|
<span class="autocomplete__item flex align-center gap unpad">
|
||||||
|
<%= avatar_tag user %>
|
||||||
|
<span class="autocompletable__name"><%= user.name %></span>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
<template type="editor">
|
||||||
|
<%= render "users/mention", user: user %>
|
||||||
|
</template>
|
||||||
|
</lexxy-prompt-item>
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
<%= render partial: "autocompletable/users/prompt_item", collection: @page.records, as: :user %>
|
||||||
@@ -1,3 +1,3 @@
|
|||||||
<div class="trix-content">
|
<div class="lexxy-content">
|
||||||
<%= yield -%>
|
<%= yield -%>
|
||||||
</div>
|
</div>
|
||||||
@@ -40,9 +40,9 @@
|
|||||||
<div class="flash" data-controller="element-removal" data-action="animationend->element-removal#remove">
|
<div class="flash" data-controller="element-removal" data-action="animationend->element-removal#remove">
|
||||||
<div class="flash__inner shadow" style="<%= "--flash-background: var(--color-negative)" if flash[:alert] %>">
|
<div class="flash__inner shadow" style="<%= "--flash-background: var(--color-negative)" if flash[:alert] %>">
|
||||||
<% if flash[:alert] %>
|
<% if flash[:alert] %>
|
||||||
<%= image_tag "alert.svg", aria: { hidden: true }, size: 24, class: "colorize--white" %></span>
|
<%= image_tag "alert.svg", aria: { hidden: true }, size: 24, class: "colorize--white" %>
|
||||||
<% else %>
|
<% else %>
|
||||||
<%= image_tag "check.svg", aria: { hidden: true }, size: 24, class: "colorize--white" %></span>
|
<%= image_tag "check.svg", aria: { hidden: true }, size: 24, class: "colorize--white" %>
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
<span class="for-screen-reader" role="alert" aria-atomic="true"><%= notice %></span>
|
<span class="for-screen-reader" role="alert" aria-atomic="true"><%= notice %></span>
|
||||||
@@ -63,6 +63,11 @@
|
|||||||
|
|
||||||
<%= render "layouts/lightbox" %>
|
<%= render "layouts/lightbox" %>
|
||||||
|
|
||||||
|
<% if Current.session %>
|
||||||
|
<%= tag.div hidden: true, data: { controller: "push-subscription-sync",
|
||||||
|
push_subscription_sync_url_value: user_push_subscriptions_path, push_subscription_sync_session_value: push_subscription_session_marker } %>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
<a href="https://once.com" id="app-logo" target="_blank" aria-label="Once software from 37signals home page">
|
<a href="https://once.com" id="app-logo" target="_blank" aria-label="Once software from 37signals home page">
|
||||||
<%= image_tag "campfire-icon.png", alt: "Campfire logo", width: 256, height: 216 %>
|
<%= image_tag "campfire-icon.png", alt: "Campfire logo", width: 256, height: 216 %>
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
Loaded 100 of 220 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user