Previously, admins could only add or delete custom emojis one at a time
through the admin UI, with no way to migrate emoji sets between sites or
manage them in bulk.
This adds a Select to export flow on the emoji list page that packages
selected emojis (images + a CSV manifest) into a downloadable ZIP, and
an Import page that accepts a ZIP, previews the result with per-emoji
conflict resolution (keep existing / use incoming), and applies the
import in a single confirmed transaction.
The generated CSV manifest contains a list of the exported emoji names,
groups, and filenames. The import process relies on this manifest, and
only imports emojis listed on it. Should there be conflicting between
identical names, non matching groups, etc. the admin will be able to see
conflicts on a preview confirmation page.
<img width="1462" height="899" alt="image"
src="https://github.com/user-attachments/assets/b6e45b8b-0081-41bb-88c5-a5525287827e"
/>
<img width="1461" height="852" alt="image"
src="https://github.com/user-attachments/assets/feb9a791-eb70-4c1e-b2f7-495884282aa3"
/>
We want to allow site admins to enable or disable the GIFs component, so
we will retire this setting at `stable` and remove the upcoming change
metadata. This setting will now be visible within the GIFs panel under
the Appearance section in the admin sidebar.
Intentionally keeping the migration rake task
(`lib/tasks/migrate_discourse_gifs_to_core.rake`) for a few more weeks
to account for any self-hosters who may not have migrated yet. We will
follow up in a few weeks to remove it entirely.
**Previously**, login with email codes was gated behind a hidden site
setting, invisible to admins and checked via raw `SiteSetting` reads
that would ignore upcoming-change auto-promotion.
**In this update**, registered the setting as an alpha upcoming change
(with preview image and learn-more link), moved server-side checks to
`UpcomingChanges`, and hid the change on sites where it cannot be
enabled.
Update the GIFs picker to show Klipy logo to meet branding requirements
for using the service.
We conditionally showed this in the original theme component but it was
missed when porting the component to core.
- Add new `discourse/app/workers/...` directory
- Adds a custom rolldown plugin which can create a dynamic entrypoint
and return a digested URL for it
- Update media-optimization service to use this new rolldown interface,
and launch the worker via a `blob:`
- Updates media-optimization to use type:module worker, and improve
boot-error-handling strategy
This is an improvement for a few reasons:
1. We can drop all the manual entrypoint/manifest/ruby config which was
used to obtain the media-optimization bundle URL
2. We don't need to serve the worker from the (undigested/uncached)
`public/` directory on the forum domain. Instead we just generate a
one-liner worker entrypoint and create a blob from it
3. Since we're launching from a blob, the worker inherits the CSP of the
host document, which is a nice defense-in-depth improvement
Moves the `experimental_new_new_view_groups` setting to an
upcoming change called "Enable unified new" (`enable_unified_new`)
This will aim to introduce the unified new view topic list to
all sites as the permanent default experience.
This replaces the old ember-cli build with a modern Rolldown build. In
local testing, this provides an 80% improvement in build times, while
remaining 100% backwards compatible for themes and plugins.
As part of this move, we have decided to stop using a proxy in front of
Discourse for development. Development should now be done directly
against the Rails server.
`bin/ember-cli -u` has been replaced with `bin/dev`. This will launch
Rails on `:3000`, and will run the rolldown build in the background. Log
output from both processes will be shown with an appropriate prefix. You
should visit `:3000` in your browser. `:4200` will no longer serve
anything.
To help with migration, `bin/ember-cli` is now a backwards-compatible
shim. It will print help information, and will launch a lightweight
server on `:4200` with instructions to move to `:3000`.
If you prefer to launch Rails and the JS build as separate commands, you
can still do that. Rails boot commands are unchanged, and the rolldown
development builder can be run using `bin/dev --only ember`.
https://meta.discourse.org/t/403908
---------
Co-authored-by: Jarek Radosz <jarek@cvx.dev>
Co-authored-by: Chris Manson <chris@manson.ie>
## Summary
- Extends client-side image optimization to convert **JXL → JPEG**,
**HEIC → JPEG**, and **animated GIF → animated WEBP** using jSquash WASM
packages before upload
- Transparent JXL/HEIC images are converted to **WEBP** instead of JPEG,
so transparency is preserved (WEBP compresses much better than PNG for
this case)
- Gated as an **upcoming change** via
`composer_media_optimization_image_convert_enabled` (experimental,
`feature,all_members`, opt-in per group through the upcoming changes
admin UI)
- Sends raw file bytes to Web Worker for formats browsers can't decode
natively (JXL, HEIC), with new `"convert"` and `"convertAnimated"`
worker message types
- Adds JXL to `authorized_extensions` and `supported_images`; adds
HEIC/HEIF to `supported_images` for consistency
- Skips GIF→WEBP when output is larger than input
- Falls back to filename when MIME type is missing (browsers may not
recognize JXL/HEIC)
### Note on jSquash packages
This depends on Discourse-scoped forks of the jSquash packages
(`@discourse/jxl`, `@discourse/heic`, `@discourse/webp`,
`@discourse/gif`, `@discourse/jpeg`, `@discourse/resize`). The following
upstream PRs would let us move back to the canonical `@jsquash/*`
packages, but the upstream maintainer is currently unresponsive:
- https://github.com/jamsinclair/jSquash/pull/101 (`@jsquash/heic`)
- https://github.com/jamsinclair/jSquash/pull/103 (`@jsquash/webp`
animated support)
- https://github.com/jamsinclair/jSquash/pull/104 (`@jsquash/gif`)
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Previously, building the web push "Reply" action payload raised because
`inline_reply.png` lived under `public/images/` where Propshaft can't
resolve it, so `Jobs::Chat::NotifyMentioned` retried after
`create_notification!` and inflated the bell with duplicate rows.
This change moves the icon into `app/assets/images/push-notifications/`
and wraps the action builder in a `rescue` so any future payload failure
logs and returns `{}` instead of stranding the job.
A post-deploy migration sweeps the duplicate `chat_mention`
`Notification` rows (and their `chat_mention_notifications` join
entries) created during the deploy window — keeping the oldest row per
`(user_id, data)` group and merging the read state forward.
## Summary
When Discourse is loaded in full app embed mode with `dynamicHeight:
true`, the iframe was not resizing in production despite
`discourse-resize` messages arriving correctly.
The full app path sets `iframe.style.height` as an inline style for the
initial height. Inline styles always win over the `height` HTML
attribute, so our resize handler — which set `iframe.height = ...` — had
no visible effect.
This changes the resize handler to set `iframe.style.height` so it
actually overrides the inline style. This also works for the standard
(non-full-app) embed path since no inline style is set there initially.
## Test plan
- [ ] Full app embed with `dynamicHeight: true` — verify iframe height
updates as content loads
- [ ] Standard (non-full-app) embed — verify iframe still resizes
correctly
- [ ] CSS `max-height` on the iframe still wins (inline `height` doesn't
override `max-height`)
## Summary
Replaces the `IntersectionObserver` implementation from #39299 with the
native `loading="lazy"` attribute on the iframe. All supported browsers
(Chrome 77+, Safari 16.4+, Firefox 121+) handle this, and the
browser-determined threshold adapts to connection speed — generally
smarter than a fixed pixel margin.
- Drops ~20 lines of JS and the feature-detection fallback
- Removes the `lazyLoadMargin` option (no longer configurable — browser
decides)
- `lazyLoad: false` still opts out by setting `loading="eager"`
- Admin embedding snippet updated to drop the `lazyLoadMargin` line
Shipped two days ago in #39299, so no external consumers should depend
on `lazyLoadMargin` yet.
## Test plan
- [ ] Full app embed below the fold — verify iframe defers loading until
scrolling near it
- [ ] Full app embed above the fold — verify it loads immediately
- [ ] `lazyLoad: false` — verify immediate loading, same as pre-#39299
behavior
- [ ] Standard (non-full-app) embed — verify unchanged behavior
## Summary
- Add opt-in `dynamicHeight` option for full app embeds that resizes the
iframe based on content
- Add `embedMinHeight` / `embedMaxHeight` options to clamp the dynamic
sizing
- Inside the embedded app, a `ResizeObserver` posts `discourse-resize`
messages to the parent window
- Scrolling stays enabled inside the iframe — Discourse's virtual DOM
only renders ~20 posts at a time and relies on internal scroll position
- Show the new options (commented out) in the admin embedding code
snippet
## Usage
```html
<script type="text/javascript">
window.DiscourseEmbed = {
discourseUrl: 'https://forum.example.com/',
topicId: 123,
fullApp: true,
dynamicHeight: true, // opt-in to iframe resizing
embedMinHeight: '400', // optional, pixels
embedMaxHeight: '900', // optional, pixels
embedHeight: '600px', // initial height before first resize
};
</script>
```
## How it works
- **Default (no `dynamicHeight`)**: Full app embed uses fixed height
(`embedHeight` or `600px`) with internal scrolling. No change from
current behavior.
- **With `dynamicHeight: true`**: The iframe resizes to match content
height, clamped by `embedMinHeight` / `embedMaxHeight` if set. Short
topics shrink the iframe (no wasted whitespace), long topics grow to max
and scroll internally.
## Test plan
- [ ] Full app embed without `dynamicHeight` — verify unchanged behavior
(fixed height, scrolling)
- [ ] Full app embed with `dynamicHeight: true` — verify iframe resizes
as content loads
- [ ] Verify `embedMinHeight` / `embedMaxHeight` clamp the height
correctly
- [ ] Verify standard (non-full-app) embed still resizes as before
- [ ] Open the composer in embed mode — verify height updates propagate
- [ ] Load a long topic — verify virtual DOM post loading works with
scrolling
- [ ] Admin > Embedding page shows the new options commented out in the
code snippet
## Summary
- Defer loading the Discourse iframe for full app embeds until the user
scrolls near it
- Feature detect `IntersectionObserver` — fall back to immediate loading
if unavailable
- Default margin of 1000px to give the heavy PWA time to load before the
user reaches it
- On by default for `fullApp` embeds, opt out with `lazyLoad: false`
- Configurable margin via `lazyLoadMargin`
- Show options in the admin embedding code snippet
## How it works
The iframe element is appended to the DOM immediately (preserving layout
space with `embedHeight`), but its `src` is not set until the
`IntersectionObserver` fires. The default 1000px margin gives Discourse
a head start on loading before the user actually scrolls to the embed.
```js
DiscourseEmbed = {
discourseUrl: 'https://forum.example.com/',
topicId: 123,
fullApp: true,
// lazyLoad: false, // disable lazy loading of the iframe
// lazyLoadMargin: '1000', // pixels before viewport to start loading
};
```
## Test plan
- [ ] Full app embed below the fold — verify iframe doesn't load until
scrolling near it
- [ ] Full app embed above the fold — verify it loads immediately
(IntersectionObserver fires right away)
- [ ] `lazyLoad: false` — verify immediate loading, same as current
behavior
- [ ] Custom `lazyLoadMargin` — verify the margin is respected
- [ ] Standard (non-full-app) embed — verify unchanged behavior (always
loads immediately)
- [ ] Browser without IntersectionObserver — verify falls back to
immediate loading
The `can_permanently_delete` site setting was hidden and could only be
enabled via the Rails console. There was no indication in the admin UI
that this capability existed, leading to confusion and wasted time for
admins trying to permanently delete content.
This commit unhides the setting and adds layered safeguards at every
level of the permanent deletion flow:
**Site setting visibility:**
- Remove `hidden: true` so the setting appears in the admin UI
- Add a `requires_confirmation` dialog when enabling (not when
disabling) via a new `simple_on_enable` confirmation type
- Add a proper setting description since it was missing
**Type-to-confirm on all permanent delete actions:**
- Replace the weak yes/no dialogs on post and revision permanent
deletion with a type-to-confirm pattern (type "permanently delete")
- Show context-aware titles and messages (post vs topic, with post count
for topics)
- Reusable `PermanentlyDeleteConfirm` dialog body component following
the `SecondFactorConfirmPhrase` pattern
**Server-side pre-check endpoint:**
- Add `GET /posts/:id/permanently_delete_check` (admin-only via
`AdminConstraint`) that uses the guardian to validate whether permanent
deletion is allowed before showing the confirmation dialog
- Returns the reason when denied (cooldown timer, undeleted posts) so
the admin gets immediate feedback instead of going through the full
confirmation flow only to be rejected
- Returns accurate `post_count` for topic deletion messages
**Refactors:**
- Extract `Topic#deletable_posts_count` to share the post counting query
between the guardian, `cannot_permanently_delete_reason`, and the new
endpoint
- Use exclusion (`NOT small_action`) instead of inclusion for post type
filtering, so plugin-added post types are counted correctly
Ref - t/181345
---------
Co-authored-by: Martin Brennan <martin@discourse.org>
Adds a redesigned FormKit checkbox behind the enable_new_checkbox_style
upcoming change flag. The native checkbox is visually hidden but remains
the functional element — the new checkmark span is purely
presentational, allowing for styling and animations.
https://github.com/user-attachments/assets/86986103-3832-4bde-917e-00eafe486b34
t/178553
This commit introduces a new build system for plugins, which shares a
large amount of code with the recently-modernized theme compiler.
Plugins are compiled to native ES Modules, and loaded using native
`import()` in the browser, just like themes.
To achieve inter-plugin imports, each bundled plugin entrypoint
implements a custom 'module federation' interface. Each export from
internal plugin modules is made available as a specially-named export on
the entrypoint. When modules in another plugin's namespace are imported,
they are automatically rewritten to use these federated entrypoints.
This change should be almost 100% backwards-compatible. There are some
edge cases which will behave differently, since modules are now eagerly
evaluated according to the ESM spec, instead of being lazily required
via asynchronous-module-definitions (AMD).
The native ESM format should also provide a performance improvement. The
old AMD system involved lots of nested function calls when booting the
app. Now, all the source modules are bundled up into a single ESM bundle
with minimal stack depth.
The build system implements a filesystem-based cache. That means that if
the plugin javascript files are unchanged, they will not need to be
recompiled, even after restarting the Rails server. This mimics the
behaviour of the theme system. Similarly, in production builds, existing
files will be automatically reused if they exist. In future, we plan to
include pre-built copies of common plugins in our prebuild-asset
bundles.
Initially, this new compiler is disabled by default. To test it, we can
set the `ROLLUP_PLUGIN_COMPILER=1` environment variable. We'll continue
to test, improve and document the system before enabling it by default.
---------
Co-authored-by: Jarek Radosz <jradosz@gmail.com>
Co-authored-by: Chris Manson <chris@manson.ie>
Allow staff to pin important messages in chat channels. Pinned messages
appear in a dedicated panel accessible from the channel navbar. Members
of allowed groups (admins/moderators by default) can pin and unpin
messages via the message actions menu.
Pinned messages show "pinned by" attribution, track unread state per
user, and auto-unpin when the original message is trashed. Real-time
updates via MessageBus keep the pinned list in sync.
Gated behind the chat_pinned_messages upcoming change (experimental).
## Summary
Adds a new `fullApp` option to the embed snippet that renders the
complete Discourse Ember application in the iframe instead of the
simplified Rails-templated view. This allows embedded comments to have
the full Discourse experience including likes, reactions, and inline
replies.
When `fullApp: true` is set:
- `/embed/comments` redirects to the topic URL with `?embed_mode=true`
- Header, sidebar, and footer are hidden
- All links open in new tabs (to avoid navigating away within the
iframe)
- Iframe is scrollable with configurable height (default 600px)
Feature is gated on a hidden site setting, default disabled, named `embed_full_app`
## Demo
https://discourse-full-embed.pages.dev/embed-test
## Usage
```js
DiscourseEmbed = {
discourseUrl: 'https://forum.example.com/',
discourseEmbedUrl: 'EMBED_URL',
fullApp: true,
// embedHeight: '800px', // optional, defaults to 600px
};
```
## Security
The `embed_mode` parameter only removes the X-Frame-Options header if:
- `embed_any_origin` site setting is enabled, OR
- The request referer matches a configured embeddable host
This matches the existing security model for `/embed/comments`.
* Change the simplified category upcoming change to Experimental so
admins can try it out
* Delete unused preview-related code for the category UI
* Preserve category permissions when switching between public & private
access toggle
<img width="329" height="245" alt="image"
src="https://github.com/user-attachments/assets/729ba0f9-5e1c-4e71-8b4f-8c26d59bae45"
/>
---------
Co-authored-by: Régis Hanol <regis@hanol.fr>
This commit introduces a new simplified category creation and editing
form, hidden behind a `enable_simplified_category_creation` upcoming
change in the "conceptual" status
right now.
The simplified form allows users to toggle between simple + advanced
mode:
<img width="1106" height="641" alt="image"
src="https://github.com/user-attachments/assets/2ff70db7-280b-4edf-a119-bbb6554a1408"
/>
<img width="1116" height="694" alt="image"
src="https://github.com/user-attachments/assets/79e7e7d7-f7a8-49ee-80de-06f4dcb8a93f"
/>
The aim here is to make it faster for admins to create new categories,
which can be a fairly convoluted process right now. Our future aim
is to have different "types" of category creation flows.
In addition, all other forms in the other category tabs have been
converted
to use FormKit.
---------
Co-authored-by: Martin Brennan <martin@discourse.org>
Co-authored-by: Régis Hanol <regis@hanol.fr>
This site setting was hidden anyway, so it is a good candidate to
move to testing upcoming changes. Admins will not see this still
until enable_upcoming_changes is turned on.
Followup 66cee823ab
Introduces a few basic system specs to verify that image optimization is
working as expected when uploading images via the composer. The specs
check
that images are optimized and that the optimization process completes
successfully
for both png and jpg images.
This PR also improves the formatting of optimization logs, including
adding
information about the original image size in bytes
A new webpack entrypoint is created for media-optimization, so that
webpack can take care of bundling up the dependencies. The main Worker
is still hosted in `public/javascripts`, since Workers must be
same-origin.
This commit moves most of emoji logic into the discourse-emojis gem:
https://github.com/discourse/discourse-emojis/
Most notably:
- images are now symlinked from the gem
- the gem provides path to the json files
Search aliases have also been made asynchronous and memoized. When you
will search for an emoji we will now load the aliases and store the list
for future use.
---------
Co-authored-by: David Taylor <david@taylorhq.com>