forked from github/cinny
🦉 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.
This commit is contained in:
@@ -19,3 +19,8 @@ In this owl folder should go all documents we produce for this Endeavour.
|
|||||||
- Find out how we can set a theme by default for new users.
|
- Find out how we can set a theme by default for new users.
|
||||||
- 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.
|
- 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.
|
||||||
- youtube links should create embedded players (if enabled).
|
- youtube links should create embedded players (if enabled).
|
||||||
|
|
||||||
|
|
||||||
|
## Commit Messages
|
||||||
|
|
||||||
|
All Commits for OWL changes start with the Owl Emoji 🦉
|
||||||
|
|||||||
176
owl/REPORT-002-external-images.md
Normal file
176
owl/REPORT-002-external-images.md
Normal 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.
|
||||||
@@ -5,6 +5,7 @@ import { HTMLReactParserOptions } from 'html-react-parser';
|
|||||||
import { Avatar, Box, Chip, Header, Icon, Icons, Text, config } from 'folds';
|
import { Avatar, Box, Chip, Header, Icon, Icons, Text, config } from 'folds';
|
||||||
import { Opts as LinkifyOpts } from 'linkifyjs';
|
import { Opts as LinkifyOpts } from 'linkifyjs';
|
||||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||||
|
import { useAllowExternalImages } from '../../../owl/hooks/useAllowExternalImages';
|
||||||
import {
|
import {
|
||||||
factoryRenderLinkifyWithMention,
|
factoryRenderLinkifyWithMention,
|
||||||
getReactCustomHtmlParser,
|
getReactCustomHtmlParser,
|
||||||
@@ -76,6 +77,7 @@ export function SearchResultGroup({
|
|||||||
}: SearchResultGroupProps) {
|
}: SearchResultGroupProps) {
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
const useAuthentication = useMediaAuthentication();
|
const useAuthentication = useMediaAuthentication();
|
||||||
|
const allowExternalImages = useAllowExternalImages(mx, room);
|
||||||
const highlightRegex = useMemo(() => makeHighlightRegex(highlights), [highlights]);
|
const highlightRegex = useMemo(() => makeHighlightRegex(highlights), [highlights]);
|
||||||
|
|
||||||
const powerLevels = usePowerLevels(room);
|
const powerLevels = usePowerLevels(room);
|
||||||
@@ -106,6 +108,7 @@ export function SearchResultGroup({
|
|||||||
linkifyOpts,
|
linkifyOpts,
|
||||||
highlightRegex,
|
highlightRegex,
|
||||||
useAuthentication,
|
useAuthentication,
|
||||||
|
allowExternalImages,
|
||||||
handleSpoilerClick: spoilerClickHandler,
|
handleSpoilerClick: spoilerClickHandler,
|
||||||
handleMentionClick: mentionClickHandler,
|
handleMentionClick: mentionClickHandler,
|
||||||
}),
|
}),
|
||||||
@@ -117,6 +120,7 @@ export function SearchResultGroup({
|
|||||||
mentionClickHandler,
|
mentionClickHandler,
|
||||||
spoilerClickHandler,
|
spoilerClickHandler,
|
||||||
useAuthentication,
|
useAuthentication,
|
||||||
|
allowExternalImages,
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -116,6 +116,7 @@ import { useMentionClickHandler } from '../../hooks/useMentionClickHandler';
|
|||||||
import { useSpoilerClickHandler } from '../../hooks/useSpoilerClickHandler';
|
import { useSpoilerClickHandler } from '../../hooks/useSpoilerClickHandler';
|
||||||
import { useRoomNavigate } from '../../hooks/useRoomNavigate';
|
import { useRoomNavigate } from '../../hooks/useRoomNavigate';
|
||||||
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
||||||
|
import { useAllowExternalImages } from '../../../owl/hooks/useAllowExternalImages';
|
||||||
import { useIgnoredUsers } from '../../hooks/useIgnoredUsers';
|
import { useIgnoredUsers } from '../../hooks/useIgnoredUsers';
|
||||||
import { useImagePackRooms } from '../../hooks/useImagePackRooms';
|
import { useImagePackRooms } from '../../hooks/useImagePackRooms';
|
||||||
import { useIsDirectRoom } from '../../hooks/useRoom';
|
import { useIsDirectRoom } from '../../hooks/useRoom';
|
||||||
@@ -447,6 +448,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||||||
const showUrlPreview = room.hasEncryptionStateEvent() ? encUrlPreview : urlPreview;
|
const showUrlPreview = room.hasEncryptionStateEvent() ? encUrlPreview : urlPreview;
|
||||||
const [showHiddenEvents] = useSetting(settingsAtom, 'showHiddenEvents');
|
const [showHiddenEvents] = useSetting(settingsAtom, 'showHiddenEvents');
|
||||||
const [showDeveloperTools] = useSetting(settingsAtom, 'developerTools');
|
const [showDeveloperTools] = useSetting(settingsAtom, 'developerTools');
|
||||||
|
const allowExternalImages = useAllowExternalImages(mx, room);
|
||||||
|
|
||||||
const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock');
|
const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock');
|
||||||
const [dateFormatString] = useSetting(settingsAtom, 'dateFormatString');
|
const [dateFormatString] = useSetting(settingsAtom, 'dateFormatString');
|
||||||
@@ -528,10 +530,11 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||||||
getReactCustomHtmlParser(mx, room.roomId, {
|
getReactCustomHtmlParser(mx, room.roomId, {
|
||||||
linkifyOpts,
|
linkifyOpts,
|
||||||
useAuthentication,
|
useAuthentication,
|
||||||
|
allowExternalImages,
|
||||||
handleSpoilerClick: spoilerClickHandler,
|
handleSpoilerClick: spoilerClickHandler,
|
||||||
handleMentionClick: mentionClickHandler,
|
handleMentionClick: mentionClickHandler,
|
||||||
}),
|
}),
|
||||||
[mx, room, linkifyOpts, spoilerClickHandler, mentionClickHandler, useAuthentication]
|
[mx, room, linkifyOpts, spoilerClickHandler, mentionClickHandler, useAuthentication, allowExternalImages]
|
||||||
);
|
);
|
||||||
const parseMemberEvent = useMemberEventParser();
|
const parseMemberEvent = useMemberEventParser();
|
||||||
|
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ import { RenderMatrixEvent, useMatrixEventRenderer } from '../../../hooks/useMat
|
|||||||
import { RenderMessageContent } from '../../../components/RenderMessageContent';
|
import { RenderMessageContent } from '../../../components/RenderMessageContent';
|
||||||
import { useSetting } from '../../../state/hooks/settings';
|
import { useSetting } from '../../../state/hooks/settings';
|
||||||
import { settingsAtom } from '../../../state/settings';
|
import { settingsAtom } from '../../../state/settings';
|
||||||
|
import { useAllowExternalImages } from '../../../../owl/hooks/useAllowExternalImages';
|
||||||
import * as customHtmlCss from '../../../styles/CustomHtml.css';
|
import * as customHtmlCss from '../../../styles/CustomHtml.css';
|
||||||
import { EncryptedContent } from '../message';
|
import { EncryptedContent } from '../message';
|
||||||
import { Image } from '../../../components/media';
|
import { Image } from '../../../components/media';
|
||||||
@@ -273,6 +274,7 @@ export const RoomPinMenu = forwardRef<HTMLDivElement, RoomPinMenuProps>(
|
|||||||
const useAuthentication = useMediaAuthentication();
|
const useAuthentication = useMediaAuthentication();
|
||||||
const [mediaAutoLoad] = useSetting(settingsAtom, 'mediaAutoLoad');
|
const [mediaAutoLoad] = useSetting(settingsAtom, 'mediaAutoLoad');
|
||||||
const [urlPreview] = useSetting(settingsAtom, 'urlPreview');
|
const [urlPreview] = useSetting(settingsAtom, 'urlPreview');
|
||||||
|
const allowExternalImages = useAllowExternalImages(mx, room);
|
||||||
|
|
||||||
const direct = useIsDirectRoom();
|
const direct = useIsDirectRoom();
|
||||||
const [legacyUsernameColor] = useSetting(settingsAtom, 'legacyUsernameColor');
|
const [legacyUsernameColor] = useSetting(settingsAtom, 'legacyUsernameColor');
|
||||||
@@ -307,10 +309,11 @@ export const RoomPinMenu = forwardRef<HTMLDivElement, RoomPinMenuProps>(
|
|||||||
getReactCustomHtmlParser(mx, room.roomId, {
|
getReactCustomHtmlParser(mx, room.roomId, {
|
||||||
linkifyOpts,
|
linkifyOpts,
|
||||||
useAuthentication,
|
useAuthentication,
|
||||||
|
allowExternalImages,
|
||||||
handleSpoilerClick: spoilerClickHandler,
|
handleSpoilerClick: spoilerClickHandler,
|
||||||
handleMentionClick: mentionClickHandler,
|
handleMentionClick: mentionClickHandler,
|
||||||
}),
|
}),
|
||||||
[mx, room, linkifyOpts, mentionClickHandler, spoilerClickHandler, useAuthentication]
|
[mx, room, linkifyOpts, mentionClickHandler, spoilerClickHandler, useAuthentication, allowExternalImages]
|
||||||
);
|
);
|
||||||
|
|
||||||
const renderMatrixEvent = useMatrixEventRenderer<[MatrixEvent, string, GetContentCallback]>(
|
const renderMatrixEvent = useMatrixEventRenderer<[MatrixEvent, string, GetContentCallback]>(
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ import FocusTrap from 'focus-trap-react';
|
|||||||
import { Page, PageContent, PageHeader } from '../../../components/page';
|
import { Page, PageContent, PageHeader } from '../../../components/page';
|
||||||
import { SequenceCard } from '../../../components/sequence-card';
|
import { SequenceCard } from '../../../components/sequence-card';
|
||||||
import { useSetting } from '../../../state/hooks/settings';
|
import { useSetting } from '../../../state/hooks/settings';
|
||||||
import { DateFormat, MessageLayout, MessageSpacing, settingsAtom } from '../../../state/settings';
|
import { DateFormat, ExternalImageMode, MessageLayout, MessageSpacing, settingsAtom } from '../../../state/settings';
|
||||||
import { SettingTile } from '../../../components/setting-tile';
|
import { SettingTile } from '../../../components/setting-tile';
|
||||||
import { KeySymbol } from '../../../utils/key-symbol';
|
import { KeySymbol } from '../../../utils/key-symbol';
|
||||||
import { isMacOS } from '../../../utils/user-agent';
|
import { isMacOS } from '../../../utils/user-agent';
|
||||||
@@ -879,6 +879,80 @@ 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>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function Messages() {
|
function Messages() {
|
||||||
const [legacyUsernameColor, setLegacyUsernameColor] = useSetting(
|
const [legacyUsernameColor, setLegacyUsernameColor] = useSetting(
|
||||||
settingsAtom,
|
settingsAtom,
|
||||||
@@ -966,6 +1040,12 @@ function Messages() {
|
|||||||
after={<Switch variant="Primary" value={encUrlPreview} onChange={setEncUrlPreview} />}
|
after={<Switch variant="Primary" value={encUrlPreview} onChange={setEncUrlPreview} />}
|
||||||
/>
|
/>
|
||||||
</SequenceCard>
|
</SequenceCard>
|
||||||
|
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||||||
|
<SettingTile
|
||||||
|
title="External Images"
|
||||||
|
after={<SelectExternalImages />}
|
||||||
|
/>
|
||||||
|
</SequenceCard>
|
||||||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||||||
<SettingTile
|
<SettingTile
|
||||||
title="Show Hidden Events"
|
title="Show Hidden Events"
|
||||||
|
|||||||
@@ -65,6 +65,7 @@ import {
|
|||||||
import { RenderMessageContent } from '../../../components/RenderMessageContent';
|
import { RenderMessageContent } from '../../../components/RenderMessageContent';
|
||||||
import { useSetting } from '../../../state/hooks/settings';
|
import { useSetting } from '../../../state/hooks/settings';
|
||||||
import { settingsAtom } from '../../../state/settings';
|
import { settingsAtom } from '../../../state/settings';
|
||||||
|
import { useAllowExternalImages } from '../../../../owl/hooks/useAllowExternalImages';
|
||||||
import { Image } from '../../../components/media';
|
import { Image } from '../../../components/media';
|
||||||
import { ImageViewer } from '../../../components/image-viewer';
|
import { ImageViewer } from '../../../components/image-viewer';
|
||||||
import { GetContentCallback, MessageEvent, StateEvent } from '../../../../types/matrix/room';
|
import { GetContentCallback, MessageEvent, StateEvent } from '../../../../types/matrix/room';
|
||||||
@@ -236,6 +237,7 @@ function RoomNotificationsGroupComp({
|
|||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
const accessibleTagColors = useAccessiblePowerTagColors(theme.kind, creatorsTag, powerLevelTags);
|
const accessibleTagColors = useAccessiblePowerTagColors(theme.kind, creatorsTag, powerLevelTags);
|
||||||
|
|
||||||
|
const allowExternalImages = useAllowExternalImages(mx, room);
|
||||||
const mentionClickHandler = useMentionClickHandler(room.roomId);
|
const mentionClickHandler = useMentionClickHandler(room.roomId);
|
||||||
const spoilerClickHandler = useSpoilerClickHandler();
|
const spoilerClickHandler = useSpoilerClickHandler();
|
||||||
|
|
||||||
@@ -253,10 +255,11 @@ function RoomNotificationsGroupComp({
|
|||||||
getReactCustomHtmlParser(mx, room.roomId, {
|
getReactCustomHtmlParser(mx, room.roomId, {
|
||||||
linkifyOpts,
|
linkifyOpts,
|
||||||
useAuthentication,
|
useAuthentication,
|
||||||
|
allowExternalImages,
|
||||||
handleSpoilerClick: spoilerClickHandler,
|
handleSpoilerClick: spoilerClickHandler,
|
||||||
handleMentionClick: mentionClickHandler,
|
handleMentionClick: mentionClickHandler,
|
||||||
}),
|
}),
|
||||||
[mx, room, linkifyOpts, mentionClickHandler, spoilerClickHandler, useAuthentication]
|
[mx, room, linkifyOpts, mentionClickHandler, spoilerClickHandler, useAuthentication, allowExternalImages]
|
||||||
);
|
);
|
||||||
|
|
||||||
const renderMatrixEvent = useMatrixEventRenderer<[IRoomEvent, string, GetContentCallback]>(
|
const renderMatrixEvent = useMatrixEventRenderer<[IRoomEvent, string, GetContentCallback]>(
|
||||||
|
|||||||
@@ -319,6 +319,7 @@ export const getReactCustomHtmlParser = (
|
|||||||
handleSpoilerClick?: ReactEventHandler<HTMLElement>;
|
handleSpoilerClick?: ReactEventHandler<HTMLElement>;
|
||||||
handleMentionClick?: ReactEventHandler<HTMLElement>;
|
handleMentionClick?: ReactEventHandler<HTMLElement>;
|
||||||
useAuthentication?: boolean;
|
useAuthentication?: boolean;
|
||||||
|
allowExternalImages?: boolean;
|
||||||
}
|
}
|
||||||
): HTMLReactParserOptions => {
|
): HTMLReactParserOptions => {
|
||||||
const opts: HTMLReactParserOptions = {
|
const opts: HTMLReactParserOptions = {
|
||||||
@@ -475,9 +476,17 @@ export const getReactCustomHtmlParser = (
|
|||||||
|
|
||||||
if (name === 'img') {
|
if (name === 'img') {
|
||||||
const isMxc = typeof props.src === 'string' && props.src.startsWith('mxc://');
|
const isMxc = typeof props.src === 'string' && props.src.startsWith('mxc://');
|
||||||
|
const isExternal = !isMxc && typeof props.src === 'string';
|
||||||
const htmlSrc = isMxc
|
const htmlSrc = isMxc
|
||||||
? mxcUrlToHttp(mx, props.src, params.useAuthentication)
|
? mxcUrlToHttp(mx, props.src, params.useAuthentication)
|
||||||
: props.src;
|
: props.src;
|
||||||
|
if (isExternal && !params.allowExternalImages) {
|
||||||
|
return (
|
||||||
|
<a href={props.src} target="_blank" rel="noreferrer noopener">
|
||||||
|
{props.alt || props.title || props.src}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
}
|
||||||
if (htmlSrc && 'data-mx-emoticon' in props) {
|
if (htmlSrc && 'data-mx-emoticon' in props) {
|
||||||
return (
|
return (
|
||||||
<span className={css.EmoticonBase}>
|
<span className={css.EmoticonBase}>
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ export enum MessageLayout {
|
|||||||
Bubble = 2,
|
Bubble = 2,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type ExternalImageMode = 'always' | 'homeserver' | 'never';
|
||||||
|
|
||||||
export interface Settings {
|
export interface Settings {
|
||||||
themeId?: string;
|
themeId?: string;
|
||||||
useSystemTheme: boolean;
|
useSystemTheme: boolean;
|
||||||
@@ -39,6 +41,7 @@ export interface Settings {
|
|||||||
encUrlPreview: boolean;
|
encUrlPreview: boolean;
|
||||||
showHiddenEvents: boolean;
|
showHiddenEvents: boolean;
|
||||||
legacyUsernameColor: boolean;
|
legacyUsernameColor: boolean;
|
||||||
|
externalImages: ExternalImageMode;
|
||||||
|
|
||||||
showNotifications: boolean;
|
showNotifications: boolean;
|
||||||
isNotificationSounds: boolean;
|
isNotificationSounds: boolean;
|
||||||
@@ -73,6 +76,7 @@ const defaultSettings: Settings = {
|
|||||||
encUrlPreview: false,
|
encUrlPreview: false,
|
||||||
showHiddenEvents: false,
|
showHiddenEvents: false,
|
||||||
legacyUsernameColor: false,
|
legacyUsernameColor: false,
|
||||||
|
externalImages: 'never',
|
||||||
|
|
||||||
showNotifications: true,
|
showNotifications: true,
|
||||||
isNotificationSounds: true,
|
isNotificationSounds: true,
|
||||||
|
|||||||
14
src/owl/hooks/useAllowExternalImages.ts
Normal file
14
src/owl/hooks/useAllowExternalImages.ts
Normal 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]);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user