Compare commits

..

34 Commits

Author SHA1 Message Date
695218595a 🦉 video preview support 2026-04-18 00:17:12 +02:00
7f08ce6b10 🦉 Update our README with more tactical information 2026-04-17 23:43:49 +02:00
5967455d60 🦉 Removing the additional first reaction chip
replacing it with a timestamp instead. much more helpful and nice.
2026-04-16 12:08:34 +02:00
3b681ac766 🦉Doc Corrections
I was emotional and wrong. After revisiting multiple other chats, all of
them do it this way. Adding reactions to existing ones was what I was
missing. the first reaction can be in the top right bar.
2026-04-16 11:55:55 +02:00
8e6b26477a 🦉 additional reaction chip left 2026-04-16 00:26:21 +02:00
3b8d7fb026 🦉 img tag conversion 2026-04-15 23:09:03 +02:00
7f12d047f3 🦉 allowing external images as setting
For the config.json (server-level default for new users):

```json
{
"defaultSettings": {
"externalImages": "homeserver"
}
}
```

User's own choice in the UI always overrides the config.json default.
2026-04-15 21:34:03 +02:00
1173c17452 🦉 allow images 2026-04-15 20:49:40 +02:00
8c892f14f8 🦉 Feature: configurable preset for users:
- twitter emoji
 - useSystemTheme
 - themeId
2026-04-15 18:01:28 +02:00
5423e8bb7a 🦉 OWL establishment 2026-04-15 18:00:36 +02:00
dependabot[bot]
a33e8db9a3 chore(deps): bump dawidd6/action-download-artifact from 16 to 20 (#2880)
Bumps [dawidd6/action-download-artifact](https://github.com/dawidd6/action-download-artifact) from 16 to 20.
- [Release notes](https://github.com/dawidd6/action-download-artifact/releases)
- [Commits](2536c51d3d...8305c0f106)

---
updated-dependencies:
- dependency-name: dawidd6/action-download-artifact
  dependency-version: '20'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-14 14:36:52 +10:00
dependabot[bot]
fb76e3ecb4 chore(deps): bump actions/upload-artifact from 7.0.0 to 7.0.1 (#2893)
Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 7.0.0 to 7.0.1.
- [Release notes](https://github.com/actions/upload-artifact/releases)
- [Commits](bbbca2ddaa...043fb46d1a)

---
updated-dependencies:
- dependency-name: actions/upload-artifact
  dependency-version: 7.0.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-14 14:33:37 +10:00
dependabot[bot]
3d79293167 chore(deps): bump softprops/action-gh-release from 2.3.3 to 3.0.0 (#2892)
Bumps [softprops/action-gh-release](https://github.com/softprops/action-gh-release) from 2.3.3 to 3.0.0.
- [Release notes](https://github.com/softprops/action-gh-release/releases)
- [Changelog](https://github.com/softprops/action-gh-release/blob/master/CHANGELOG.md)
- [Commits](6cbd405e2c...b430933298)

---
updated-dependencies:
- dependency-name: softprops/action-gh-release
  dependency-version: 3.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-14 14:33:01 +10:00
dependabot[bot]
74745edcda chore(deps): bump nginx from 1.29.5-alpine to 1.29.8-alpine (#2894)
Bumps nginx from 1.29.5-alpine to 1.29.8-alpine.

---
updated-dependencies:
- dependency-name: nginx
  dependency-version: 1.29.8-alpine
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-14 14:31:53 +10:00
dependabot[bot]
0812131a97 chore(deps): bump docker/build-push-action from 6.19.2 to 7.1.0 (#2895)
Bumps [docker/build-push-action](https://github.com/docker/build-push-action) from 6.19.2 to 7.1.0.
- [Release notes](https://github.com/docker/build-push-action/releases)
- [Commits](10e90e3645...bcafcacb16)

---
updated-dependencies:
- dependency-name: docker/build-push-action
  dependency-version: 7.1.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-14 14:27:58 +10:00
dependabot[bot]
1068bba5c7 chore(deps): bump docker/login-action from 3.7.0 to 4.1.0 (#2879)
Bumps [docker/login-action](https://github.com/docker/login-action) from 3.7.0 to 4.1.0.
- [Release notes](https://github.com/docker/login-action/releases)
- [Commits](c94ce9fb46...4907a6ddec)

---
updated-dependencies:
- dependency-name: docker/login-action
  dependency-version: 4.1.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-14 14:26:52 +10:00
DJ Chase
1b5e58a3b4 chore: add matrixrooms.info to directory list (#2844)
* chore: add matrixrooms.info to directory list

matrixrooms.info is a directory of all public Matrix rooms it can find,
regardless of homeserver. It is much larger than the morg directory,
so is more useful as a general search
2026-03-28 17:35:53 +11:00
Krishan
acae043f31 chore: make error more useful and understandable (#2859)
* chore: make error more useful and understandable

* chore: use similar wording
2026-03-27 21:22:46 +11:00
ranidspace
b4299f8f37 feat: add YYYY-MM-DD (ISO 8601) date format to presets (#2712)
* Add YYYY-MM-DD (ISO 8601) date format to presets

* Fix formatting due to added date format
2026-03-27 21:20:10 +11:00
Krishan
b6adac6714 chore: add notice about SDK replacement (#2778) 2026-03-25 12:10:15 +11:00
DJ Chase
1c8f203164 chore: add 'Stickers and Emojis' as featured space (#2842)
* Mention CLA in CONTRIBUTING.md

Closes: #2146

* add: 'Stickers and Emojis' to config.json

Add #stickers-and-emojis:tastytea.de (space) to config.json
2026-03-25 12:09:16 +11:00
Krishan
0c30ece281 fix: remove typo in no rooms UI (#2834) 2026-03-23 16:57:52 +11:00
Krishan
4e559e56d4 chore: group related package update together (#2833) 2026-03-23 16:49:22 +11:00
Krishan
19f28b40ac chore: use private vulnerability disclosure (#2827) 2026-03-22 18:29:09 +11:00
Krishan
bcaf43a540 chore: fix link in issue triage template (#2826)
* chore: fix link in issue triage template

* chore: delete .github/PULL_REQUEST_TEMPLATE.md
2026-03-22 18:20:33 +11:00
Krishan
9c7b635e7e chore: add new issue triage discussion template (#2825)
* chore: add new issue triage discussion template

* chore: ask for desktop app version as well

* chore: create preapproved.md
2026-03-22 17:55:53 +11:00
Krishan
65c87dff3a chore: add git author to the sem release (#2815) 2026-03-21 12:07:52 +11:00
Krishan
132a76df27 chore: add semantic release (#2759)
* chore: install deps related to semantic release

* chore: add husky config

* ci: add a script to update version number on new release

* ci: update ci/cd to include semantic release changes

* chore: merge dev to semantic-release
2026-03-19 16:26:25 +11:00
DJ Chase
b0954eeddc fix: Mention CLA in CONTRIBUTING.md (#2804)
Mention CLA in CONTRIBUTING.md

Closes: #2146
2026-03-19 11:41:41 +11:00
Ajay Bura
8f1add6059 fix: prevent codeblock filename drop on edit (#2780)
prevent codeblock filename drop on edit
2026-03-15 15:37:14 +11:00
Jan Jurzitza
8a78c9699e feat: allow using filenames in codeblocks (#2455)
Allow using filenames in codeblocks

- If there is a dot in the language name, we instead treat the first line after ``` as the filename and everything after the last dot as the language
- we use a custom "data-label" attribute on the code block to specify the name of the file (so only compatible with cinny from this point onwards)
2026-03-14 18:54:03 +11:00
Krishan
0721b29a2c chore: batch slate related deps (#2775) 2026-03-14 17:22:57 +11:00
Ajay Bura
3d354909d6 fix: hover state on url preview image and make it keyboard friendly (#2777)
add hover state on url preview image and make it keyboard friendly
2026-03-14 17:22:18 +11:00
LeaPhant
7570a84dfd Show image viewer when clicking url preview thumbnail (#2309)
* Show large image overlay when clicking url preview thumbnail

* Move image overlay into its own component

* Move ImageOverlay props into extended type

* Remove export for internal type
2026-03-14 11:04:55 +05:30
59 changed files with 9038 additions and 210 deletions

View File

@@ -0,0 +1,127 @@
labels: ["needs-confirmation"]
body:
- type: markdown #add faqs in future
attributes:
value: |
> [!IMPORTANT]
> Please read through [the Discussion rules](https://github.com/cinnyapp/cinny/discussions/2653) and check for both existing [Discussions](https://github.com/cinnyapp/cinny/discussions?discussions_q=) and [Issues](https://github.com/cinnyapp/cinny/issues?q=sort%3Areactions-desc) prior to opening a new Discussion.
- type: markdown
attributes:
value: "# Issue Details"
- type: textarea
attributes:
label: Issue Description
description: |
Provide a detailed description of the issue. Include relevant information, such as:
- The feature or configuration option you encounter the issue with.
- Screenshots, screen recordings, or other supporting media (as needed).
- If this is a regression of an existing issue that was closed or resolved, please include the previous item reference (Discussion, Issue, PR, commit) in your description.
placeholder: |
When I try to send a message in a room, the message doesn't appear in the timeline.
OR
The application crashes when I click on the settings button.
validations:
required: true
- type: textarea
attributes:
label: Expected Behavior
description: |
Describe how you expect Cinny to behave in this situation.
placeholder: |
I expected the message to appear in the room timeline immediately after sending.
OR
The settings panel should open smoothly without any crashes.
validations:
required: true
- type: textarea
attributes:
label: Actual Behavior
description: |
Describe how Cinny actually behaves in this situation. If it is not immediately obvious how the actual behavior differs from the expected behavior described above, please be sure to mention the deviation specifically.
placeholder: |
The application freezes for 3 seconds and then shows a white screen.
validations:
required: true
- type: textarea
attributes:
label: Reproduction Steps
description: |
Provide a detailed set of step-by-step instructions for reproducing this issue.
placeholder: |
1. Open Cinny and log in to my account
2. Navigate to the #general room
3. Type a message in the message box
4. Press Enter to send
5. Notice that the message doesn't appear in the timeline
validations:
required: true
- type: textarea
attributes:
label: Environement
description: |
Please provide information about your environment. Include the following:
- OS:
- Browser:
- Cinny Web Version: (app.cinny.in or self hosted)
- Cinny desktop Version: (appimage or deb or flatpak)
- Matrix Homeserver:
placeholder: |
- OS: Windows 11
- Browser: Chrome 120.0.6099.109
- Cinny Web Version: 3.2.0 (app.cinny.in or self hosted)
- Cinny desktop Version: 3.2.0 (appimage or deb or flatpak)
- Matrix Homeserver: matrix.org (Synapse 1.97.0)
render: text
validations:
required: true
- type: textarea
id: logs
attributes:
label: Relevant Logs
description: |
If applicable, add browser console logs to help explain your problem.
**To get browser console logs:**
- Chrome/Edge: Press F12 → Console tab
- Firefox: Press F12 → Console tab
- Safari: Develop → Show Web Inspector → Console
Please wrap large log outputs in code blocks with triple backticks (```).
placeholder: |
```
Error: Failed to send message
at MessageComposer.sendMessage (composer.js:245)
at HTMLButtonElement.onClick (composer.js:189)
TypeError: Cannot read property 'content' of undefined
at RoomTimeline.render (timeline.js:567)
```
render: shell
validations:
required: false
- type: textarea
attributes:
label: Additional context
description: |
Add any other context about the problem here (e.g., when did this start happening, does it happen on different homeservers, etc.)
placeholder: |
- This started happening after I updated to version 3.2.0
- It only happens in encrypted rooms, not in public rooms
- I've tried on both Firefox and Chrome with the same result
- It works fine on my phone using the same account
- This happens on all homeservers I've tested (matrix.org, mozilla.org)
validations:
required: false
- type: markdown
attributes:
value: |
# User Acknowledgements
> [!TIP]
> Use these links to review the existing Cinny [Discussions](https://github.com/cinnyapp/cinny/discussions?discussions_q=) and [Issues](https://github.com/cinnyapp/cinny/issues?q=sort%3Areactions-desc).
- type: checkboxes #add faqs in future
attributes:
label: "I acknowledge that:"
options:
- label: I have searched the Cinny repository (both open and closed Discussions and Issues) and confirm this is not a duplicate of an existing issue or discussion.
required: true
- label: I have checked the "Preview" tab on all text fields to ensure that everything looks right, and have wrapped all configuration and code in code blocks with a group of three backticks (` ``` `) on separate lines.
required: true

View File

@@ -1,57 +0,0 @@
name: 🐞 Bug Report
description: Report a bug
body:
- type: markdown
attributes:
value: |
## First of all
1. Please search for [existing issues](https://github.com/ajbura/cinny/issues?q=is%3Aissue) about this problem first.
2. Make sure Cinny is up to date.
3. Make sure it's an issue with Cinny and not something else you are using.
4. Remember to be friendly.
- type: textarea
id: description
attributes:
label: Describe the bug
description: A clear description of what the bug is. Include screenshots if applicable.
placeholder: Bug description
validations:
required: true
- type: textarea
id: reproduction
attributes:
label: Reproduction
description: Steps to reproduce the behavior.
placeholder: |
1. Go to ...
2. Click on ...
3. See error
- type: textarea
id: expected-behavior
attributes:
label: Expected behavior
description: A clear description of what you expected to happen.
- type: textarea
id: info
attributes:
label: Platform and versions
description: "Provide OS, browser and Cinny version with your Homeserver."
placeholder: |
1. OS: [e.g. Windows 10, MacOS]
2. Browser: [e.g. chrome 99.5, firefox 97.2]
3. Cinny version: [e.g. 1.8.1 (app.cinny.in)]
4. Matrix homeserver: [e.g. matrix.org]
render: shell
validations:
required: true
- type: textarea
id: context
attributes:
label: Additional context
description: Add any other context about the problem here.

View File

@@ -1,4 +1,5 @@
blank_issues_enabled: false
contact_links:
- name: 💬 Matrix Chat
url: https://matrix.to/#/#cinny:matrix.org
about: Ask questions and talk to other Cinny users and the maintainers
- name: Features, Bug Reports, Questions
url: https://github.com/cinnyapp/cinny/discussions/new/choose
about: Our preferred starting point if you have any questions or suggestions about features or behavior.

View File

@@ -1,33 +0,0 @@
name: 💡 Feature Request
description: Suggest an idea
body:
- type: textarea
id: problem
attributes:
label: Describe the problem
description: A clear description of the problem this feature would solve
placeholder: "I'm always frustrated when..."
validations:
required: true
- type: textarea
id: solution
attributes:
label: "Describe the solution you'd like"
description: A clear description of what change you would like
placeholder: "I would like to..."
validations:
required: true
- type: textarea
id: alternatives
attributes:
label: Alternatives considered
description: "Any alternative solutions you've considered"
- type: textarea
id: context
attributes:
label: Additional context
description: Add any other context about the problem here.

9
.github/ISSUE_TEMPLATE/preapproved.md vendored Normal file
View File

@@ -0,0 +1,9 @@
---
name: Pre-Discussed and Approved Topics
about: |-
Only for topics already discussed and approved in the GitHub Discussions section.
---
**DO NOT OPEN A NEW ISSUE. PLEASE USE THE DISCUSSIONS SECTION.**
**I DIDN'T READ THE ABOVE LINE. PLEASE CLOSE THIS ISSUE.**

View File

@@ -1,22 +0,0 @@
<!-- Please read https://github.com/ajbura/cinny/blob/dev/CONTRIBUTING.md before submitting your pull request -->
### Description
<!-- Please include a summary of the change. Please also include relevant motivation and context. List any dependencies that are required for this change. -->
Fixes #
#### Type of change
- [ ] Bug fix (non-breaking change which fixes an issue)
- [ ] New feature (non-breaking change which adds functionality)
- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected)
- [ ] This change requires a documentation update
### Checklist:
- [ ] My code follows the style guidelines of this project
- [ ] I have performed a self-review of my own code
- [ ] I have commented my code, particularly in hard-to-understand areas
- [ ] I have made corresponding changes to the documentation
- [ ] My changes generate no new warnings

3
.github/SECURITY.md vendored
View File

@@ -1,3 +0,0 @@
# Reporting a Vulnerability
**If you've found a security vulnerability, please report it to cinnyapp@gmail.com**

16
.github/renovate.json vendored
View File

@@ -3,12 +3,26 @@
"extends": [
"config:recommended",
":dependencyDashboardApproval",
":semanticCommits"
":semanticCommits",
"group:monorepos"
],
"labels": ["Dependencies"],
"rebaseWhen": "conflicted",
"packageRules": [
{
"matchUpdateTypes": ["lockFileMaintenance"]
},
{
"groupName": "Slatejs",
"matchPackageNames": ["slate", "slate-dom", "slate-history", "slate-react"]
},
{
"groupName": "Call",
"matchPackageNames": ["@element-hq/element-call-embedded", "matrix-widget-api"]
},
{
"groupName": "Linkify",
"matchPackageNames": ["linkifyjs", "linkify-react"]
}
],
"lockFileMaintenance": {

View File

@@ -25,7 +25,7 @@ jobs:
NODE_OPTIONS: '--max_old_space_size=4096'
run: npm run build
- name: Upload artifact
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: preview
path: dist
@@ -33,7 +33,7 @@ jobs:
- name: Save pr number
run: echo ${PR_NUMBER} > ./pr.txt
- name: Upload pr number
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: pr
path: ./pr.txt

View File

@@ -16,7 +16,7 @@ jobs:
if: ${{ github.event.workflow_run.conclusion == 'success' }}
steps:
- name: Download pr number
uses: dawidd6/action-download-artifact@2536c51d3d126276eb39f74d6bc9c72ac6ef30d3 # v16
uses: dawidd6/action-download-artifact@8305c0f1062bb0d184d09ef4493ecb9288447732 # v20
with:
workflow: ${{ github.event.workflow.id }}
run_id: ${{ github.event.workflow_run.id }}
@@ -25,7 +25,7 @@ jobs:
id: pr
run: echo "id=$(<pr.txt)" >> $GITHUB_OUTPUT
- name: Download artifact
uses: dawidd6/action-download-artifact@2536c51d3d126276eb39f74d6bc9c72ac6ef30d3 # v16
uses: dawidd6/action-download-artifact@8305c0f1062bb0d184d09ef4493ecb9288447732 # v20
with:
workflow: ${{ github.event.workflow.id }}
run_id: ${{ github.event.workflow_run.id }}

View File

@@ -26,7 +26,7 @@ jobs:
- name: Login to Docker Hub #Do not update this action from a outside PR
if: github.event.pull_request.head.repo.fork == false
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
@@ -34,7 +34,7 @@ jobs:
- name: Login to the Github Container registry #Do not update this action from a outside PR
if: github.event.pull_request.head.repo.fork == false
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
with:
registry: ghcr.io
username: ${{ github.actor }}
@@ -50,7 +50,7 @@ jobs:
ghcr.io/${{ github.repository }}
- name: Build Docker image (no push)
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
with:
context: .
platforms: linux/amd64

View File

@@ -1,16 +1,19 @@
name: Production deploy
on:
release:
types: [published]
workflow_dispatch:
jobs:
deploy-and-tarball:
name: Netlify deploy and tarball
outputs:
version: ${{ steps.vars.outputs.tag }}
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
- name: Setup node
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with:
@@ -18,6 +21,19 @@ jobs:
package-manager-cache: false
- name: Install dependencies
run: npm ci
- name: Run semantic release
run: npm run semantic-release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GIT_AUTHOR_NAME: ${{ secrets.GIT_AUTHOR_NAME }}
GIT_AUTHOR_EMAIL: ${{ secrets.GIT_AUTHOR_EMAIL }}
GIT_COMMITTER_NAME: ${{ secrets.GIT_AUTHOR_NAME }}
GIT_COMMITTER_EMAIL: ${{ secrets.GIT_AUTHOR_EMAIL }}
- name: Get version from tag
id: vars
run: |
TAG=$(git describe --tags --abbrev=0)
echo "tag=$TAG" >> $GITHUB_OUTPUT
- name: Build app
env:
NODE_OPTIONS: '--max_old_space_size=4096'
@@ -26,7 +42,7 @@ jobs:
uses: nwtgck/actions-netlify@4cbaf4c08f1a7bfa537d6113472ef4424e4eb654 # v3.0.0
with:
publish-dir: dist
deploy-message: 'Prod deploy ${{ github.ref_name }}'
deploy-message: 'Prod deploy ${{ steps.vars.outputs.tag }}'
enable-commit-comment: false
github-token: ${{ secrets.GITHUB_TOKEN }}
production-deploy: true
@@ -36,9 +52,6 @@ jobs:
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID_APP }}
timeout-minutes: 1
- name: Get version from tag
id: vars
run: echo "tag=${GITHUB_REF#refs/*/}" >> $GITHUB_OUTPUT
- name: Create tar.gz
run: tar -czvf cinny-${{ steps.vars.outputs.tag }}.tar.gz dist
- name: Sign tar.gz
@@ -52,14 +65,18 @@ jobs:
gpg --export | xxd -p
echo '${{ secrets.GNUPG_PASSPHRASE }}' | gpg --batch --yes --pinentry-mode loopback --passphrase-fd 0 --armor --detach-sign cinny-${{ steps.vars.outputs.tag }}.tar.gz
- name: Upload tagged release
uses: softprops/action-gh-release@6cbd405e2c4e67a21c47fa9e383d020e4e28b836 # v2.3.3
uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3.0.0
with:
tag_name: ${{ steps.vars.outputs.tag }}
files: |
cinny-${{ steps.vars.outputs.tag }}.tar.gz
cinny-${{ steps.vars.outputs.tag }}.tar.gz.asc
publish-image:
name: Push Docker image to Docker Hub, GHCR
needs: deploy-and-tarball
env:
VERSION: ${{ needs.deploy-and-tarball.outputs.version }}
runs-on: ubuntu-latest
permissions:
contents: read
@@ -67,17 +84,19 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
- name: Set up QEMU
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
- name: Login to Docker Hub #Do not update this action from a outside PR
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Login to the Github Container registry #Do not update this action from a outside PR
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
with:
registry: ghcr.io
username: ${{ github.actor }}
@@ -89,11 +108,14 @@ jobs:
images: |
${{ secrets.DOCKER_USERNAME }}/cinny
ghcr.io/${{ github.repository }}
tags: |
type=raw,value=${{ env.VERSION }}
type=raw,value=latest
- name: Build and push Docker image
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
with:
context: .
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
labels: ${{ steps.meta.outputs.labels }}

3
.husky/pre-commit Normal file
View File

@@ -0,0 +1,3 @@
# These are commented until we enable lint and typecheck
# npx tsc -p tsconfig.json --noEmit
# npx lint-staged

View File

@@ -18,7 +18,7 @@ Bug reports and feature suggestions must use descriptive and concise titles and
## Pull requests
> ### Legal Notice
> When contributing to this project, you must agree that you have authored 100% of the content, that you have the necessary rights to the content and that the content you contribute may be provided under the project license.
> When contributing to this project, you must agree that you have authored 100% of the content, that you have the necessary rights to the content and that the content you contribute may be provided under the project license. You will also be asked to [sign the CLA](https://github.com/cinnyapp/cla) upon submiting your pull request.
**NOTE: If you want to add new features, please discuss with maintainers before coding or opening a pull request.** This is to ensure that we are on same track and following our roadmap.

View File

@@ -11,7 +11,7 @@ RUN npm run build
## App
FROM nginx:1.29.5-alpine
FROM nginx:1.29.8-alpine
COPY --from=builder /src/dist /app
COPY --from=builder /src/docker-nginx.conf /etc/nginx/conf.d/default.conf

View File

@@ -16,6 +16,10 @@ A Matrix client focusing primarily on simple, elegant and secure interface. The
- [Roadmap](https://github.com/orgs/cinnyapp/projects/1)
- [Contributing](./CONTRIBUTING.md)
> [!IMPORTANT]
We are currently in the [process of replacing the matrix-js-sdk](https://github.com/cinnyapp/cinny/issues/257#issuecomment-3714406704) with our own SDK. As a result, we will not be accepting any pull requests until further notice.
Thank you for your understanding.
<img align="center" src="https://raw.githubusercontent.com/cinnyapp/cinny-site/main/assets/preview2-light.png" height="380">
## Getting started

View File

@@ -11,7 +11,8 @@
"#space:unredacted.org",
"#science-space:matrix.org",
"#libregaming-games:tchncs.de",
"#mathematics-on:matrix.org"
"#mathematics-on:matrix.org",
"#stickers-and-emojis:tastytea.de"
],
"rooms": [
"#cinny:matrix.org",
@@ -21,7 +22,7 @@
"#PrivSec.dev:arcticfoxes.net",
"#disroot:aria-net.org"
],
"servers": ["matrix.org", "mozilla.org", "unredacted.org"]
"servers": ["matrixrooms.info", "matrix.org", "mozilla.org", "unredacted.org"]
},
"hashRouter": {

36
owl/README.md Normal file
View File

@@ -0,0 +1,36 @@
# Owl Folder
owl.cx employs it's own cinny fork.
this one should slowly introduce changes and features that are useful for a homeserver, and go even further into the direction of replacing teamspeak or discord.
it does not try to keep up the typical matrix approach to have overengineered decisions in the chat, or avoid all kinds of exploits that are dangerous for federated servers, but less impactful for a single-server-instance.
It still tries to _respect_ matrix as a tool, that can do all these things.
In this owl folder should go all documents we produce for this Endeavour.
## Goals
- [x] Find a way to build extensions that survive upstream merges best. This means, reduce contact point, create own files, even be in own folder if possible. This needs a thorough scan of the codebase.
- [ ] We will try to integrate `comms` project at some point. but this is a separate task, not needed yet. It will bring voice server to the rooms. comms uses a wasm system.
- [x] Try to find out how much we can change behaviour of the page with the _themes_ (skins) only. Some small silly things, like that reacting with a new emoji on a post should not be on the left side of a message, where currently it is embedded in a popup.
- [x] Find out how we can set that Twitter Emojis are standard - system emojis on windows are terrible.
- [x] Find out how we can set a theme by default for new users.
- [x] img tags in messages should be rendered, without requiring them to be on a local cache. We do not need to secure against identification attacks.
- [x] youtube links should create embedded players (if enabled).
- [x] 9gag links with videos should allow them to be played if possible on demand.
## Commit Messages
- All Commits for OWL changes start with the Owl Emoji 🦉
- I do most commits as user
## Code Guidelines
- This Repository follows the official cinny repo, but represents a custom modification.
- This means we have to keep Upstream Codebase mergable easy
- This means: /src/owl/ is the local override folder, longer implementations and definitions go
- Only required wirings, imports or unavoidable modifications should be in the main source code.
- Learn from owl commits

View File

@@ -0,0 +1,262 @@
# OWL Research Report #001 - Initial Codebase Analysis
Date: 2026-04-15
This report covers the initial research into all goals outlined in the README, except comms integration (deferred).
---
## 1. Extension Strategy - Surviving Upstream Merges
### Codebase Overview
Cinny is a Vite 5 + React 18 SPA using TypeScript, Jotai for state, `@vanilla-extract/css` for styling, and the `folds` design system. Key layout:
```
src/
app/
components/ # Reusable React components (48 feature components)
features/ # Feature modules (17 major features)
hooks/ # Custom React hooks (~40 files)
pages/ # Page-level components (routing/auth)
plugins/ # Extension plugins (emoji, markdown, calls, etc.)
state/ # State management (Jotai atoms)
styles/ # CSS (Vanilla Extract)
utils/ # Utilities (matrix, room, sanitization, etc.)
client/ # Matrix client initialization
colors.css.ts # Theme color definitions
index.tsx # Application entry point
```
### No Plugin System
Cinny has **no formal plugin/extension architecture**. All imports are static. The `plugins/` directory is just a naming convention for utility modules, not a registration system.
### Recommended Isolation Strategy
All custom owl code lives in a single `src/owl/` directory tree. React and Vite don't care where files live — imports are just paths. This keeps everything in one place that upstream will never touch, making it easy to see the full scope of our fork and trivial to manage across merges.
```
src/owl/
components/ # Our custom UI components (YouTubeEmbedCard, etc.)
features/ # Our custom feature modules
hooks/ # Our custom hooks
plugins/ # Our custom plugins
state/ # Our custom Jotai atoms & settings
styles/ # Our custom CSS (Vanilla Extract)
utils/ # Our custom utilities (sanitizer overrides, etc.)
```
Upstream files import our code via relative paths like `import { YouTubeEmbed } from '../../../owl/components/YouTubeEmbed'`. These one-liner injection points are the only upstream modifications needed and are easy to re-apply after a merge.
### Injection Points (unavoidable upstream edits)
These are the upstream files we'll need to add small imports/calls into. Each edit should be minimal — ideally a single import + one-liner call to owl code, keeping the actual logic in `src/owl/`.
| File | Why | Change Size |
|------|-----|-------------|
| `src/app/state/settings.ts` | Change defaults (twitterEmoji, theme) and add new settings (youtubeEmbed) | ~5 lines |
| `src/app/utils/sanitize.ts` | Allow external img src URLs | ~10 lines |
| `src/app/plugins/react-custom-html-parser.tsx` | Render external images directly | ~10 lines |
| `src/app/components/RenderMessageContent.tsx` | Import & call owl YouTubeEmbedCard | ~5 lines |
| `src/colors.css.ts` | Add custom themes (if needed) | ~lines for theme |
| `src/app/hooks/useTheme.ts` | Register custom themes (if needed) | ~5 lines |
| `src/app/pages/Router.tsx` | Only if adding new routes | ~5 lines |
### Merge Strategy
- All logic lives in `src/owl/` — upstream never touches it
- Upstream edits are kept to minimal injection points (imports + short calls)
- On upstream merge, only the injection points can conflict — re-apply them by hand if needed
- Consider a `src/owl/INJECTIONS.md` file listing every upstream file we touch and why, so re-applying after merge is mechanical
---
## 2. Theming - What CSS Can and Cannot Change
### What Themes Control
Themes in Cinny are **color-only**. The `createTheme()` system from vanilla-extract exposes these token categories:
- **Background**: Container, ContainerHover, ContainerActive, ContainerLine, OnContainer
- **Surface**: Same pattern
- **SurfaceVariant**: Same pattern
- **Primary**: Main, MainHover, MainActive, MainLine, OnMain, Container variants, OnContainer
- **Secondary**: Same as Primary
- **Success / Warning / Critical**: Same as Primary
- **Other**: FocusRing, Shadow, Overlay
### What Themes CANNOT Change
- **Layout/positioning** - Component layout is hardcoded in vanilla-extract style objects, not CSS variables
- **Icon choices** - Icons are imported React components, not themeable
- **Behavior** - Popup positioning, menu structure, click handlers are all JS
- **Spacing/sizing** - Uses `folds` design tokens (`config.space`, `config.radii`, etc.) which are not part of the theme contract
### Verdict
**Themes alone cannot fix the reaction popup UX or change icons/menus.** They can only change colors. Layout, positioning, and behavioral changes require component modifications.
---
## 3. Reaction Popup UX
### Current Mechanism
The reaction picker is controlled by `src/app/features/room/message/Message.tsx`:
- A toolbar (`MessageOptionsBase`) appears on message hover, positioned `top: -30px; right: 0` (absolute) via `src/app/features/room/message/styles.css.ts`
- Clicking the emoji button captures `getBoundingClientRect()` and opens a `PopOut` component with `position="Bottom"` and `align="End"`
- The emoji board renders inside this PopOut popup
- There's also a context menu with `MessageQuickReactions` showing 4 recent emojis
### What Would Need to Change
To move reactions to a more sensible position:
1. **styles.css.ts** - Change `MessageOptionsBase` positioning (top/right/bottom)
2. **Message.tsx** - Change `PopOut` props (`position`, `align`) or replace PopOut with inline rendering
3. Optionally: render the emoji board inline below the message instead of as a floating popup
This is a **component-level change**, not achievable through themes.
---
## 4. Twitter Emoji (Twemoji) as Default
### Current State
- Setting exists: `twitterEmoji` in `src/app/state/settings.ts` (line 26)
- **Default is `false`** (line 60)
- Font files exist: `public/font/Twemoji.Mozilla.v15.1.0.ttf` and `.woff2`
- Applied via CSS custom property `--font-emoji` in `src/app/pages/client/ClientNonUIFeatures.tsx` (lines 30-40)
### How to Make It Default
**One-line change** in `src/app/state/settings.ts`:
```typescript
// Line 60: change from
twitterEmoji: false,
// to
twitterEmoji: true,
```
New users get `defaultSettings` merged with their (empty) localStorage. Existing users who never touched the toggle will also get the new default via the merge logic in `getSettings()` (lines 86-93).
**Merge risk: LOW** - Single line change in a defaults object.
---
## 5. Default Theme for New Users
### Current Defaults
In `src/app/state/settings.ts` (lines 52-56):
```typescript
themeId: undefined, // no manual override
useSystemTheme: true, // follow OS preference
lightThemeId: undefined, // fallback: LightTheme
darkThemeId: undefined, // fallback: DarkTheme
```
Theme resolution in `src/app/hooks/useTheme.ts`:
- If `useSystemTheme` is true (default): detects OS preference, picks light/dark fallback
- If disabled: uses `themeId`, falls back to LightTheme
### Available Themes
| ID | Name |
|----|------|
| `light-theme` | Light (default light) |
| `silver-theme` | Silver |
| `dark-theme` | Dark (default dark) |
| `butter-theme` | Butter |
### How to Set a Default Theme
**Option A - Force a specific theme:**
```typescript
useSystemTheme: false,
themeId: 'butter-theme', // or any theme ID
```
**Option B - Change the dark/light fallbacks (keeps system detection):**
```typescript
darkThemeId: 'butter-theme', // dark mode users get Butter
lightThemeId: 'silver-theme', // light mode users get Silver
```
**Merge risk: LOW** - Changes to defaults object only.
---
## 6. External IMG Tags in Messages
### Current Behavior
External image URLs are **blocked** at two levels:
1. **Sanitization** (`src/app/utils/sanitize.ts`, lines 108-127): `transformImgTag` converts any `<img>` with non-`mxc://` src into an `<a>` link
2. **React parser** (`src/app/plugins/react-custom-html-parser.tsx`, lines 476-494): Non-mxc images are rendered as links, not `<img>` elements
This is a deliberate privacy measure for federated Matrix — loading external images reveals user IPs to image hosts.
### What Needs to Change
Since owl.cx is a single-server instance where this threat model doesn't apply:
1. **sanitize.ts**: Modify `transformImgTag` to allow `https://` and `http://` src URLs through as `<img>` tags instead of converting to `<a>`
2. **react-custom-html-parser.tsx**: Modify the `img` handler to render external URLs directly with `<img src={originalUrl}>` instead of converting to links
### Suggested Approach
Add an owl setting (e.g., `allowExternalImages: true`) and conditionally bypass the mxc-only restriction. This keeps the change isolated and opt-in.
**Merge risk: MEDIUM** - Touching security-sensitive sanitization code. Keep changes minimal and well-isolated.
---
## 7. YouTube Link Embeds
### Current State
- **No YouTube handling exists** in the codebase
- **`iframe` is NOT in the allowed tags** in sanitize.ts
- URL previews exist (`UrlPreviewCard.tsx`) but only render Open Graph metadata cards (title, image, description) — no embeds
- Settings exist: `urlPreview` and `encUrlPreview` booleans
### What Needs to Be Built
**Recommended approach**: Create a `YouTubeEmbedCard` component that slots into the existing URL preview system.
1. **Create** `src/owl/components/YouTubeEmbedCard.tsx`:
- Detect YouTube URLs via regex (`youtube.com/watch?v=`, `youtu.be/`)
- Extract video ID
- Render `<iframe src="https://www.youtube.com/embed/{ID}" ...>`
- Include sandbox attributes for security
2. **Modify** `src/app/components/RenderMessageContent.tsx`:
- In `renderUrlsPreview`, check if URL is YouTube before rendering `UrlPreviewCard`
- If YouTube and embed enabled, render `YouTubeEmbedCard` instead
3. **Add setting** in `src/app/state/settings.ts`:
- `youtubeEmbed: boolean` (default: true for owl)
4. **No need to modify sanitize.ts** for this — the iframe is rendered by our component, not parsed from message HTML.
**Merge risk: LOW-MEDIUM** - Most code is new files. Only `RenderMessageContent.tsx` and `settings.ts` need upstream modifications.
---
## Priority Recommendations
| Task | Effort | Risk | Recommendation |
|------|--------|------|---------------|
| Twemoji default | 1 line | Low | Do immediately |
| Default theme | 2-3 lines | Low | Do immediately |
| External images | ~30 lines | Medium | Do soon, add owl setting |
| YouTube embeds | New component + ~20 lines changes | Low-Medium | Build as isolated component |
| Reaction UX | Component restructure | Medium | Plan carefully, touches upstream |
The first two are trivial defaults changes. External images and YouTube embeds can be built mostly in isolation. The reaction UX rework is the most invasive change and should be planned carefully to minimize merge conflict surface.

View File

@@ -0,0 +1,176 @@
# OWL Report #002 - External Images: Settings & Scoping
Date: 2026-04-15
## The Question
We've already removed the external image block (sanitize.ts + react-custom-html-parser.tsx). But should this be unconditional? Options:
1. Global toggle (user setting)
2. Per-room setting
3. Automatic based on federation status
4. Homeserver-level default via config.json
## What Context Is Available at Render Time
The key rendering call chain is:
```
RoomTimeline.tsx (has: room object, mx client, settings)
→ getReactCustomHtmlParser(mx, roomId, params)
→ HTMLReactParserOptions (handles img tags)
→ RenderMessageContent (receives pre-built parser options)
```
At the `RoomTimeline` level (line 528 of `src/app/features/room/RoomTimeline.tsx`), we have:
- Full `room` object (Room from matrix-js-sdk)
- `mx` (MatrixClient) — knows the homeserver domain
- All user settings via Jotai atoms
### Federation Detection
Rooms carry an `m.federate` flag in their `m.room.create` state event:
```typescript
const createEvent = getStateEvent(room, StateEvent.RoomCreate);
const isFederated = createEvent?.getContent()?.['m.federate'] !== false;
```
Default is `true` (federated). Local-only rooms explicitly set `m.federate: false`.
### Homeserver Detection
We can tell if a message sender is local:
```typescript
const senderDomain = event.getSender()?.split(':')[1];
const isLocal = senderDomain === mx.getDomain();
```
## Options Analysis
### Option A: Global User Setting
Add `allowExternalImages: boolean` to settings, default `false` (or `true` via config.json for owl).
**Pros:** Simple, one toggle, works everywhere.
**Cons:** All-or-nothing. A privacy-conscious user can't allow it for trusted rooms only.
### Option B: Per-Room Setting (Room State Event)
Store a custom state event like `cx.owl.room.settings` with `{ allowExternalImages: true }` per room. Room admins control it.
**Pros:** Fine-grained, admin-controlled.
**Cons:** Requires room admin action. UX overhead. Need UI for room settings. Only works in rooms we control.
### Option C: Automatic by Federation Status
If `m.federate === false` (local-only room) → allow external images. If federated → block.
**Pros:** Zero configuration, matches the threat model (federated = untrusted external servers can leak IPs via images).
**Cons:** Most rooms default to `m.federate: true` even on a single homeserver. Users would need to explicitly create non-federated rooms. Doesn't match the real intent — on owl.cx, all rooms are effectively local even if technically federated.
### Option D: Homeserver-Level Default via config.json
Add to config.json:
```json
{
"defaultSettings": {
"allowExternalImages": "homeserver"
}
}
```
Where the value is one of: `"always"`, `"never"`, `"homeserver"` (only in rooms on this homeserver), `"local"` (only non-federated rooms).
**Pros:** Server operator controls the policy. Matches the owl use case perfectly — "we trust our own server."
**Cons:** More logic to implement. Need to define what "homeserver room" means (all members local? room alias local? room ID local?).
### Option E: Layered Approach (Recommended)
Combine a global setting with smart defaults:
1. **Global setting** `externalImages`: `"always"` | `"never"` | `"homeserver"`
- Default: `"never"` (safe for general Cinny users)
- owl config.json sets: `"homeserver"` or `"always"`
2. **"homeserver" mode**: allow external images when the room's server matches the user's homeserver (check room ID domain: `!roomid:owl.cx`)
3. **Per-room override** (future): room admins can set `cx.owl.room.settings` state event to override
This gives us:
- Safe default for upstream/general use
- owl.cx sets `"homeserver"` via config.json — external images work in all owl rooms
- Users can override to `"always"` or `"never"` in settings
- Room-level control can be added later without changing the architecture
## Implementation Plan
### Settings Layer
**`src/app/state/settings.ts`:**
```typescript
// Add to Settings interface:
externalImages: 'always' | 'never' | 'homeserver';
// Add to defaultSettings:
externalImages: 'never',
```
**`config.json` (owl deployment):**
```json
{
"defaultSettings": {
"externalImages": "homeserver"
}
}
```
### Rendering Layer
**`src/app/plugins/react-custom-html-parser.tsx`:**
Add `allowExternalImages?: boolean` to the params type. When false, revert to the original behavior (convert external img to `<a>` link).
**`src/app/features/room/RoomTimeline.tsx`:**
Read the `externalImages` setting, check room context, compute the boolean `allowExternalImages`, pass it into `getReactCustomHtmlParser` params.
```typescript
const [externalImages] = useSetting(settingsAtom, 'externalImages');
const allowExternalImages = useMemo(() => {
if (externalImages === 'always') return true;
if (externalImages === 'never') return false;
// 'homeserver': check if room belongs to our homeserver
const roomDomain = room.roomId.split(':')[1];
return roomDomain === mx.getDomain();
}, [externalImages, room.roomId, mx]);
```
**`src/app/utils/sanitize.ts`:**
`sanitizeCustomHtml` needs the flag too, since it runs before the React parser. Either:
- Pass it as a parameter: `sanitizeCustomHtml(html, { allowExternalImages })`
- Or move all image logic to the React parser (simpler — sanitizer just passes img tags through, parser decides what to render)
The second approach is cleaner: the sanitizer allows img tags with any src (already done), and the React parser decides whether to render or convert to link based on the flag.
### Settings UI
Add a dropdown in `src/app/features/settings/general/General.tsx` under the "Media" or "Privacy" section:
```
External Images: [Always / Homeserver Only / Never]
```
### Files to Touch
| File | Change |
|------|--------|
| `src/app/state/settings.ts` | Add `externalImages` to Settings + defaults |
| `src/app/plugins/react-custom-html-parser.tsx` | Accept + check `allowExternalImages` param |
| `src/app/features/room/RoomTimeline.tsx` | Compute flag from setting + room context, pass to parser |
| `src/app/features/settings/general/General.tsx` | Add UI dropdown |
| `src/app/utils/sanitize.ts` | Already done — img tags pass through |
### What About the Sanitizer?
Current state: we already removed the img→link conversion in `sanitize.ts`. For the settings-based approach, we have two options:
**Option 1 (simpler):** Leave sanitize.ts as-is (allows all img src through). The React parser handles the allow/block decision. This means the sanitized HTML contains `<img src="https://...">` but the React parser may convert it to a link at render time.
**Option 2 (stricter):** Restore the original sanitize.ts behavior and only bypass it when `allowExternalImages` is true. This requires passing options to `sanitizeCustomHtml`, which changes its signature and every call site.
**Recommendation:** Option 1. The sanitizer's job is preventing XSS, not policy decisions. Let the React parser handle the rendering policy — it already has all the context it needs.

View File

@@ -0,0 +1,337 @@
# OWL Report #003 - External Video Embeds (YouTube + Direct Video URLs)
Date: 2026-04-17
## The Goal
Two README items merge naturally into one feature:
- **YouTube links** should create embedded players (click-to-play).
- **9gag-style direct video URLs** (e.g. `https://img-9gag-fun.9cache.com/photo/aYQnPXN_460svav1.mp4`) should be playable inline.
Both are "external video in the timeline". They share the same wiring points (URL detection → card renderer in `RenderMessageContent.tsx`) and the same policy model (`'always' | 'homeserver' | 'never'`, mirroring the external-images setting). Different rendering tech: YouTube needs an iframe, direct videos use the `<video>` tag — but that's an internal detail of the renderer.
---
## Existing Plumbing We Will Reuse
The external-images work (Report #002, now shipped) gave us the exact pattern to follow.
| Piece | File | Role |
|-------|------|------|
| URL extraction | `src/app/utils/regex.ts``URL_REG` / `HTTP_URL_PATTERN` | Already pulls every http(s) URL out of message text |
| URL classifier | `src/owl/utils/imageUrl.ts``isImageUrl()` | Pattern we will mirror for `isVideoUrl`/`isYouTubeUrl` |
| Render dispatch | `src/app/components/RenderMessageContent.tsx:63-84``renderUrlsPreview` | The single place we branch per URL kind |
| Card component | `src/owl/components/ExternalImageCard.tsx` | Exact shape to mirror |
| Policy setting | `src/app/state/settings.ts:44``externalImages: ExternalImageMode` | Copy for `externalVideos` |
| Policy hook | `src/owl/hooks/useAllowExternalImages.ts` | Copy for `useAllowExternalVideos` |
| Settings UI | `src/app/features/settings/general/General.tsx:882-954` (`SelectExternalImages`) + mount at line 1043-1048 | Copy for `SelectExternalVideos` |
| Matrix `<video>` element | `src/app/components/media/Video.tsx` | Already exists — reusable for direct video rendering |
The key insight: `renderUrlsPreview` in `RenderMessageContent.tsx` is already where all URL-derived embeds dispatch. Today it routes images to `ExternalImageCard` and everything else to `UrlPreviewCard`. Adding video is one more branch in the same `filter()` chain. **No changes to `sanitize.ts` or the HTML parser are needed** — we are rendering from the URL list, not from user HTML.
---
## Part A — YouTube Embeds
### Detection
YouTube URL variants we want to catch:
```
https://www.youtube.com/watch?v=VIDEOID
https://youtube.com/watch?v=VIDEOID&t=42s
https://youtu.be/VIDEOID
https://youtu.be/VIDEOID?t=42
https://www.youtube.com/shorts/VIDEOID
https://www.youtube.com/embed/VIDEOID
https://m.youtube.com/watch?v=VIDEOID
```
Parse via `URL` rather than a mega-regex. That way a typo in the query string won't break detection:
```ts
// src/owl/utils/videoUrl.ts
export function getYouTubeId(url: string): string | null {
try {
const u = new URL(url);
const host = u.hostname.replace(/^www\.|^m\./, '');
if (host === 'youtu.be') return u.pathname.slice(1).split('/')[0] || null;
if (host === 'youtube.com') {
if (u.pathname === '/watch') return u.searchParams.get('v');
const m = u.pathname.match(/^\/(shorts|embed|v)\/([^/]+)/);
if (m) return m[2];
}
return null;
} catch { return null; }
}
export function getYouTubeStart(url: string): number | null {
try {
const t = new URL(url).searchParams.get('t') ?? new URL(url).searchParams.get('start');
if (!t) return null;
// handle "42", "42s", "1m30s"
const m = t.match(/(?:(\d+)h)?(?:(\d+)m)?(?:(\d+)s?)?/);
if (!m) return null;
return (+(m[1] || 0)) * 3600 + (+(m[2] || 0)) * 60 + (+(m[3] || 0));
} catch { return null; }
}
```
### Rendering Strategy: "Facade" (Click-to-Load)
Don't render the iframe eagerly. Two reasons:
1. **Privacy**: YouTube's iframe (even on `youtube-nocookie.com`) phones home as soon as it mounts. Loading a full iframe for every YouTube link in a room scrollback is a tracking and performance problem.
2. **Performance**: A YouTube iframe is ~500KB of JS per instance. Ten YouTube links in a channel would pin the tab.
Instead: show a clickable thumbnail from `https://i.ytimg.com/vi/<ID>/hqdefault.jpg` with a play-button overlay. On click, swap to the iframe. This is the standard approach (used by Reddit, Discord, Mastodon front-ends).
```
┌───────────────────────────────────┐
│ [ YouTube thumbnail image ] │
│ ▶ (big play button) │
│ │
│ youtube.com/watch?v=dQw4w9WgXcQ │
└───────────────────────────────────┘
↓ click
┌───────────────────────────────────┐
│ [ <iframe> YouTube player ] │
└───────────────────────────────────┘
```
### iframe Attributes
When the user clicks to load:
```tsx
<iframe
src={`https://www.youtube-nocookie.com/embed/${id}?autoplay=1${start ? `&start=${start}` : ''}`}
title={`YouTube: ${id}`}
loading="lazy"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; fullscreen"
allowFullScreen
referrerPolicy="strict-origin-when-cross-origin"
// no sandbox attr — the full YouTube embed API needs scripts+same-origin, and
// youtube-nocookie is already an isolated origin. Sandbox breaks fullscreen.
/>
```
Notes:
- **`youtube-nocookie.com`** is the reduced-tracking variant and should be the default.
- **Don't sandbox**. The YouTube player relies on `postMessage` and same-origin window access for its controls; sandboxing breaks fullscreen and picture-in-picture. The iframe is already origin-isolated.
- **CSP**: Cinny's `vite.config.ts` / `public/config.json` don't set CSP headers — the deployment host does. owl.cx must allow `frame-src https://www.youtube-nocookie.com https://www.youtube.com` (and the `i.ytimg.com` image host). Flag this for deployment.
### Component sketch
```tsx
// src/owl/components/YouTubeEmbedCard.tsx
export const YouTubeEmbedCard = as<'div', { url: string }>(({ url, ...p }, ref) => {
const id = getYouTubeId(url);
const start = getYouTubeStart(url);
const [playing, setPlaying] = useState(false);
if (!id) return null;
return (
<Box direction="Column" gap="100" {...p} ref={ref}>
<div className={css.YouTubeFrame}>
{playing ? (
<iframe {...see above with id + start} />
) : (
<button onClick={() => setPlaying(true)}>
<img src={`https://i.ytimg.com/vi/${id}/hqdefault.jpg`} loading="lazy" />
<PlayOverlay />
</button>
)}
</div>
<Text as="a" href={url} target="_blank" rel="noreferrer">{url}</Text>
</Box>
);
});
```
Aspect ratio: 16:9 (`aspect-ratio: 16 / 9` in CSS) with a sensible `max-width` (e.g. 480px) so it doesn't dominate the timeline.
---
## Part B — Direct Video URLs (9gag, etc.)
### Detection
Mirror `isImageUrl` exactly:
```ts
// src/owl/utils/videoUrl.ts
const VIDEO_EXTENSIONS = ['.mp4', '.webm', '.ogg', '.ogv', '.mov', '.m4v'];
export function isVideoUrl(url: string): boolean {
try {
const { pathname } = new URL(url);
return VIDEO_EXTENSIONS.some((ext) => pathname.toLowerCase().endsWith(ext));
} catch { return false; }
}
```
**About 9gag specifically**: their `.webp` links served under `/photo/` are actually animated images (not video). The `.mp4` variant (`aYQnPXN_460svav1.mp4`) is a true MP4 and needs `<video>`. The path-segment `photo` is misleading — extension is what matters. Our image-extension list already handles `.webp`, so animated WebP 9gag links will flow through `ExternalImageCard` today (the `<img>` tag will animate them natively). MP4 variants will flow through the new video card. No special 9gag detection needed.
### Rendering
No facade needed — `<video>` with `preload="metadata"` only downloads the first few KB (metadata + poster frame). Users click play to stream.
```tsx
// src/owl/components/ExternalVideoCard.tsx
export const ExternalVideoCard = as<'div', { url: string }>(({ url, ...p }, ref) => {
const [error, setError] = useState(false);
if (error) return null;
return (
<Box direction="Column" gap="100" {...p} ref={ref}>
<video
className={css.ExternalVideo}
src={url}
controls
preload="metadata"
playsInline
onError={() => setError(true)}
/>
<Text as="a" href={url} target="_blank" rel="noreferrer">{url}</Text>
</Box>
);
});
```
We can reuse the existing `<Video>` wrapper from `src/app/components/media/Video.tsx` if we want shared styling, but a new card mirroring `ExternalImageCard` is simpler and keeps all owl code in `src/owl/`.
### Gotchas
- **CORS / Hotlinking**: Some hosts (including Cloudflare sites) block hotlinked video with `Referer` checks. 9cache.com currently allows it — easy to verify empirically. If a host blocks us, `<video onError>` falls back (card hides itself). No leaking user IP because the threat model for owl is single-server — same as external images.
- **Autoplay**: Do **not** autoplay. Browsers require `muted` for autoplay and it's user-hostile. `preload="metadata"` gives a poster frame without streaming the whole file.
- **Mobile data**: `preload="metadata"` is the right default; don't change it to `auto`.
---
## Part C — Unified Wiring
### Setting
```ts
// src/app/state/settings.ts
export type ExternalVideoMode = 'always' | 'homeserver' | 'never';
// add to Settings interface:
externalVideos: ExternalVideoMode;
// add to defaultSettings:
externalVideos: 'never', // upstream-safe default
```
owl.cx `config.json` overrides to `'homeserver'` (or `'always'`) via the same `defaultSettings` mechanism that external-images already uses.
Open question: **single setting or two?** My recommendation is **one setting `externalVideos` covering both YouTube and direct video**, because:
- They have the same privacy/trust model from the user's point of view ("do I trust external video hosts?").
- Two toggles create decision fatigue for a feature that's largely "on or off".
- If we later want finer control, splitting later is easier than merging later.
If we ever need YouTube-specific controls (e.g. "block YouTube but allow direct video" for a super-privacy-conscious user), we can add it then. Don't design for the hypothetical.
### Hook
```ts
// src/owl/hooks/useAllowExternalVideos.ts (one-to-one copy of useAllowExternalImages)
export function useAllowExternalVideos(mx: MatrixClient, room: Room): boolean {
const [externalVideos] = useSetting(settingsAtom, 'externalVideos');
return useMemo(() => {
if (externalVideos === 'always') return true;
if (externalVideos === 'never') return false;
return room.roomId.split(':')[1] === mx.getDomain();
}, [externalVideos, room.roomId, mx]);
}
```
### Dispatch in `RenderMessageContent.tsx`
The current branch (lines 63-84):
```ts
const imageUrls = filteredUrls.filter(isImageUrl);
const otherUrls = filteredUrls.filter((url) => !isImageUrl(url));
```
Becomes:
```ts
const imageUrls = filteredUrls.filter(isImageUrl);
const youtubeUrls = filteredUrls.filter((u) => !isImageUrl(u) && getYouTubeId(u));
const videoUrls = filteredUrls.filter((u) => !isImageUrl(u) && !getYouTubeId(u) && isVideoUrl(u));
const otherUrls = filteredUrls.filter(
(u) => !isImageUrl(u) && !getYouTubeId(u) && !isVideoUrl(u)
);
```
And render each list with its card. **Gating**: `RenderMessageContent` doesn't currently take `allowExternalVideos` as a prop — we'd either add one (mirroring the eventual external-images gating if/when it lands here), or gate inside the new card component by reading the hook. Reading inside the card is simpler but requires the card to know its room context. Passing a prop down from `RoomTimeline` is cleaner and matches how `urlPreview` already flows. **Recommendation: prop.**
The prop flow would be:
- `RoomTimeline.tsx:451` — already calls `useAllowExternalImages(mx, room)`. Add a sibling `useAllowExternalVideos(mx, room)`.
- `RoomTimeline.tsx` — pass `allowExternalVideos` into `RenderMessageContent` (need to find where `RenderMessageContent` is rendered in the timeline and thread it through; currently `allowExternalImages` goes into `getReactCustomHtmlParser` at line 533, not into `RenderMessageContent`, because images are gated inside HTML parsing. Video gating is different — it gates URL-derived embeds, not parsed HTML — so we pass directly to `RenderMessageContent`).
### Settings UI
In `General.tsx`, copy `SelectExternalImages` (lines 882-954) to `SelectExternalVideos`, and add a `SettingTile` row mirroring lines 1043-1048:
```tsx
<SequenceCard ...>
<SettingTile title="External Videos" after={<SelectExternalVideos />} />
</SequenceCard>
```
---
## Files Touched
| File | Kind | Change size |
|------|------|-------------|
| `src/owl/utils/videoUrl.ts` | NEW | `isVideoUrl`, `getYouTubeId`, `getYouTubeStart` |
| `src/owl/components/YouTubeEmbedCard.tsx` | NEW | Facade + iframe |
| `src/owl/components/YouTubeEmbedCard.css.ts` | NEW | Aspect-ratio, thumbnail, play button |
| `src/owl/components/ExternalVideoCard.tsx` | NEW | `<video>` card |
| `src/owl/components/ExternalVideoCard.css.ts` | NEW | Sizing |
| `src/owl/hooks/useAllowExternalVideos.ts` | NEW | Mirror of images hook |
| `src/app/state/settings.ts` | edit | +2 lines (type + default) |
| `src/app/components/RenderMessageContent.tsx` | edit | ~10 lines: import cards, filter branches, render |
| `src/app/features/room/RoomTimeline.tsx` | edit | ~2 lines: add hook call, pass prop |
| `src/app/features/settings/general/General.tsx` | edit | ~80 lines: copy `SelectExternalImages``SelectExternalVideos`, add tile row |
Upstream surface area: the three `src/app/...` edits. All feature logic (detection, rendering, policy hook, both cards, both CSS files) lives in `src/owl/`.
**Merge risk: LOW.** Pure additive change. No sanitizer or HTML-parser changes. If upstream touches `renderUrlsPreview` in `RenderMessageContent.tsx`, the branch addition is mechanical to re-apply.
---
## Security Summary
| Concern | Mitigation |
|---------|------------|
| YouTube tracking | `youtube-nocookie.com`, facade (no iframe until user click), `referrerPolicy="strict-origin-when-cross-origin"` |
| iframe XSS | YouTube iframe is on a separate origin — no DOM access to our app. No sandbox needed (and it breaks fullscreen). |
| Video host IP leak | Same as external images: single-server threat model, already accepted by owl. Setting defaults to `'never'` for upstream. |
| Video MIME spoofing | Browser validates MIME before playback; `<video>` rejects non-video payloads. `onError` hides the card. |
| CSP | Deployment-side: owl.cx host config must permit `frame-src youtube-nocookie.com`, `img-src i.ytimg.com`, `media-src *` (or allowlist). Flag for deploy. |
| Malicious autoplay | We never autoplay until user clicks. |
---
## Recommendation & Priority
**Build this as one PR**, in this order:
1. `src/owl/utils/videoUrl.ts` + unit-style smoke test (throwaway URLs in dev console is fine — this is a client app, no test infra being set up for this).
2. `ExternalVideoCard` (easier, mostly static markup).
3. `YouTubeEmbedCard` with facade.
4. Settings wiring + UI row.
5. Manual test on owl.cx dev with: YouTube watch link, `youtu.be` short link, link with `?t=` timestamp, `youtube.com/shorts/`, a 9gag mp4, an arbitrary `.webm`, and a broken URL.
Overall risk: LOW. The pattern is copy-paste from external images, the only genuinely new code is the YouTube facade and the setting row.
One thing to explicitly decide with the user before implementing: **single `externalVideos` setting vs. split `youtubeEmbed` + `directVideoEmbed`**. My recommendation is single; flagging it so we don't bikeshed after the PR is up.

7079
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -11,11 +11,52 @@
"start": "vite",
"build": "vite build",
"preview": "vite preview",
"lint": "yarn check:eslint && yarn check:prettier",
"lint": "npm run check:eslint && npm run check:prettier",
"check:eslint": "eslint src/*",
"check:prettier": "prettier --check .",
"fix:prettier": "prettier --write .",
"typecheck": "tsc --noEmit"
"typecheck": "tsc --noEmit",
"prepare": "husky install",
"commit": "git-cz",
"semantic-release": "semantic-release"
},
"lint-staged": {
"*.{ts,tsx,js,jsx}": "eslint",
"*": "prettier --ignore-unknown --write"
},
"config": {
"commitizen": {
"path": "./node_modules/cz-conventional-changelog"
}
},
"release": {
"branches": [
"dev"
],
"plugins": [
"@semantic-release/commit-analyzer",
"@semantic-release/release-notes-generator",
[
"@semantic-release/exec",
{
"prepareCmd": "node scripts/update-version.js ${nextRelease.version}"
}
],
[
"@semantic-release/git",
{
"assets": [
"package.json",
"package-lock.json",
"src/app/features/settings/about/About.tsx",
"src/app/pages/auth/AuthFooter.tsx",
"src/app/pages/client/WelcomePage.tsx"
],
"message": "chore(release): ${nextRelease.version} [skip ci]"
}
],
"@semantic-release/github"
]
},
"keywords": [],
"author": "Ajay Bura",
@@ -82,6 +123,8 @@
"@esbuild-plugins/node-globals-polyfill": "0.2.3",
"@rollup/plugin-inject": "5.0.3",
"@rollup/plugin-wasm": "6.1.1",
"@semantic-release/exec": "7.1.0",
"@semantic-release/git": "10.0.1",
"@types/chroma-js": "3.1.1",
"@types/file-saver": "2.0.5",
"@types/is-hotkey": "0.1.10",
@@ -96,6 +139,7 @@
"@typescript-eslint/parser": "5.46.1",
"@vitejs/plugin-react": "4.2.0",
"buffer": "6.0.3",
"cz-conventional-changelog": "3.3.0",
"eslint": "8.29.0",
"eslint-config-airbnb": "19.0.4",
"eslint-config-prettier": "8.5.0",
@@ -103,7 +147,10 @@
"eslint-plugin-jsx-a11y": "6.6.1",
"eslint-plugin-react": "7.31.11",
"eslint-plugin-react-hooks": "4.6.0",
"husky": "9.1.7",
"lint-staged": "16.3.2",
"prettier": "2.8.1",
"semantic-release": "25.0.3",
"typescript": "4.9.4",
"vite": "5.4.19",
"vite-plugin-pwa": "0.20.5",

48
scripts/update-version.js Normal file
View File

@@ -0,0 +1,48 @@
import fs from "fs";
import path from "path";
import { execSync } from "child_process";
import { fileURLToPath } from "url";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const version = process.argv[2];
if (!version) {
console.error("Version argument missing");
process.exit(1);
}
const root = path.resolve(__dirname, "..");
const newVersionTag = `v${version}`;
// Update package.json + package-lock.json safely
execSync(`npm version ${version} --no-git-tag-version`, {
cwd: root,
stdio: "inherit",
});
console.log(`Updated package.json and package-lock.json → ${version}`);
// Update UI version references
const files = [
"src/app/features/settings/about/About.tsx",
"src/app/pages/auth/AuthFooter.tsx",
"src/app/pages/client/WelcomePage.tsx",
];
files.forEach((filePath) => {
const absPath = path.join(root, filePath);
if (!fs.existsSync(absPath)) {
console.warn(`File not found: ${filePath}`);
return;
}
const content = fs.readFileSync(absPath, "utf8");
const updated = content.replace(/v\d+\.\d+\.\d+/g, newVersionTag);
fs.writeFileSync(absPath, updated);
console.log(`Updated ${filePath}${newVersionTag}`);
});

View File

@@ -0,0 +1,45 @@
import FocusTrap from 'focus-trap-react';
import { as, Modal, Overlay, OverlayBackdrop, OverlayCenter } from 'folds';
import React, { ReactNode } from 'react';
import { ModalWide } from '../styles/Modal.css';
import { stopPropagation } from '../utils/keyboard';
export type RenderViewerProps = {
src: string;
alt: string;
requestClose: () => void;
};
type ImageOverlayProps = RenderViewerProps & {
viewer: boolean;
renderViewer: (props: RenderViewerProps) => ReactNode;
};
export const ImageOverlay = as<'div', ImageOverlayProps>(
({ src, alt, viewer, requestClose, renderViewer, ...props }, ref) => (
<Overlay {...props} ref={ref} open={viewer} backdrop={<OverlayBackdrop />}>
<OverlayCenter>
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: () => requestClose(),
clickOutsideDeactivates: true,
escapeDeactivates: stopPropagation,
}}
>
<Modal
className={ModalWide}
size="500"
onContextMenu={(evt: any) => evt.stopPropagation()}
>
{renderViewer({
src,
alt,
requestClose,
})}
</Modal>
</FocusTrap>
</OverlayCenter>
</Overlay>
)
);

View File

@@ -25,6 +25,11 @@ import {
VideoContent,
} from './message';
import { UrlPreviewCard, UrlPreviewHolder } from './url-preview';
import { ExternalImageCard } from '../../owl/components/ExternalImageCard';
import { ExternalVideoCard } from '../../owl/components/ExternalVideoCard';
import { YouTubeEmbedCard } from '../../owl/components/YouTubeEmbedCard';
import { isImageUrl } from '../../owl/utils/imageUrl';
import { getYouTubeId, isVideoUrl } from '../../owl/utils/videoUrl';
import { Image, MediaControl, Video } from './media';
import { ImageViewer } from './image-viewer';
import { PdfViewer } from './Pdf-viewer';
@@ -40,6 +45,7 @@ type RenderMessageContentProps = {
getContent: <T>() => T;
mediaAutoLoad?: boolean;
urlPreview?: boolean;
allowExternalVideos?: boolean;
highlightRegex?: RegExp;
htmlReactParserOptions: HTMLReactParserOptions;
linkifyOpts: Opts;
@@ -53,6 +59,7 @@ export function RenderMessageContent({
getContent,
mediaAutoLoad,
urlPreview,
allowExternalVideos,
highlightRegex,
htmlReactParserOptions,
linkifyOpts,
@@ -61,12 +68,38 @@ export function RenderMessageContent({
const renderUrlsPreview = (urls: string[]) => {
const filteredUrls = urls.filter((url) => !testMatrixTo(url));
if (filteredUrls.length === 0) return undefined;
const imageUrls = filteredUrls.filter(isImageUrl);
const nonImage = filteredUrls.filter((url) => !isImageUrl(url));
const youtubeUrls = allowExternalVideos
? nonImage.filter((url) => getYouTubeId(url) !== null)
: [];
const videoUrls = allowExternalVideos
? nonImage.filter((url) => getYouTubeId(url) === null && isVideoUrl(url))
: [];
const otherUrls = nonImage.filter(
(url) => !youtubeUrls.includes(url) && !videoUrls.includes(url)
);
return (
<UrlPreviewHolder>
{filteredUrls.map((url) => (
<UrlPreviewCard key={url} url={url} ts={ts} />
<>
{imageUrls.map((url) => (
<ExternalImageCard key={url} url={url} />
))}
</UrlPreviewHolder>
{youtubeUrls.map((url) => (
<YouTubeEmbedCard key={url} url={url} />
))}
{videoUrls.map((url) => (
<ExternalVideoCard key={url} url={url} />
))}
{otherUrls.length > 0 && (
<UrlPreviewHolder>
{otherUrls.map((url) => (
<UrlPreviewCard key={url} url={url} ts={ts} />
))}
</UrlPreviewHolder>
)}
</>
);
};
const renderCaption = () => {

View File

@@ -228,9 +228,13 @@ const parseCodeBlockNode = (node: Element): CodeBlockElement[] | ParagraphElemen
children: [{ text }],
}));
const childCode = node.children[0];
const className =
isTag(childCode) && childCode.tagName === 'code' ? childCode.attribs.class ?? '' : '';
const prefix = { text: `${mdSequence}${className.replace('language-', '')}` };
const attribs =
isTag(childCode) && childCode.tagName === 'code' ? childCode.attribs : undefined;
const languageClass = attribs?.class;
const customLabel = attribs?.['data-label'];
const prefix = {
text: `${mdSequence}${customLabel ?? languageClass?.replace('language-', '') ?? ''}`,
};
const suffix = { text: mdSequence };
return [
{ type: BlockType.Paragraph, children: [prefix] },

View File

@@ -23,6 +23,11 @@ export const UrlPreviewImg = style([
objectPosition: 'center',
flexShrink: 0,
overflow: 'hidden',
cursor: 'pointer',
':hover': {
filter: 'brightness(0.8)',
},
},
]);

View File

@@ -1,6 +1,7 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { IPreviewUrlResponse } from 'matrix-js-sdk';
import { Box, Icon, IconButton, Icons, Scroll, Spinner, Text, as, color, config } from 'folds';
import { ImageOverlay } from '../ImageOverlay';
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { UrlPreview, UrlPreviewContent, UrlPreviewDescription, UrlPreviewImg } from './UrlPreview';
@@ -12,6 +13,8 @@ import * as css from './UrlPreviewCard.css';
import { tryDecodeURIComponent } from '../../utils/dom';
import { mxcUrlToHttp } from '../../utils/matrix';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import { ImageViewer } from '../image-viewer';
import { onEnterOrSpace } from '../../utils/keyboard';
const linkStyles = { color: color.Success.Main };
@@ -19,6 +22,7 @@ export const UrlPreviewCard = as<'div', { url: string; ts: number }>(
({ url, ts, ...props }, ref) => {
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const [viewer, setViewer] = useState(false);
const [previewStatus, loadPreview] = useAsyncCallback(
useCallback(() => mx.getUrlPreview(url, ts), [url, ts, mx])
);
@@ -30,7 +34,7 @@ export const UrlPreviewCard = as<'div', { url: string; ts: number }>(
if (previewStatus.status === AsyncStatus.Error) return null;
const renderContent = (prev: IPreviewUrlResponse) => {
const imgUrl = mxcUrlToHttp(
const thumbUrl = mxcUrlToHttp(
mx,
prev['og:image'] || '',
useAuthentication,
@@ -40,9 +44,31 @@ export const UrlPreviewCard = as<'div', { url: string; ts: number }>(
false
);
const imgUrl = mxcUrlToHttp(mx, prev['og:image'] || '', useAuthentication);
return (
<>
{imgUrl && <UrlPreviewImg src={imgUrl} alt={prev['og:title']} title={prev['og:title']} />}
{thumbUrl && (
<UrlPreviewImg
src={thumbUrl}
alt={prev['og:title']}
title={prev['og:title']}
tabIndex={0}
onKeyDown={(evt) => onEnterOrSpace(() => setViewer(true))(evt)}
onClick={() => setViewer(true)}
/>
)}
{imgUrl && (
<ImageOverlay
src={imgUrl}
alt={prev['og:title']}
viewer={viewer}
requestClose={() => {
setViewer(false);
}}
renderViewer={(p) => <ImageViewer {...p} />}
/>
)}
<UrlPreviewContent>
<Text
style={linkStyles}

View File

@@ -5,6 +5,7 @@ import { HTMLReactParserOptions } from 'html-react-parser';
import { Avatar, Box, Chip, Header, Icon, Icons, Text, config } from 'folds';
import { Opts as LinkifyOpts } from 'linkifyjs';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useAllowExternalImages } from '../../../owl/hooks/useAllowExternalImages';
import {
factoryRenderLinkifyWithMention,
getReactCustomHtmlParser,
@@ -76,6 +77,7 @@ export function SearchResultGroup({
}: SearchResultGroupProps) {
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const allowExternalImages = useAllowExternalImages(mx, room);
const highlightRegex = useMemo(() => makeHighlightRegex(highlights), [highlights]);
const powerLevels = usePowerLevels(room);
@@ -106,6 +108,7 @@ export function SearchResultGroup({
linkifyOpts,
highlightRegex,
useAuthentication,
allowExternalImages,
handleSpoilerClick: spoilerClickHandler,
handleMentionClick: mentionClickHandler,
}),
@@ -117,6 +120,7 @@ export function SearchResultGroup({
mentionClickHandler,
spoilerClickHandler,
useAuthentication,
allowExternalImages,
]
);

View File

@@ -116,6 +116,8 @@ import { useMentionClickHandler } from '../../hooks/useMentionClickHandler';
import { useSpoilerClickHandler } from '../../hooks/useSpoilerClickHandler';
import { useRoomNavigate } from '../../hooks/useRoomNavigate';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import { useAllowExternalImages } from '../../../owl/hooks/useAllowExternalImages';
import { useAllowExternalVideos } from '../../../owl/hooks/useAllowExternalVideos';
import { useIgnoredUsers } from '../../hooks/useIgnoredUsers';
import { useImagePackRooms } from '../../hooks/useImagePackRooms';
import { useIsDirectRoom } from '../../hooks/useRoom';
@@ -447,6 +449,8 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
const showUrlPreview = room.hasEncryptionStateEvent() ? encUrlPreview : urlPreview;
const [showHiddenEvents] = useSetting(settingsAtom, 'showHiddenEvents');
const [showDeveloperTools] = useSetting(settingsAtom, 'developerTools');
const allowExternalImages = useAllowExternalImages(mx, room);
const allowExternalVideos = useAllowExternalVideos(mx, room);
const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock');
const [dateFormatString] = useSetting(settingsAtom, 'dateFormatString');
@@ -528,10 +532,11 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
getReactCustomHtmlParser(mx, room.roomId, {
linkifyOpts,
useAuthentication,
allowExternalImages,
handleSpoilerClick: spoilerClickHandler,
handleMentionClick: mentionClickHandler,
}),
[mx, room, linkifyOpts, spoilerClickHandler, mentionClickHandler, useAuthentication]
[mx, room, linkifyOpts, spoilerClickHandler, mentionClickHandler, useAuthentication, allowExternalImages]
);
const parseMemberEvent = useMemberEventParser();
@@ -1104,6 +1109,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
getContent={getContent}
mediaAutoLoad={mediaAutoLoad}
urlPreview={showUrlPreview}
allowExternalVideos={allowExternalVideos}
htmlReactParserOptions={htmlReactParserOptions}
linkifyOpts={linkifyOpts}
outlineAttachment={messageLayout === MessageLayout.Bubble}
@@ -1210,6 +1216,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
getContent={getContent}
mediaAutoLoad={mediaAutoLoad}
urlPreview={showUrlPreview}
allowExternalVideos={allowExternalVideos}
htmlReactParserOptions={htmlReactParserOptions}
linkifyOpts={linkifyOpts}
outlineAttachment={messageLayout === MessageLayout.Bubble}

View File

@@ -79,6 +79,7 @@ import { MemberPowerTag, StateEvent } from '../../../../types/matrix/room';
import { PowerIcon } from '../../../components/power';
import colorMXID from '../../../../util/colorMXID';
import { getPowerTagIconSrc } from '../../../hooks/useMemberPowerTag';
import { timeHourMinute } from '../../../utils/time';
export type ReactionHandler = (keyOrMxc: string, shortcode: string) => void;
@@ -813,6 +814,15 @@ export const Message = as<'div', MessageProps>(
</AvatarBase>
);
const visibleReactions = relations?.getSortedAnnotationsByKey();
const hasVisibleReactions = !!visibleReactions && visibleReactions.some(
([key, events]) => typeof key === 'string' && Array.from(events).length > 0
);
const handleInlineAddReaction: MouseEventHandler<HTMLButtonElement> = (evt) => {
setEmojiBoardAnchor(evt.currentTarget.getBoundingClientRect());
};
const msgContentJSX = (
<Box direction="Column" alignSelf="Start" style={{ maxWidth: '100%' }}>
{reply}
@@ -831,7 +841,11 @@ export const Message = as<'div', MessageProps>(
) : (
children
)}
{reactions}
{reactions && React.isValidElement(reactions)
? React.cloneElement(reactions as React.ReactElement<any>, {
onAddReaction: handleInlineAddReaction,
})
: reactions}
</Box>
);
@@ -1129,6 +1143,17 @@ export const Message = as<'div', MessageProps>(
</Menu>
</div>
)}
{!edit && collapse && messageLayout !== MessageLayout.Compact
&& (hover || !!emojiBoardAnchor) && (
<Text
className={css.CollapsedTime}
as="time"
size="T200"
priority="300"
>
{timeHourMinute(mEvent.getTs(), hour24Clock)}
</Text>
)}
{messageLayout === MessageLayout.Compact && (
<CompactLayout before={headerJSX} onContextMenu={handleContextMenu}>
{msgContentJSX}

View File

@@ -23,6 +23,7 @@ import * as css from './styles.css';
import { ReactionViewer } from '../reaction-viewer';
import { stopPropagation } from '../../../utils/keyboard';
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
import { AddReactionButton } from '../../../../owl/components/AddReactionButton';
export type ReactionsProps = {
room: Room;
@@ -30,9 +31,10 @@ export type ReactionsProps = {
canSendReaction?: boolean;
relations: Relations;
onReactionToggle: (targetEventId: string, key: string, shortcode?: string) => void;
onAddReaction?: MouseEventHandler<HTMLButtonElement>;
};
export const Reactions = as<'div', ReactionsProps>(
({ className, room, relations, mEventId, canSendReaction, onReactionToggle, ...props }, ref) => {
({ className, room, relations, mEventId, canSendReaction, onReactionToggle, onAddReaction, ...props }, ref) => {
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const [viewer, setViewer] = useState<boolean | string>(false);
@@ -41,6 +43,9 @@ export const Reactions = as<'div', ReactionsProps>(
relations,
useCallback((rel) => [...(rel.getSortedAnnotationsByKey() ?? [])], [])
);
const visibleCount = reactions.filter(
([key, events]) => typeof key === 'string' && Array.from(events).length > 0
).length;
const handleViewReaction: MouseEventHandler<HTMLButtonElement> = (evt) => {
evt.stopPropagation();
@@ -94,7 +99,10 @@ export const Reactions = as<'div', ReactionsProps>(
</TooltipProvider>
);
})}
{reactions.length > 0 && (
{visibleCount > 0 && canSendReaction && onAddReaction && (
<AddReactionButton onClick={onAddReaction} />
)}
{visibleCount > 0 && (
<Overlay
onContextMenu={(evt: any) => {
evt.stopPropagation();

View File

@@ -55,3 +55,13 @@ export const ReactionsContainer = style({
export const ReactionsTooltipText = style({
wordBreak: 'break-word',
});
export const CollapsedTime = style({
position: 'absolute',
left: config.space.S400,
top: '50%',
transform: 'translateY(-50%)',
opacity: 0.6,
userSelect: 'none',
pointerEvents: 'none',
});

View File

@@ -63,6 +63,7 @@ import { RenderMatrixEvent, useMatrixEventRenderer } from '../../../hooks/useMat
import { RenderMessageContent } from '../../../components/RenderMessageContent';
import { useSetting } from '../../../state/hooks/settings';
import { settingsAtom } from '../../../state/settings';
import { useAllowExternalImages } from '../../../../owl/hooks/useAllowExternalImages';
import * as customHtmlCss from '../../../styles/CustomHtml.css';
import { EncryptedContent } from '../message';
import { Image } from '../../../components/media';
@@ -273,6 +274,7 @@ export const RoomPinMenu = forwardRef<HTMLDivElement, RoomPinMenuProps>(
const useAuthentication = useMediaAuthentication();
const [mediaAutoLoad] = useSetting(settingsAtom, 'mediaAutoLoad');
const [urlPreview] = useSetting(settingsAtom, 'urlPreview');
const allowExternalImages = useAllowExternalImages(mx, room);
const direct = useIsDirectRoom();
const [legacyUsernameColor] = useSetting(settingsAtom, 'legacyUsernameColor');
@@ -307,10 +309,11 @@ export const RoomPinMenu = forwardRef<HTMLDivElement, RoomPinMenuProps>(
getReactCustomHtmlParser(mx, room.roomId, {
linkifyOpts,
useAuthentication,
allowExternalImages,
handleSpoilerClick: spoilerClickHandler,
handleMentionClick: mentionClickHandler,
}),
[mx, room, linkifyOpts, mentionClickHandler, spoilerClickHandler, useAuthentication]
[mx, room, linkifyOpts, mentionClickHandler, spoilerClickHandler, useAuthentication, allowExternalImages]
);
const renderMatrixEvent = useMatrixEventRenderer<[MatrixEvent, string, GetContentCallback]>(

View File

@@ -286,7 +286,7 @@ export function Search({ requestClose }: SearchProps) {
gap="100"
>
<Text size="H6" align="Center">
{result ? 'No Match Found' : `No Rooms'}`}
{result ? 'No Match Found' : 'No Rooms'}
</Text>
<Text size="T200" align="Center">
{result

View File

@@ -32,7 +32,7 @@ import FocusTrap from 'focus-trap-react';
import { Page, PageContent, PageHeader } from '../../../components/page';
import { SequenceCard } from '../../../components/sequence-card';
import { useSetting } from '../../../state/hooks/settings';
import { DateFormat, MessageLayout, MessageSpacing, settingsAtom } from '../../../state/settings';
import { DateFormat, ExternalImageMode, ExternalVideoMode, MessageLayout, MessageSpacing, settingsAtom } from '../../../state/settings';
import { SettingTile } from '../../../components/setting-tile';
import { KeySymbol } from '../../../utils/key-symbol';
import { isMacOS } from '../../../utils/user-agent';
@@ -879,6 +879,154 @@ function SelectMessageSpacing() {
);
}
const externalImageItems: { mode: ExternalImageMode; name: string }[] = [
{ mode: 'never', name: 'Never' },
{ mode: 'homeserver', name: 'Homeserver Only' },
{ mode: 'always', name: 'Always' },
];
function SelectExternalImages() {
const [menuCords, setMenuCords] = useState<RectCords>();
const [externalImages, setExternalImages] = useSetting(settingsAtom, 'externalImages');
const handleMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
setMenuCords(evt.currentTarget.getBoundingClientRect());
};
const handleSelect = (mode: ExternalImageMode) => {
setExternalImages(mode);
setMenuCords(undefined);
};
return (
<>
<Button
size="300"
variant="Secondary"
outlined
fill="Soft"
radii="300"
after={<Icon size="300" src={Icons.ChevronBottom} />}
onClick={handleMenu}
>
<Text size="T300">
{externalImageItems.find((i) => i.mode === externalImages)?.name ?? externalImages}
</Text>
</Button>
<PopOut
anchor={menuCords}
offset={5}
position="Bottom"
align="End"
content={
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: () => setMenuCords(undefined),
clickOutsideDeactivates: true,
isKeyForward: (evt: KeyboardEvent) =>
evt.key === 'ArrowDown' || evt.key === 'ArrowRight',
isKeyBackward: (evt: KeyboardEvent) =>
evt.key === 'ArrowUp' || evt.key === 'ArrowLeft',
escapeDeactivates: stopPropagation,
}}
>
<Menu>
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
{externalImageItems.map((item) => (
<MenuItem
key={item.mode}
size="300"
variant={externalImages === item.mode ? 'Primary' : 'Surface'}
radii="300"
onClick={() => handleSelect(item.mode)}
>
<Text size="T300">{item.name}</Text>
</MenuItem>
))}
</Box>
</Menu>
</FocusTrap>
}
/>
</>
);
}
const externalVideoItems: { mode: ExternalVideoMode; name: string }[] = [
{ mode: 'never', name: 'Never' },
{ mode: 'homeserver', name: 'Homeserver Only' },
{ mode: 'always', name: 'Always' },
];
function SelectExternalVideos() {
const [menuCords, setMenuCords] = useState<RectCords>();
const [externalVideos, setExternalVideos] = useSetting(settingsAtom, 'externalVideos');
const handleMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
setMenuCords(evt.currentTarget.getBoundingClientRect());
};
const handleSelect = (mode: ExternalVideoMode) => {
setExternalVideos(mode);
setMenuCords(undefined);
};
return (
<>
<Button
size="300"
variant="Secondary"
outlined
fill="Soft"
radii="300"
after={<Icon size="300" src={Icons.ChevronBottom} />}
onClick={handleMenu}
>
<Text size="T300">
{externalVideoItems.find((i) => i.mode === externalVideos)?.name ?? externalVideos}
</Text>
</Button>
<PopOut
anchor={menuCords}
offset={5}
position="Bottom"
align="End"
content={
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: () => setMenuCords(undefined),
clickOutsideDeactivates: true,
isKeyForward: (evt: KeyboardEvent) =>
evt.key === 'ArrowDown' || evt.key === 'ArrowRight',
isKeyBackward: (evt: KeyboardEvent) =>
evt.key === 'ArrowUp' || evt.key === 'ArrowLeft',
escapeDeactivates: stopPropagation,
}}
>
<Menu>
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
{externalVideoItems.map((item) => (
<MenuItem
key={item.mode}
size="300"
variant={externalVideos === item.mode ? 'Primary' : 'Surface'}
radii="300"
onClick={() => handleSelect(item.mode)}
>
<Text size="T300">{item.name}</Text>
</MenuItem>
))}
</Box>
</Menu>
</FocusTrap>
}
/>
</>
);
}
function Messages() {
const [legacyUsernameColor, setLegacyUsernameColor] = useSetting(
settingsAtom,
@@ -966,6 +1114,19 @@ function Messages() {
after={<Switch variant="Primary" value={encUrlPreview} onChange={setEncUrlPreview} />}
/>
</SequenceCard>
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
<SettingTile
title="External Images"
after={<SelectExternalImages />}
/>
</SequenceCard>
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
<SettingTile
title="External Videos"
description="Embed YouTube links and play direct video URLs (mp4, webm) inline."
after={<SelectExternalVideos />}
/>
</SequenceCard>
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
<SettingTile
title="Show Hidden Events"

View File

@@ -18,6 +18,8 @@ export type ClientConfig = {
};
hashRouter?: HashRouterConfig;
defaultSettings?: Record<string, unknown>;
};
const ClientConfigContext = createContext<ClientConfig | null>(null);

View File

@@ -25,6 +25,10 @@ export const useDateFormatItems = (): DateFormatItem[] =>
format: 'YYYY/MM/DD',
name: 'YYYY/MM/DD',
},
{
format: 'YYYY-MM-DD',
name: 'YYYY-MM-DD',
},
{
format: '',
name: 'Custom',

View File

@@ -12,6 +12,7 @@ import { FeatureCheck } from './FeatureCheck';
import { createRouter } from './Router';
import { ScreenSizeProvider, useScreenSize } from '../hooks/useScreenSize';
import { useCompositionEndTracking } from '../hooks/useComposingCheck';
import { ConfigSettingsApply } from '../../owl/components/ConfigSettingsApply';
const queryClient = new QueryClient();
@@ -37,6 +38,7 @@ function App() {
<ClientConfigProvider value={clientConfig}>
<QueryClientProvider client={queryClient}>
<JotaiProvider>
<ConfigSettingsApply />
<RouterProvider router={createRouter(clientConfig, screenSize)} />
</JotaiProvider>
<ReactQueryDevtools initialIsOpen={false} />

View File

@@ -22,7 +22,7 @@ export function SpecVersions({ baseUrl, children }: { baseUrl: string; children:
<Dialog>
<Box direction="Column" gap="400" style={{ padding: config.space.S400 }}>
<Text>
Failed to connect to homeserver. Either homeserver is down or your internet.
Unable to connect to the homeserver. The homeserver or your internet connection may be down.
</Text>
<Button variant="Critical" onClick={retry}>
<Text as="span" size="B400">

View File

@@ -65,6 +65,7 @@ import {
import { RenderMessageContent } from '../../../components/RenderMessageContent';
import { useSetting } from '../../../state/hooks/settings';
import { settingsAtom } from '../../../state/settings';
import { useAllowExternalImages } from '../../../../owl/hooks/useAllowExternalImages';
import { Image } from '../../../components/media';
import { ImageViewer } from '../../../components/image-viewer';
import { GetContentCallback, MessageEvent, StateEvent } from '../../../../types/matrix/room';
@@ -236,6 +237,7 @@ function RoomNotificationsGroupComp({
const theme = useTheme();
const accessibleTagColors = useAccessiblePowerTagColors(theme.kind, creatorsTag, powerLevelTags);
const allowExternalImages = useAllowExternalImages(mx, room);
const mentionClickHandler = useMentionClickHandler(room.roomId);
const spoilerClickHandler = useSpoilerClickHandler();
@@ -253,10 +255,11 @@ function RoomNotificationsGroupComp({
getReactCustomHtmlParser(mx, room.roomId, {
linkifyOpts,
useAuthentication,
allowExternalImages,
handleSpoilerClick: spoilerClickHandler,
handleMentionClick: mentionClickHandler,
}),
[mx, room, linkifyOpts, mentionClickHandler, spoilerClickHandler, useAuthentication]
[mx, room, linkifyOpts, mentionClickHandler, spoilerClickHandler, useAuthentication, allowExternalImages]
);
const renderMatrixEvent = useMatrixEventRenderer<[IRoomEvent, string, GetContentCallback]>(

View File

@@ -16,8 +16,12 @@ export const CodeBlockRule: BlockMDRule = {
match: (text) => text.match(CODEBLOCK_REG_1),
html: (match) => {
const [, g1, g2] = match;
const classNameAtt = g1 ? ` class="language-${g1}"` : '';
return `<pre data-md="${CODEBLOCK_MD_1}"><code${classNameAtt}>${g2}</code></pre>`;
// use last identifier after dot, e.g. for "example.json" gets us "json" as language code.
const langCode = g1 ? g1.substring(g1.lastIndexOf('.') + 1) : null;
const filename = g1 !== langCode ? g1 : null;
const classNameAtt = langCode ? ` class="language-${langCode}"` : '';
const filenameAtt = filename ? ` data-label="${filename}"` : '';
return `<pre data-md="${CODEBLOCK_MD_1}"><code${classNameAtt}${filenameAtt}>${g2}</code></pre>`;
},
};

View File

@@ -232,8 +232,9 @@ export function CodeBlock({
opts: HTMLReactParserOptions;
}) {
const code = children[0];
const languageClass =
code instanceof Element && code.name === 'code' ? code.attribs.class : undefined;
const attribs = code instanceof Element && code.name === 'code' ? code.attribs : undefined;
const languageClass = attribs?.class;
const customLabel = attribs?.['data-label'];
const language =
languageClass && languageClass.startsWith('language-')
? languageClass.replace('language-', '')
@@ -262,7 +263,7 @@ export function CodeBlock({
<Header variant="Surface" size="400" className={css.CodeBlockHeader}>
<Box grow="Yes">
<Text size="L400" truncate>
{language ?? 'Code'}
{customLabel ?? language ?? 'Code'}
</Text>
</Box>
<Box shrink="No" gap="200">
@@ -318,6 +319,7 @@ export const getReactCustomHtmlParser = (
handleSpoilerClick?: ReactEventHandler<HTMLElement>;
handleMentionClick?: ReactEventHandler<HTMLElement>;
useAuthentication?: boolean;
allowExternalImages?: boolean;
}
): HTMLReactParserOptions => {
const opts: HTMLReactParserOptions = {
@@ -473,11 +475,15 @@ export const getReactCustomHtmlParser = (
}
if (name === 'img') {
const htmlSrc = mxcUrlToHttp(mx, props.src, params.useAuthentication);
if (htmlSrc && props.src.startsWith('mxc://') === false) {
const isMxc = typeof props.src === 'string' && props.src.startsWith('mxc://');
const isExternal = !isMxc && typeof props.src === 'string';
const htmlSrc = isMxc
? mxcUrlToHttp(mx, props.src, params.useAuthentication)
: props.src;
if (isExternal && !params.allowExternalImages) {
return (
<a href={htmlSrc} target="_blank" rel="noreferrer noopener">
{props.alt || props.title || htmlSrc}
<a href={props.src} target="_blank" rel="noreferrer noopener">
{props.alt || props.title || props.src}
</a>
);
}

View File

@@ -1,7 +1,13 @@
import { atom } from 'jotai';
const STORAGE_KEY = 'settings';
export type DateFormat = 'D MMM YYYY' | 'DD/MM/YYYY' | 'MM/DD/YYYY' | 'YYYY/MM/DD' | '';
export type DateFormat =
| 'D MMM YYYY'
| 'DD/MM/YYYY'
| 'MM/DD/YYYY'
| 'YYYY/MM/DD'
| 'YYYY-MM-DD'
| '';
export type MessageSpacing = '0' | '100' | '200' | '300' | '400' | '500';
export enum MessageLayout {
Modern = 0,
@@ -9,6 +15,9 @@ export enum MessageLayout {
Bubble = 2,
}
export type ExternalImageMode = 'always' | 'homeserver' | 'never';
export type ExternalVideoMode = 'always' | 'homeserver' | 'never';
export interface Settings {
themeId?: string;
useSystemTheme: boolean;
@@ -33,6 +42,8 @@ export interface Settings {
encUrlPreview: boolean;
showHiddenEvents: boolean;
legacyUsernameColor: boolean;
externalImages: ExternalImageMode;
externalVideos: ExternalVideoMode;
showNotifications: boolean;
isNotificationSounds: boolean;
@@ -67,6 +78,8 @@ const defaultSettings: Settings = {
encUrlPreview: false,
showHiddenEvents: false,
legacyUsernameColor: false,
externalImages: 'never',
externalVideos: 'never',
showNotifications: true,
isNotificationSounds: true,
@@ -77,11 +90,12 @@ const defaultSettings: Settings = {
developerTools: false,
};
export const getSettings = () => {
export const getSettings = (configDefaults?: Partial<Settings>) => {
const settings = localStorage.getItem(STORAGE_KEY);
if (settings === null) return defaultSettings;
if (settings === null) return { ...defaultSettings, ...configDefaults };
return {
...defaultSettings,
...configDefaults,
...(JSON.parse(settings) as Settings),
};
};

View File

@@ -71,7 +71,7 @@ const permittedTagToAttributes = {
ul: ['data-md'],
a: ['name', 'target', 'href', 'rel', 'data-md'],
img: ['width', 'height', 'alt', 'title', 'src', 'data-mx-emoticon'],
code: ['class', 'data-md'],
code: ['class', 'data-md', 'data-label'],
strong: ['data-md'],
i: ['data-md'],
em: ['data-md'],
@@ -105,26 +105,12 @@ const transformATag: Transformer = (tagName, attribs) => ({
},
});
const transformImgTag: Transformer = (tagName, attribs) => {
const { src } = attribs;
if (typeof src === 'string' && src.startsWith('mxc://') === false) {
return {
tagName: 'a',
attribs: {
href: src,
rel: 'noreferrer noopener',
target: '_blank',
},
text: attribs.alt || src,
};
}
return {
tagName,
attribs: {
...attribs,
},
};
};
const transformImgTag: Transformer = (tagName, attribs) => ({
tagName,
attribs: {
...attribs,
},
});
export const sanitizeCustomHtml = (customHtml: string): string =>
sanitizeHtml(customHtml, {

View File

@@ -0,0 +1,22 @@
import React, { MouseEventHandler } from 'react';
import { Box, Icon, Icons } from 'folds';
import * as css from '../styles/reactions.css';
type AddReactionButtonProps = {
onClick: MouseEventHandler<HTMLButtonElement>;
};
export function AddReactionButton({ onClick }: AddReactionButtonProps) {
return (
<Box
as="button"
className={css.AddReactionChip}
alignItems="Center"
justifyContent="Center"
shrink="No"
onClick={onClick}
>
<Icon src={Icons.SmilePlus} size="50" />
</Box>
);
}

View File

@@ -0,0 +1,18 @@
import { useEffect, useRef } from 'react';
import { useAtom } from 'jotai';
import { useClientConfig } from '../../app/hooks/useClientConfig';
import { settingsAtom, getSettings, Settings } from '../../app/state/settings';
export function ConfigSettingsApply() {
const { defaultSettings: configDefaults } = useClientConfig();
const [, setSettings] = useAtom(settingsAtom);
const applied = useRef(false);
useEffect(() => {
if (applied.current || !configDefaults) return;
applied.current = true;
setSettings(getSettings(configDefaults as Partial<Settings>));
}, [configDefaults, setSettings]);
return null;
}

View File

@@ -0,0 +1,28 @@
import { style } from '@vanilla-extract/css';
import { DefaultReset, color, config, toRem } from 'folds';
export const ExternalImageCard = style([
DefaultReset,
{
display: 'inline-block',
maxWidth: toRem(400),
borderRadius: config.radii.R300,
overflow: 'hidden',
border: `${config.borderWidth.B300} solid ${color.SurfaceVariant.ContainerLine}`,
cursor: 'pointer',
':hover': {
filter: 'brightness(0.95)',
},
},
]);
export const ExternalImage = style([
DefaultReset,
{
display: 'block',
maxWidth: '100%',
maxHeight: toRem(400),
objectFit: 'contain',
},
]);

View File

@@ -0,0 +1,57 @@
import React, { useState } from 'react';
import { Box, Text, as, color } from 'folds';
import { ImageOverlay } from '../../app/components/ImageOverlay';
import { ImageViewer } from '../../app/components/image-viewer';
import { onEnterOrSpace } from '../../app/utils/keyboard';
import * as css from './ExternalImageCard.css';
import { tryDecodeURIComponent } from '../../app/utils/dom';
const linkStyles = { color: color.Success.Main };
export const ExternalImageCard = as<'div', { url: string }>(({ url, ...props }, ref) => {
const [viewer, setViewer] = useState(false);
const [error, setError] = useState(false);
if (error) return null;
const filename = url.split('/').pop()?.split('?')[0] || url;
return (
<Box direction="Column" gap="100" {...props} ref={ref}>
<div
className={css.ExternalImageCard}
role="button"
tabIndex={0}
onKeyDown={(evt) => onEnterOrSpace(() => setViewer(true))(evt)}
onClick={() => setViewer(true)}
>
<img
className={css.ExternalImage}
src={url}
alt={filename}
loading="lazy"
onError={() => setError(true)}
/>
</div>
<Text
style={linkStyles}
truncate
as="a"
href={url}
target="_blank"
rel="noreferrer"
size="T200"
priority="300"
>
{tryDecodeURIComponent(url)}
</Text>
<ImageOverlay
src={url}
alt={filename}
viewer={viewer}
requestClose={() => setViewer(false)}
renderViewer={(p) => <ImageViewer {...p} />}
/>
</Box>
);
});

View File

@@ -0,0 +1,24 @@
import { style } from '@vanilla-extract/css';
import { DefaultReset, color, config, toRem } from 'folds';
export const ExternalVideoCard = style([
DefaultReset,
{
display: 'inline-block',
maxWidth: toRem(480),
borderRadius: config.radii.R300,
overflow: 'hidden',
border: `${config.borderWidth.B300} solid ${color.SurfaceVariant.ContainerLine}`,
backgroundColor: color.SurfaceVariant.Container,
},
]);
export const ExternalVideo = style([
DefaultReset,
{
display: 'block',
width: '100%',
maxHeight: toRem(400),
backgroundColor: '#000',
},
]);

View File

@@ -0,0 +1,40 @@
import React, { useState } from 'react';
import { Box, Text, as, color } from 'folds';
import * as css from './ExternalVideoCard.css';
import { tryDecodeURIComponent } from '../../app/utils/dom';
const linkStyles = { color: color.Success.Main };
export const ExternalVideoCard = as<'div', { url: string }>(({ url, ...props }, ref) => {
const [error, setError] = useState(false);
if (error) return null;
return (
<Box direction="Column" gap="100" {...props} ref={ref}>
<div className={css.ExternalVideoCard}>
{/* eslint-disable-next-line jsx-a11y/media-has-caption */}
<video
className={css.ExternalVideo}
src={url}
controls
preload="metadata"
playsInline
onError={() => setError(true)}
/>
</div>
<Text
style={linkStyles}
truncate
as="a"
href={url}
target="_blank"
rel="noreferrer"
size="T200"
priority="300"
>
{tryDecodeURIComponent(url)}
</Text>
</Box>
);
});

View File

@@ -0,0 +1,83 @@
import { style } from '@vanilla-extract/css';
import { DefaultReset, color, config, toRem } from 'folds';
export const YouTubeCard = style([
DefaultReset,
{
display: 'inline-block',
width: '100%',
maxWidth: toRem(480),
borderRadius: config.radii.R300,
overflow: 'hidden',
border: `${config.borderWidth.B300} solid ${color.SurfaceVariant.ContainerLine}`,
backgroundColor: '#000',
},
]);
export const YouTubeFrame = style([
DefaultReset,
{
position: 'relative',
width: '100%',
aspectRatio: '16 / 9',
},
]);
export const YouTubeThumbButton = style([
DefaultReset,
{
position: 'absolute',
inset: 0,
width: '100%',
height: '100%',
padding: 0,
border: 'none',
background: '#000',
cursor: 'pointer',
overflow: 'hidden',
':hover': {
filter: 'brightness(1.05)',
},
},
]);
export const YouTubeThumbImg = style([
DefaultReset,
{
display: 'block',
width: '100%',
height: '100%',
objectFit: 'cover',
},
]);
export const YouTubePlayOverlay = style([
DefaultReset,
{
position: 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
width: toRem(64),
height: toRem(64),
borderRadius: '50%',
backgroundColor: 'rgba(0, 0, 0, 0.7)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: '#fff',
pointerEvents: 'none',
},
]);
export const YouTubeIframe = style([
DefaultReset,
{
position: 'absolute',
inset: 0,
width: '100%',
height: '100%',
border: 'none',
},
]);

View File

@@ -0,0 +1,73 @@
import React, { useState } from 'react';
import { Box, Icon, Icons, Text, as, color } from 'folds';
import * as css from './YouTubeEmbedCard.css';
import { tryDecodeURIComponent } from '../../app/utils/dom';
import { getYouTubeId, getYouTubeStart } from '../utils/videoUrl';
const linkStyles = { color: color.Success.Main };
export const YouTubeEmbedCard = as<'div', { url: string }>(({ url, ...props }, ref) => {
const id = getYouTubeId(url);
const start = getYouTubeStart(url);
const [playing, setPlaying] = useState(false);
const [thumbError, setThumbError] = useState(false);
if (!id) return null;
const embedSrc =
`https://www.youtube-nocookie.com/embed/${encodeURIComponent(id)}` +
`?autoplay=1&rel=0${start ? `&start=${start}` : ''}`;
return (
<Box direction="Column" gap="100" {...props} ref={ref}>
<div className={css.YouTubeCard}>
<div className={css.YouTubeFrame}>
{playing ? (
<iframe
className={css.YouTubeIframe}
src={embedSrc}
title={`YouTube: ${id}`}
loading="lazy"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; fullscreen"
allowFullScreen
referrerPolicy="strict-origin-when-cross-origin"
/>
) : (
<button
type="button"
className={css.YouTubeThumbButton}
onClick={() => setPlaying(true)}
aria-label="Play YouTube video"
>
{!thumbError && (
<img
className={css.YouTubeThumbImg}
src={`https://i.ytimg.com/vi/${encodeURIComponent(id)}/hqdefault.jpg`}
alt=""
loading="lazy"
referrerPolicy="no-referrer"
onError={() => setThumbError(true)}
/>
)}
<span className={css.YouTubePlayOverlay}>
<Icon size="400" src={Icons.Play} filled />
</span>
</button>
)}
</div>
</div>
<Text
style={linkStyles}
truncate
as="a"
href={url}
target="_blank"
rel="noreferrer"
size="T200"
priority="300"
>
{tryDecodeURIComponent(url)}
</Text>
</Box>
);
});

View File

@@ -0,0 +1,14 @@
import { useMemo } from 'react';
import { MatrixClient, Room } from 'matrix-js-sdk';
import { useSetting } from '../../app/state/hooks/settings';
import { settingsAtom } from '../../app/state/settings';
export function useAllowExternalImages(mx: MatrixClient, room: Room): boolean {
const [externalImages] = useSetting(settingsAtom, 'externalImages');
return useMemo(() => {
if (externalImages === 'always') return true;
if (externalImages === 'never') return false;
const roomDomain = room.roomId.split(':')[1];
return roomDomain === mx.getDomain();
}, [externalImages, room.roomId, mx]);
}

View File

@@ -0,0 +1,14 @@
import { useMemo } from 'react';
import { MatrixClient, Room } from 'matrix-js-sdk';
import { useSetting } from '../../app/state/hooks/settings';
import { settingsAtom } from '../../app/state/settings';
export function useAllowExternalVideos(mx: MatrixClient, room: Room): boolean {
const [externalVideos] = useSetting(settingsAtom, 'externalVideos');
return useMemo(() => {
if (externalVideos === 'always') return true;
if (externalVideos === 'never') return false;
const roomDomain = room.roomId.split(':')[1];
return roomDomain === mx.getDomain();
}, [externalVideos, room.roomId, mx]);
}

View File

@@ -0,0 +1,28 @@
import { style } from '@vanilla-extract/css';
import { DefaultReset, FocusOutline, color, config, toRem } from 'folds';
const addReactionBase = {
padding: `${toRem(2)} ${config.space.S200}`,
backgroundColor: color.SurfaceVariant.Container,
border: `${config.borderWidth.B300} solid ${color.SurfaceVariant.ContainerLine}`,
borderRadius: config.radii.R300,
cursor: 'pointer',
selectors: {
'&:hover, &:focus-visible': {
backgroundColor: color.SurfaceVariant.ContainerHover,
opacity: 1,
},
'&:active': {
backgroundColor: color.SurfaceVariant.ContainerActive,
},
},
} as const;
export const AddReactionChip = style([
DefaultReset,
FocusOutline,
{
...addReactionBase,
opacity: 0.6,
},
]);

10
src/owl/utils/imageUrl.ts Normal file
View File

@@ -0,0 +1,10 @@
const IMAGE_EXTENSIONS = ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.avif', '.svg', '.bmp', '.ico'];
export function isImageUrl(url: string): boolean {
try {
const { pathname } = new URL(url);
return IMAGE_EXTENSIONS.some((ext) => pathname.toLowerCase().endsWith(ext));
} catch {
return false;
}
}

46
src/owl/utils/videoUrl.ts Normal file
View File

@@ -0,0 +1,46 @@
const VIDEO_EXTENSIONS = ['.mp4', '.webm', '.ogg', '.ogv', '.mov', '.m4v'];
export function isVideoUrl(url: string): boolean {
try {
const { pathname } = new URL(url);
return VIDEO_EXTENSIONS.some((ext) => pathname.toLowerCase().endsWith(ext));
} catch {
return false;
}
}
export function getYouTubeId(url: string): string | null {
try {
const u = new URL(url);
const host = u.hostname.replace(/^www\.|^m\./, '');
if (host === 'youtu.be') {
const id = u.pathname.slice(1).split('/')[0];
return id || null;
}
if (host === 'youtube.com') {
if (u.pathname === '/watch') return u.searchParams.get('v');
const m = u.pathname.match(/^\/(shorts|embed|v|live)\/([^/]+)/);
if (m) return m[2];
}
return null;
} catch {
return null;
}
}
export function getYouTubeStart(url: string): number | null {
try {
const u = new URL(url);
const raw = u.searchParams.get('t') ?? u.searchParams.get('start');
if (!raw) return null;
if (/^\d+$/.test(raw)) return parseInt(raw, 10);
const m = raw.match(/^(?:(\d+)h)?(?:(\d+)m)?(?:(\d+)s?)?$/);
if (!m) return null;
const total = (parseInt(m[1] || '0', 10)) * 3600
+ (parseInt(m[2] || '0', 10)) * 60
+ (parseInt(m[3] || '0', 10));
return total > 0 ? total : null;
} catch {
return null;
}
}