Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ebdc6fc581 | |||
| 683159bed8 | |||
| 6135db3405 | |||
| dc5570f5f7 |
@@ -67,8 +67,14 @@ A full custom theme engine layered on top of Cinny's vanilla-extract theming:
|
|||||||
- **Dark mode in element-call**: After joining, `CallEmbed.applyStyles()` injects `:root { color-scheme: dark|light }` into the iframe document so `@media (prefers-color-scheme)` rules inside element-call resolve to the correct Cinny theme regardless of the OS system preference. `themeKind` is stored on the `CallEmbed` instance and updated on every `setTheme()` call, so live theme switching also re-injects the CSS. Without this, users with OS light mode would see a white background even when Cinny is in dark mode.
|
- **Dark mode in element-call**: After joining, `CallEmbed.applyStyles()` injects `:root { color-scheme: dark|light }` into the iframe document so `@media (prefers-color-scheme)` rules inside element-call resolve to the correct Cinny theme regardless of the OS system preference. `themeKind` is stored on the `CallEmbed` instance and updated on every `setTheme()` call, so live theme switching also re-injects the CSS. Without this, users with OS light mode would see a white background even when Cinny is in dark mode.
|
||||||
- **Call embed wallpaper**: The user's `chatBackground` pattern (Blueprint, Carbon, Stars…) is applied as the `backgroundImage`/`backgroundColor` of `div[data-call-embed-container]` when the call is in full view (not PiP). The iframe `html, body` is forced to `background: none !important` so the pattern shows through. When `chatBackground` is `none`, behaviour is unchanged.
|
- **Call embed wallpaper**: The user's `chatBackground` pattern (Blueprint, Carbon, Stars…) is applied as the `backgroundImage`/`backgroundColor` of `div[data-call-embed-container]` when the call is in full view (not PiP). The iframe `html, body` is forced to `background: none !important` so the pattern shows through. When `chatBackground` is `none`, behaviour is unchanged.
|
||||||
|
|
||||||
|
### Moderation
|
||||||
|
|
||||||
|
- **Report Room**: A "Report Room" option in the room header menu (⋮) allows users to report a room to homeserver admins with a reason and abuse category (Spam / Harassment / Inappropriate Content / Other). Calls `POST /_matrix/client/v3/rooms/{roomId}/report` (MSC4151, confirmed supported on matrix.lotusguild.org). Implemented in `ReportRoomModal.tsx` with loading/success/error states.
|
||||||
|
|
||||||
### Messaging Enhancements
|
### Messaging Enhancements
|
||||||
|
|
||||||
|
- **Rich room topics**: Room topics that contain formatted text (bold, links, italic) are now rendered with full HTML formatting. Falls back to plain text if no `formatted_body` is present. Activates when any room admin sets a formatted topic.
|
||||||
|
- **Edit history viewer**: Clicking the "edited" label on any edited message opens a modal showing every prior version with timestamps. Fetches all `m.replace` relations for the event and displays them oldest-to-newest. Previously the "edited" label was visible but unclickable.
|
||||||
- **GIF picker**: Giphy-powered GIF search and send. Button appears in the message composer only when `gifApiKey` is set in `config.json`. Sends GIF as `m.image` — fetches blob, uploads via `mx.uploadContent`, sends with `mx.sendMessage`. `FocusTrap` handles click-outside / Escape to close. When TDS is active: dark navy background (`#060c14`), orange dim border, `// GIF_SEARCH` header, CSS overrides for Giphy SDK search bar (dark bg, orange border/focus ring, JetBrains Mono), custom orange scrollbar. All TDS styles live in `lotus-terminal.css.ts` — no runtime `<style>` injection, eliminating flash of unstyled content.
|
- **GIF picker**: Giphy-powered GIF search and send. Button appears in the message composer only when `gifApiKey` is set in `config.json`. Sends GIF as `m.image` — fetches blob, uploads via `mx.uploadContent`, sends with `mx.sendMessage`. `FocusTrap` handles click-outside / Escape to close. When TDS is active: dark navy background (`#060c14`), orange dim border, `// GIF_SEARCH` header, CSS overrides for Giphy SDK search bar (dark bg, orange border/focus ring, JetBrains Mono), custom orange scrollbar. All TDS styles live in `lotus-terminal.css.ts` — no runtime `<style>` injection, eliminating flash of unstyled content.
|
||||||
- **Message forwarding**: Forward any message to any room from the message context menu.
|
- **Message forwarding**: Forward any message to any room from the message context menu.
|
||||||
- **Draft persistence**: Unsent message drafts survive page reload via `localStorage` (`draft-msg-<roomId>`). Jotai in-memory atom is primary; localStorage is used as fallback on reload and cleared on send.
|
- **Draft persistence**: Unsent message drafts survive page reload via `localStorage` (`draft-msg-<roomId>`). Jotai in-memory atom is primary; localStorage is used as fallback on reload and cleared on send.
|
||||||
@@ -77,6 +83,10 @@ A full custom theme engine layered on top of Cinny's vanilla-extract theming:
|
|||||||
- **Location sharing**: Map embed view for incoming location events + static share button. Renders `m.location` events inline with a map tile.
|
- **Location sharing**: Map embed view for incoming location events + static share button. Renders `m.location` events inline with a map tile.
|
||||||
- **Deleted message placeholders**: Redacted `m.room.message`, `m.room.encrypted`, and `m.sticker` events render as "This message has been deleted" with reason (if provided) rather than disappearing. One-line change in the `eventRenderer` filter in `RoomTimeline.tsx`.
|
- **Deleted message placeholders**: Redacted `m.room.message`, `m.room.encrypted`, and `m.sticker` events render as "This message has been deleted" with reason (if provided) rather than disappearing. One-line change in the `eventRenderer` filter in `RoomTimeline.tsx`.
|
||||||
|
|
||||||
|
### Room Customization
|
||||||
|
|
||||||
|
- **Personal room name overrides**: Right-click any room in the sidebar → "Rename for me…" to set a local display name visible only to you. Other members see the original name unchanged. A small pencil icon marks rooms with a custom local name. Stored in Matrix account data (`io.lotus.room_names`). Uses `io.lotus.room_names` account data key (based on MSC4431).
|
||||||
|
|
||||||
### Per-Message Read Receipts
|
### Per-Message Read Receipts
|
||||||
|
|
||||||
Full per-message read receipt system — shows who has read each message directly in the timeline.
|
Full per-message read receipt system — shows who has read each message directly in the timeline.
|
||||||
@@ -125,12 +135,18 @@ Emoji reaction buttons styled for terminal mode via `button[data-reaction-key]`
|
|||||||
- **Presence badges on members**: Online/busy/away dots shown next to users in the room members drawer and settings members panel (`PresenceBadge` component from `src/app/components/presence/Presence.tsx`).
|
- **Presence badges on members**: Online/busy/away dots shown next to users in the room members drawer and settings members panel (`PresenceBadge` component from `src/app/components/presence/Presence.tsx`).
|
||||||
- **Document title unread count**: Tab title updates to `(N) Lotus Chat` for mentions, `· Lotus Chat` for unreads, `Lotus Chat` when clear.
|
- **Document title unread count**: Tab title updates to `(N) Lotus Chat` for mentions, `· Lotus Chat` for unreads, `Lotus Chat` when clear.
|
||||||
|
|
||||||
|
### Server Integration
|
||||||
|
|
||||||
|
- **Server support contact (MSC1929)**: Settings → Help & About displays the homeserver admin contact fetched from `/.well-known/matrix/support`. Shows the admin's Matrix ID and a link to the support page when the homeserver has configured this endpoint. Degrades gracefully when not configured (section is hidden on 404 or network error). In TDS mode the contact text and link render in `--lt-accent-cyan`. Implemented in `src/app/features/settings/about/About.tsx`.
|
||||||
|
- **Server notices**: Rooms of type `m.server_notice` (system messages from the homeserver) now render with a distinct "Server Notice" `<Chip variant="Warning">` badge in the room header and a disabled composer showing "This is a server notice room — you cannot send messages here." Previously indistinguishable from regular DMs. Badge in `src/app/features/room/RoomViewHeader.tsx`; composer guard in `src/app/features/room/RoomInput.tsx`.
|
||||||
|
|
||||||
### Infrastructure
|
### Infrastructure
|
||||||
|
|
||||||
- **Authenticated media**: All avatar/media loads use `mxcUrlToHttp(mx, mxcUrl, useAuthentication, w, h, 'crop')` from `../../utils/matrix` — the Lotus utility that handles MSC3916 authenticated media. (Upstream Cinny uses the SDK method with incorrect argument order for authenticated endpoints.)
|
- **Authenticated media**: All avatar/media loads use `mxcUrlToHttp(mx, mxcUrl, useAuthentication, w, h, 'crop')` from `../../utils/matrix` — the Lotus utility that handles MSC3916 authenticated media. (Upstream Cinny uses the SDK method with incorrect argument order for authenticated endpoints.)
|
||||||
- **Upstream tracking**: `git remote add upstream https://github.com/cinnyapp/cinny.git`. Merge strategy: `git fetch upstream && git merge upstream/main`. Daily check via `cinny-upstream-check.sh` on LXC 106 — notifies Matrix on new upstream commits.
|
- **Upstream tracking**: `git remote add upstream https://github.com/cinnyapp/cinny.git`. Merge strategy: `git fetch upstream && git merge upstream/main`. Daily check via `cinny-upstream-check.sh` on LXC 106 — notifies Matrix on new upstream commits.
|
||||||
- **Rolldown CJS interop — millify**: `src/app/plugins/millify.ts` uses a named import (`import { millify as millifyPlugin } from 'millify'`) instead of a default import. Rolldown's `__toESM` helper with `mode=1` sets `a.default = module_object` (not the function itself) when `hasOwnProperty` prevents the copy — calling `millifyPlugin()` would throw `(0, zc.default) is not a function`. Named import bypasses the interop entirely.
|
- **Rolldown CJS interop — millify**: `src/app/plugins/millify.ts` uses a named import (`import { millify as millifyPlugin } from 'millify'`) instead of a default import. Rolldown's `__toESM` helper with `mode=1` sets `a.default = module_object` (not the function itself) when `hasOwnProperty` prevents the copy — calling `millifyPlugin()` would throw `(0, zc.default) is not a function`. Named import bypasses the interop entirely.
|
||||||
- **Sentry noise filter**: `ignoreErrors: ['Request timed out']` added to `Sentry.init` in `src/index.tsx` to suppress unhandled rejections from the matrixRTC delayed-event heartbeat (matrix-sdk) and the widget PostmessageTransport initial-load race (matrix-widget-api). Neither is actionable from client code.
|
- **Sentry noise filter**: `ignoreErrors: ['Request timed out']` added to `Sentry.init` in `src/index.tsx` to suppress unhandled rejections from the matrixRTC delayed-event heartbeat (matrix-sdk) and the widget PostmessageTransport initial-load race (matrix-widget-api). Neither is actionable from client code.
|
||||||
|
- **URL preview default in encrypted rooms**: `encUrlPreview` default changed from `false` to `true` in `src/app/state/settings.ts`. A security note is shown next to the toggle in Settings → General explaining that the homeserver fetches the URL (and sees it) but not the message content.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ type RenderMessageContentProps = {
|
|||||||
msgType: string;
|
msgType: string;
|
||||||
ts: number;
|
ts: number;
|
||||||
edited?: boolean;
|
edited?: boolean;
|
||||||
|
onEditHistoryClick?: () => void;
|
||||||
getContent: <T>() => T;
|
getContent: <T>() => T;
|
||||||
mediaAutoLoad?: boolean;
|
mediaAutoLoad?: boolean;
|
||||||
urlPreview?: boolean;
|
urlPreview?: boolean;
|
||||||
@@ -51,6 +52,7 @@ export function RenderMessageContent({
|
|||||||
msgType,
|
msgType,
|
||||||
ts,
|
ts,
|
||||||
edited,
|
edited,
|
||||||
|
onEditHistoryClick,
|
||||||
getContent,
|
getContent,
|
||||||
mediaAutoLoad,
|
mediaAutoLoad,
|
||||||
urlPreview,
|
urlPreview,
|
||||||
@@ -77,6 +79,7 @@ export function RenderMessageContent({
|
|||||||
<MText
|
<MText
|
||||||
style={{ marginTop: config.space.S200 }}
|
style={{ marginTop: config.space.S200 }}
|
||||||
edited={edited}
|
edited={edited}
|
||||||
|
onEditHistoryClick={onEditHistoryClick}
|
||||||
content={content}
|
content={content}
|
||||||
renderBody={(props) => (
|
renderBody={(props) => (
|
||||||
<RenderBody
|
<RenderBody
|
||||||
@@ -133,6 +136,7 @@ export function RenderMessageContent({
|
|||||||
return (
|
return (
|
||||||
<MText
|
<MText
|
||||||
edited={edited}
|
edited={edited}
|
||||||
|
onEditHistoryClick={onEditHistoryClick}
|
||||||
content={getContent()}
|
content={getContent()}
|
||||||
renderBody={(props) => (
|
renderBody={(props) => (
|
||||||
<RenderBody
|
<RenderBody
|
||||||
@@ -152,6 +156,7 @@ export function RenderMessageContent({
|
|||||||
<MEmote
|
<MEmote
|
||||||
displayName={displayName}
|
displayName={displayName}
|
||||||
edited={edited}
|
edited={edited}
|
||||||
|
onEditHistoryClick={onEditHistoryClick}
|
||||||
content={getContent()}
|
content={getContent()}
|
||||||
renderBody={(props) => (
|
renderBody={(props) => (
|
||||||
<RenderBody
|
<RenderBody
|
||||||
@@ -170,6 +175,7 @@ export function RenderMessageContent({
|
|||||||
return (
|
return (
|
||||||
<MNotice
|
<MNotice
|
||||||
edited={edited}
|
edited={edited}
|
||||||
|
onEditHistoryClick={onEditHistoryClick}
|
||||||
content={getContent()}
|
content={getContent()}
|
||||||
renderBody={(props) => (
|
renderBody={(props) => (
|
||||||
<RenderBody
|
<RenderBody
|
||||||
|
|||||||
@@ -80,12 +80,20 @@ type RenderBodyProps = {
|
|||||||
};
|
};
|
||||||
type MTextProps = {
|
type MTextProps = {
|
||||||
edited?: boolean;
|
edited?: boolean;
|
||||||
|
onEditHistoryClick?: () => void;
|
||||||
content: Record<string, unknown>;
|
content: Record<string, unknown>;
|
||||||
renderBody: (props: RenderBodyProps) => ReactNode;
|
renderBody: (props: RenderBodyProps) => ReactNode;
|
||||||
renderUrlsPreview?: (urls: string[]) => ReactNode;
|
renderUrlsPreview?: (urls: string[]) => ReactNode;
|
||||||
style?: CSSProperties;
|
style?: CSSProperties;
|
||||||
};
|
};
|
||||||
export function MText({ edited, content, renderBody, renderUrlsPreview, style }: MTextProps) {
|
export function MText({
|
||||||
|
edited,
|
||||||
|
onEditHistoryClick,
|
||||||
|
content,
|
||||||
|
renderBody,
|
||||||
|
renderUrlsPreview,
|
||||||
|
style,
|
||||||
|
}: MTextProps) {
|
||||||
const { body, formatted_body: customBody } = content;
|
const { body, formatted_body: customBody } = content;
|
||||||
|
|
||||||
if (typeof body !== 'string') return <BrokenContent />;
|
if (typeof body !== 'string') return <BrokenContent />;
|
||||||
@@ -104,7 +112,7 @@ export function MText({ edited, content, renderBody, renderUrlsPreview, style }:
|
|||||||
body: trimmedBody,
|
body: trimmedBody,
|
||||||
customBody: typeof customBody === 'string' ? customBody : undefined,
|
customBody: typeof customBody === 'string' ? customBody : undefined,
|
||||||
})}
|
})}
|
||||||
{edited && <MessageEditedContent />}
|
{edited && <MessageEditedContent onEditHistoryClick={onEditHistoryClick} />}
|
||||||
</MessageTextBody>
|
</MessageTextBody>
|
||||||
{renderUrlsPreview && urls && urls.length > 0 && renderUrlsPreview(urls)}
|
{renderUrlsPreview && urls && urls.length > 0 && renderUrlsPreview(urls)}
|
||||||
</>
|
</>
|
||||||
@@ -114,6 +122,7 @@ export function MText({ edited, content, renderBody, renderUrlsPreview, style }:
|
|||||||
type MEmoteProps = {
|
type MEmoteProps = {
|
||||||
displayName: string;
|
displayName: string;
|
||||||
edited?: boolean;
|
edited?: boolean;
|
||||||
|
onEditHistoryClick?: () => void;
|
||||||
content: Record<string, unknown>;
|
content: Record<string, unknown>;
|
||||||
renderBody: (props: RenderBodyProps) => ReactNode;
|
renderBody: (props: RenderBodyProps) => ReactNode;
|
||||||
renderUrlsPreview?: (urls: string[]) => ReactNode;
|
renderUrlsPreview?: (urls: string[]) => ReactNode;
|
||||||
@@ -121,6 +130,7 @@ type MEmoteProps = {
|
|||||||
export function MEmote({
|
export function MEmote({
|
||||||
displayName,
|
displayName,
|
||||||
edited,
|
edited,
|
||||||
|
onEditHistoryClick,
|
||||||
content,
|
content,
|
||||||
renderBody,
|
renderBody,
|
||||||
renderUrlsPreview,
|
renderUrlsPreview,
|
||||||
@@ -144,7 +154,7 @@ export function MEmote({
|
|||||||
body: trimmedBody,
|
body: trimmedBody,
|
||||||
customBody: typeof customBody === 'string' ? customBody : undefined,
|
customBody: typeof customBody === 'string' ? customBody : undefined,
|
||||||
})}
|
})}
|
||||||
{edited && <MessageEditedContent />}
|
{edited && <MessageEditedContent onEditHistoryClick={onEditHistoryClick} />}
|
||||||
</MessageTextBody>
|
</MessageTextBody>
|
||||||
{renderUrlsPreview && urls && urls.length > 0 && renderUrlsPreview(urls)}
|
{renderUrlsPreview && urls && urls.length > 0 && renderUrlsPreview(urls)}
|
||||||
</>
|
</>
|
||||||
@@ -153,11 +163,18 @@ export function MEmote({
|
|||||||
|
|
||||||
type MNoticeProps = {
|
type MNoticeProps = {
|
||||||
edited?: boolean;
|
edited?: boolean;
|
||||||
|
onEditHistoryClick?: () => void;
|
||||||
content: Record<string, unknown>;
|
content: Record<string, unknown>;
|
||||||
renderBody: (props: RenderBodyProps) => ReactNode;
|
renderBody: (props: RenderBodyProps) => ReactNode;
|
||||||
renderUrlsPreview?: (urls: string[]) => ReactNode;
|
renderUrlsPreview?: (urls: string[]) => ReactNode;
|
||||||
};
|
};
|
||||||
export function MNotice({ edited, content, renderBody, renderUrlsPreview }: MNoticeProps) {
|
export function MNotice({
|
||||||
|
edited,
|
||||||
|
onEditHistoryClick,
|
||||||
|
content,
|
||||||
|
renderBody,
|
||||||
|
renderUrlsPreview,
|
||||||
|
}: MNoticeProps) {
|
||||||
const { body, formatted_body: customBody } = content;
|
const { body, formatted_body: customBody } = content;
|
||||||
|
|
||||||
if (typeof body !== 'string') return <BrokenContent />;
|
if (typeof body !== 'string') return <BrokenContent />;
|
||||||
@@ -176,7 +193,7 @@ export function MNotice({ edited, content, renderBody, renderUrlsPreview }: MNot
|
|||||||
body: trimmedBody,
|
body: trimmedBody,
|
||||||
customBody: typeof customBody === 'string' ? customBody : undefined,
|
customBody: typeof customBody === 'string' ? customBody : undefined,
|
||||||
})}
|
})}
|
||||||
{edited && <MessageEditedContent />}
|
{edited && <MessageEditedContent onEditHistoryClick={onEditHistoryClick} />}
|
||||||
</MessageTextBody>
|
</MessageTextBody>
|
||||||
{renderUrlsPreview && urls && urls.length > 0 && renderUrlsPreview(urls)}
|
{renderUrlsPreview && urls && urls.length > 0 && renderUrlsPreview(urls)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -66,8 +66,25 @@ export const MessageVerificationRequestContent = as<'div', { children?: never }>
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
export const MessageEditedContent = as<'span', { children?: never }>(({ ...props }, ref) => (
|
export const MessageEditedContent = as<
|
||||||
<Text as="span" size="T200" priority="300" {...props} ref={ref}>
|
'span',
|
||||||
{' (edited)'}
|
{ children?: never; onEditHistoryClick?: () => void }
|
||||||
</Text>
|
>(({ onEditHistoryClick, ...props }, ref) =>
|
||||||
));
|
onEditHistoryClick ? (
|
||||||
|
<span ref={ref} {...(props as React.HTMLAttributes<HTMLSpanElement>)}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onEditHistoryClick}
|
||||||
|
style={{ cursor: 'pointer', background: 'none', border: 'none', padding: 0 }}
|
||||||
|
>
|
||||||
|
<Text as="span" size="T200" priority="300">
|
||||||
|
{' (edited)'}
|
||||||
|
</Text>
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<Text as="span" size="T200" priority="300" {...props} ref={ref}>
|
||||||
|
{' (edited)'}
|
||||||
|
</Text>
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ export const RoomIntro = as<'div', RoomIntroProps>(({ room, ...props }, ref) =>
|
|||||||
{name}
|
{name}
|
||||||
</Text>
|
</Text>
|
||||||
<Text size="T400" priority="400">
|
<Text size="T400" priority="400">
|
||||||
{typeof topic === 'string' ? topic : 'This is the beginning of conversation.'}
|
{topic?.topic ?? 'This is the beginning of conversation.'}
|
||||||
</Text>
|
</Text>
|
||||||
{creatorName && ts && (
|
{creatorName && ts && (
|
||||||
<Text size="T200" priority="300">
|
<Text size="T200" priority="300">
|
||||||
|
|||||||
@@ -1,42 +1,57 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
import parse from 'html-react-parser';
|
||||||
import { as, Box, Header, Icon, IconButton, Icons, Modal, Scroll, Text } from 'folds';
|
import { as, Box, Header, Icon, IconButton, Icons, Modal, Scroll, Text } from 'folds';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import Linkify from 'linkify-react';
|
import Linkify from 'linkify-react';
|
||||||
import * as css from './style.css';
|
import * as css from './style.css';
|
||||||
import { LINKIFY_OPTS, scaleSystemEmoji } from '../../plugins/react-custom-html-parser';
|
import { LINKIFY_OPTS, scaleSystemEmoji } from '../../plugins/react-custom-html-parser';
|
||||||
|
import { sanitizeCustomHtml } from '../../utils/sanitize';
|
||||||
|
import { RoomTopicContent } from '../../hooks/useRoomMeta';
|
||||||
|
|
||||||
export const RoomTopicViewer = as<
|
export const RoomTopicViewer = as<
|
||||||
'div',
|
'div',
|
||||||
{
|
{
|
||||||
name: string;
|
name: string;
|
||||||
topic: string;
|
topic: string | RoomTopicContent;
|
||||||
requestClose: () => void;
|
requestClose: () => void;
|
||||||
}
|
}
|
||||||
>(({ name, topic, requestClose, className, ...props }, ref) => (
|
>(({ name, topic, requestClose, className, ...props }, ref) => {
|
||||||
<Modal
|
const topicStr = typeof topic === 'string' ? topic : topic.topic;
|
||||||
size="300"
|
const isFormatted =
|
||||||
flexHeight
|
typeof topic !== 'string' &&
|
||||||
className={classNames(css.ModalFlex, className)}
|
topic.format === 'org.matrix.custom.html' &&
|
||||||
aria-labelledby="room-topic-title"
|
typeof topic.formatted_body === 'string';
|
||||||
{...props}
|
|
||||||
ref={ref}
|
return (
|
||||||
>
|
<Modal
|
||||||
<Header className={css.ModalHeader} variant="Surface" size="500">
|
size="300"
|
||||||
<Box grow="Yes">
|
flexHeight
|
||||||
<Text as="h2" size="H4" truncate id="room-topic-title">
|
className={classNames(css.ModalFlex, className)}
|
||||||
{name}
|
aria-labelledby="room-topic-title"
|
||||||
</Text>
|
{...props}
|
||||||
</Box>
|
ref={ref}
|
||||||
<IconButton size="300" onClick={requestClose} radii="300" aria-label="Close">
|
>
|
||||||
<Icon src={Icons.Cross} />
|
<Header className={css.ModalHeader} variant="Surface" size="500">
|
||||||
</IconButton>
|
<Box grow="Yes">
|
||||||
</Header>
|
<Text as="h2" size="H4" truncate id="room-topic-title">
|
||||||
<Scroll className={css.ModalScroll} size="300" hideTrack>
|
{name}
|
||||||
<Box className={css.ModalContent} direction="Column" gap="100">
|
</Text>
|
||||||
<Text size="T300" className={css.ModalTopic} priority="400">
|
</Box>
|
||||||
<Linkify options={LINKIFY_OPTS}>{scaleSystemEmoji(topic)}</Linkify>
|
<IconButton size="300" onClick={requestClose} radii="300" aria-label="Close">
|
||||||
</Text>
|
<Icon src={Icons.Cross} />
|
||||||
</Box>
|
</IconButton>
|
||||||
</Scroll>
|
</Header>
|
||||||
</Modal>
|
<Scroll className={css.ModalScroll} size="300" hideTrack>
|
||||||
));
|
<Box className={css.ModalContent} direction="Column" gap="100">
|
||||||
|
<Text size="T300" className={css.ModalTopic} priority="400">
|
||||||
|
{isFormatted ? (
|
||||||
|
parse(sanitizeCustomHtml((topic as RoomTopicContent).formatted_body!))
|
||||||
|
) : (
|
||||||
|
<Linkify options={LINKIFY_OPTS}>{scaleSystemEmoji(topicStr)}</Linkify>
|
||||||
|
)}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
</Scroll>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|||||||
@@ -303,7 +303,7 @@ export function RoomProfile({ permissions }: RoomProfileProps) {
|
|||||||
canEditTopic={canEditTopic}
|
canEditTopic={canEditTopic}
|
||||||
avatar={avatar}
|
avatar={avatar}
|
||||||
name={name ?? ''}
|
name={name ?? ''}
|
||||||
topic={topic ?? ''}
|
topic={topic?.topic ?? ''}
|
||||||
onClose={handleCloseEdit}
|
onClose={handleCloseEdit}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
@@ -315,7 +315,7 @@ export function RoomProfile({ permissions }: RoomProfileProps) {
|
|||||||
</Text>
|
</Text>
|
||||||
{topic && (
|
{topic && (
|
||||||
<Text className={classNames(BreakWord, LineClamp3)} size="T200">
|
<Text className={classNames(BreakWord, LineClamp3)} size="T200">
|
||||||
<Linkify options={LINKIFY_OPTS}>{topic}</Linkify>
|
<Linkify options={LINKIFY_OPTS}>{topic.topic}</Linkify>
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ export function LobbyHero() {
|
|||||||
size="Inherit"
|
size="Inherit"
|
||||||
priority="300"
|
priority="300"
|
||||||
>
|
>
|
||||||
{topic}
|
{topic.topic}
|
||||||
</Text>
|
</Text>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,14 +1,21 @@
|
|||||||
import React, { MouseEventHandler, forwardRef, useState } from 'react';
|
import React, { MouseEventHandler, forwardRef, useCallback, useRef, useState } from 'react';
|
||||||
import { Room } from 'matrix-js-sdk';
|
import { Room } from 'matrix-js-sdk';
|
||||||
import {
|
import {
|
||||||
Avatar,
|
Avatar,
|
||||||
Box,
|
Box,
|
||||||
|
Button,
|
||||||
|
Dialog,
|
||||||
|
Header,
|
||||||
Icon,
|
Icon,
|
||||||
IconButton,
|
IconButton,
|
||||||
Icons,
|
Icons,
|
||||||
|
Input,
|
||||||
Text,
|
Text,
|
||||||
Menu,
|
Menu,
|
||||||
MenuItem,
|
MenuItem,
|
||||||
|
Overlay,
|
||||||
|
OverlayBackdrop,
|
||||||
|
OverlayCenter,
|
||||||
config,
|
config,
|
||||||
PopOut,
|
PopOut,
|
||||||
toRem,
|
toRem,
|
||||||
@@ -52,7 +59,12 @@ import { RoomNotificationModeSwitcher } from '../../components/RoomNotificationS
|
|||||||
import { getRoomCreatorsForRoomId, useRoomCreators } from '../../hooks/useRoomCreators';
|
import { getRoomCreatorsForRoomId, useRoomCreators } from '../../hooks/useRoomCreators';
|
||||||
import { getRoomPermissionsAPI, useRoomPermissions } from '../../hooks/useRoomPermissions';
|
import { getRoomPermissionsAPI, useRoomPermissions } from '../../hooks/useRoomPermissions';
|
||||||
import { InviteUserPrompt } from '../../components/invite-user-prompt';
|
import { InviteUserPrompt } from '../../components/invite-user-prompt';
|
||||||
import { useRoomName } from '../../hooks/useRoomMeta';
|
import {
|
||||||
|
LOCAL_ROOM_NAMES_KEY,
|
||||||
|
LocalRoomNamesContent,
|
||||||
|
useHasLocalRoomName,
|
||||||
|
useLocalRoomName,
|
||||||
|
} from '../../hooks/useRoomMeta';
|
||||||
import { useCallMembers, useCallSession } from '../../hooks/useCall';
|
import { useCallMembers, useCallSession } from '../../hooks/useCall';
|
||||||
import { useCallEmbed, useCallStart } from '../../hooks/useCallEmbed';
|
import { useCallEmbed, useCallStart } from '../../hooks/useCallEmbed';
|
||||||
import { callChatAtom } from '../../state/callEmbed';
|
import { callChatAtom } from '../../state/callEmbed';
|
||||||
@@ -62,6 +74,139 @@ import { livekitSupport } from '../../hooks/useLivekitSupport';
|
|||||||
import { StateEvent } from '../../../types/matrix/room';
|
import { StateEvent } from '../../../types/matrix/room';
|
||||||
import { webRTCSupported } from '../../utils/rtc';
|
import { webRTCSupported } from '../../utils/rtc';
|
||||||
|
|
||||||
|
type RenameRoomDialogProps = {
|
||||||
|
room: Room;
|
||||||
|
onClose: () => void;
|
||||||
|
};
|
||||||
|
function RenameRoomDialog({ room, onClose }: RenameRoomDialogProps) {
|
||||||
|
const mx = useMatrixClient();
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const getExistingContent = useCallback((): LocalRoomNamesContent => {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const raw: unknown = (mx as any).getAccountData(LOCAL_ROOM_NAMES_KEY)?.getContent();
|
||||||
|
if (
|
||||||
|
raw &&
|
||||||
|
typeof raw === 'object' &&
|
||||||
|
'rooms' in raw &&
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
typeof (raw as any).rooms === 'object'
|
||||||
|
) {
|
||||||
|
return raw as LocalRoomNamesContent;
|
||||||
|
}
|
||||||
|
return { rooms: {} };
|
||||||
|
}, [mx]);
|
||||||
|
|
||||||
|
const getCurrentLocalName = useCallback((): string => {
|
||||||
|
const content = getExistingContent();
|
||||||
|
return content.rooms[room.roomId] ?? '';
|
||||||
|
}, [getExistingContent, room.roomId]);
|
||||||
|
|
||||||
|
const handleSave = useCallback(() => {
|
||||||
|
const newName = inputRef.current?.value.trim() ?? '';
|
||||||
|
const existing = getExistingContent();
|
||||||
|
if (newName === '') {
|
||||||
|
const { [room.roomId]: _removed, ...rest } = existing.rooms;
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
(mx as any).setAccountData(LOCAL_ROOM_NAMES_KEY, { rooms: rest });
|
||||||
|
} else {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
(mx as any).setAccountData(LOCAL_ROOM_NAMES_KEY, {
|
||||||
|
rooms: { ...existing.rooms, [room.roomId]: newName },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
onClose();
|
||||||
|
}, [mx, room.roomId, getExistingContent, onClose]);
|
||||||
|
|
||||||
|
const handleClear = useCallback(() => {
|
||||||
|
const existing = getExistingContent();
|
||||||
|
const { [room.roomId]: _removed, ...rest } = existing.rooms;
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
(mx as any).setAccountData(LOCAL_ROOM_NAMES_KEY, { rooms: rest });
|
||||||
|
onClose();
|
||||||
|
}, [mx, room.roomId, getExistingContent, onClose]);
|
||||||
|
|
||||||
|
const handleKeyDown = (evt: React.KeyboardEvent<HTMLInputElement>) => {
|
||||||
|
if (evt.key === 'Enter') handleSave();
|
||||||
|
if (evt.key === 'Escape') onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Overlay open backdrop={<OverlayBackdrop />}>
|
||||||
|
<OverlayCenter>
|
||||||
|
<FocusTrap
|
||||||
|
focusTrapOptions={{
|
||||||
|
initialFocus: false,
|
||||||
|
onDeactivate: onClose,
|
||||||
|
clickOutsideDeactivates: true,
|
||||||
|
escapeDeactivates: stopPropagation,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Dialog variant="Surface" aria-labelledby="rename-room-dialog-title">
|
||||||
|
<Header
|
||||||
|
style={{
|
||||||
|
padding: `0 ${config.space.S200} 0 ${config.space.S400}`,
|
||||||
|
borderBottomWidth: config.borderWidth.B300,
|
||||||
|
}}
|
||||||
|
variant="Surface"
|
||||||
|
size="500"
|
||||||
|
>
|
||||||
|
<Box grow="Yes">
|
||||||
|
<Text as="h2" size="H4" id="rename-room-dialog-title">
|
||||||
|
Rename room (for you only)
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
<IconButton size="300" onClick={onClose} radii="300" aria-label="Cancel">
|
||||||
|
<Icon src={Icons.Cross} />
|
||||||
|
</IconButton>
|
||||||
|
</Header>
|
||||||
|
<Box style={{ padding: config.space.S400 }} direction="Column" gap="400">
|
||||||
|
<Box direction="Column" gap="200">
|
||||||
|
<Text size="L400">Custom name</Text>
|
||||||
|
<Input
|
||||||
|
ref={inputRef}
|
||||||
|
defaultValue={getCurrentLocalName()}
|
||||||
|
placeholder={room.name}
|
||||||
|
variant="Secondary"
|
||||||
|
radii="300"
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<Text size="T300" priority="300">
|
||||||
|
Only visible to you. Leave blank to use the original name.
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
<Box gap="300">
|
||||||
|
<Box grow="Yes">
|
||||||
|
<Button
|
||||||
|
onClick={handleClear}
|
||||||
|
variant="Secondary"
|
||||||
|
fill="Soft"
|
||||||
|
radii="300"
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
>
|
||||||
|
<Text size="B400">Clear</Text>
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
<Box grow="Yes">
|
||||||
|
<Button
|
||||||
|
onClick={handleSave}
|
||||||
|
variant="Primary"
|
||||||
|
radii="300"
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
>
|
||||||
|
<Text size="B400">Save</Text>
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Dialog>
|
||||||
|
</FocusTrap>
|
||||||
|
</OverlayCenter>
|
||||||
|
</Overlay>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
type RoomNavItemMenuProps = {
|
type RoomNavItemMenuProps = {
|
||||||
room: Room;
|
room: Room;
|
||||||
requestClose: () => void;
|
requestClose: () => void;
|
||||||
@@ -81,6 +226,7 @@ const RoomNavItemMenu = forwardRef<HTMLDivElement, RoomNavItemMenuProps>(
|
|||||||
const space = useSpaceOptionally();
|
const space = useSpaceOptionally();
|
||||||
|
|
||||||
const [invitePrompt, setInvitePrompt] = useState(false);
|
const [invitePrompt, setInvitePrompt] = useState(false);
|
||||||
|
const [renameDialog, setRenameDialog] = useState(false);
|
||||||
|
|
||||||
const handleMarkAsRead = () => {
|
const handleMarkAsRead = () => {
|
||||||
markAsRead(mx, room.roomId, hideActivity);
|
markAsRead(mx, room.roomId, hideActivity);
|
||||||
@@ -114,6 +260,15 @@ const RoomNavItemMenu = forwardRef<HTMLDivElement, RoomNavItemMenuProps>(
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{renameDialog && (
|
||||||
|
<RenameRoomDialog
|
||||||
|
room={room}
|
||||||
|
onClose={() => {
|
||||||
|
setRenameDialog(false);
|
||||||
|
requestClose();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
|
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
|
||||||
<MenuItem
|
<MenuItem
|
||||||
onClick={handleMarkAsRead}
|
onClick={handleMarkAsRead}
|
||||||
@@ -174,6 +329,17 @@ const RoomNavItemMenu = forwardRef<HTMLDivElement, RoomNavItemMenuProps>(
|
|||||||
Copy Link
|
Copy Link
|
||||||
</Text>
|
</Text>
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
|
<MenuItem
|
||||||
|
onClick={() => setRenameDialog(true)}
|
||||||
|
size="300"
|
||||||
|
after={<Icon size="100" src={Icons.Pencil} />}
|
||||||
|
radii="300"
|
||||||
|
aria-pressed={renameDialog}
|
||||||
|
>
|
||||||
|
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
|
||||||
|
Rename for me…
|
||||||
|
</Text>
|
||||||
|
</MenuItem>
|
||||||
<MenuItem
|
<MenuItem
|
||||||
onClick={handleRoomSettings}
|
onClick={handleRoomSettings}
|
||||||
size="300"
|
size="300"
|
||||||
@@ -264,7 +430,8 @@ function RoomNavItem_({
|
|||||||
(receipt) => receipt.userId !== mx.getUserId(),
|
(receipt) => receipt.userId !== mx.getUserId(),
|
||||||
);
|
);
|
||||||
|
|
||||||
const roomName = useRoomName(room);
|
const roomName = useLocalRoomName(room);
|
||||||
|
const hasLocalName = useHasLocalRoomName(room.roomId);
|
||||||
|
|
||||||
const handleContextMenu: MouseEventHandler<HTMLElement> = (evt) => {
|
const handleContextMenu: MouseEventHandler<HTMLElement> = (evt) => {
|
||||||
evt.preventDefault();
|
evt.preventDefault();
|
||||||
@@ -357,10 +524,18 @@ function RoomNavItem_({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Avatar>
|
</Avatar>
|
||||||
<Box as="span" grow="Yes">
|
<Box as="span" grow="Yes" alignItems="Center" gap="100">
|
||||||
<Text priority={unread ? '500' : '300'} as="span" size="Inherit" truncate>
|
<Text priority={unread ? '500' : '300'} as="span" size="Inherit" truncate>
|
||||||
{roomName}
|
{roomName}
|
||||||
</Text>
|
</Text>
|
||||||
|
{hasLocalName && (
|
||||||
|
<Icon
|
||||||
|
size="50"
|
||||||
|
src={Icons.Pencil}
|
||||||
|
aria-label="Custom local name"
|
||||||
|
style={{ opacity: 0.6, flexShrink: 0 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
{!optionsVisible && !unread && !selected && typingMember.length > 0 && (
|
{!optionsVisible && !unread && !selected && typingMember.length > 0 && (
|
||||||
<Badge size="300" variant="Secondary" fill="Soft" radii="Pill" outlined>
|
<Badge size="300" variant="Secondary" fill="Soft" radii="Pill" outlined>
|
||||||
|
|||||||
@@ -0,0 +1,186 @@
|
|||||||
|
import React, { FormEventHandler, useCallback, useState } from 'react';
|
||||||
|
import FocusTrap from 'focus-trap-react';
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Text,
|
||||||
|
Input,
|
||||||
|
Button,
|
||||||
|
IconButton,
|
||||||
|
Icon,
|
||||||
|
Icons,
|
||||||
|
Overlay,
|
||||||
|
OverlayBackdrop,
|
||||||
|
OverlayCenter,
|
||||||
|
Header,
|
||||||
|
config,
|
||||||
|
color,
|
||||||
|
Spinner,
|
||||||
|
} from 'folds';
|
||||||
|
import { Method } from 'matrix-js-sdk';
|
||||||
|
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
|
||||||
|
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||||
|
import { stopPropagation } from '../../utils/keyboard';
|
||||||
|
|
||||||
|
type ReportCategory = 'spam' | 'harassment' | 'inappropriate' | 'other';
|
||||||
|
|
||||||
|
const CATEGORY_LABELS: Record<ReportCategory, string> = {
|
||||||
|
spam: 'Spam',
|
||||||
|
harassment: 'Harassment',
|
||||||
|
inappropriate: 'Inappropriate Content',
|
||||||
|
other: 'Other',
|
||||||
|
};
|
||||||
|
|
||||||
|
type ReportRoomModalProps = {
|
||||||
|
roomId: string;
|
||||||
|
onClose: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ReportRoomModal({ roomId, onClose }: ReportRoomModalProps) {
|
||||||
|
const mx = useMatrixClient();
|
||||||
|
const [category, setCategory] = useState<ReportCategory>('spam');
|
||||||
|
|
||||||
|
const [reportState, submitReport] = useAsyncCallback(
|
||||||
|
useCallback(
|
||||||
|
async (reason: string) => {
|
||||||
|
await mx.http.authedRequest(
|
||||||
|
Method.Post,
|
||||||
|
`/rooms/${encodeURIComponent(roomId)}/report`,
|
||||||
|
undefined,
|
||||||
|
{ reason },
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[mx, roomId],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleSubmit: FormEventHandler<HTMLFormElement> = (evt) => {
|
||||||
|
evt.preventDefault();
|
||||||
|
if (reportState.status === AsyncStatus.Loading || reportState.status === AsyncStatus.Success) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const target = evt.target as HTMLFormElement;
|
||||||
|
const reasonInput = target.elements.namedItem('reasonInput') as HTMLInputElement | null;
|
||||||
|
const reasonText = reasonInput?.value.trim() ?? '';
|
||||||
|
const fullReason = reasonText
|
||||||
|
? `[${CATEGORY_LABELS[category]}] ${reasonText}`
|
||||||
|
: `[${CATEGORY_LABELS[category]}]`;
|
||||||
|
submitReport(fullReason);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Overlay open backdrop={<OverlayBackdrop />}>
|
||||||
|
<OverlayCenter>
|
||||||
|
<FocusTrap
|
||||||
|
focusTrapOptions={{
|
||||||
|
initialFocus: false,
|
||||||
|
onDeactivate: onClose,
|
||||||
|
clickOutsideDeactivates: true,
|
||||||
|
escapeDeactivates: stopPropagation,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
as="form"
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
direction="Column"
|
||||||
|
style={{
|
||||||
|
background: 'var(--mx-surface)',
|
||||||
|
borderRadius: config.radii.R400,
|
||||||
|
boxShadow: '0 8px 32px rgba(0,0,0,0.55)',
|
||||||
|
width: '100%',
|
||||||
|
maxWidth: 420,
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Header
|
||||||
|
style={{
|
||||||
|
padding: `0 ${config.space.S200} 0 ${config.space.S400}`,
|
||||||
|
borderBottomWidth: config.borderWidth.B300,
|
||||||
|
}}
|
||||||
|
variant="Surface"
|
||||||
|
size="500"
|
||||||
|
>
|
||||||
|
<Box grow="Yes">
|
||||||
|
<Text size="H4">Report Room</Text>
|
||||||
|
</Box>
|
||||||
|
<IconButton size="300" onClick={onClose} radii="300" aria-label="Close">
|
||||||
|
<Icon src={Icons.Cross} />
|
||||||
|
</IconButton>
|
||||||
|
</Header>
|
||||||
|
|
||||||
|
<Box direction="Column" gap="400" style={{ padding: config.space.S400 }}>
|
||||||
|
<Text priority="400">
|
||||||
|
Report this room to your homeserver admins. Please describe the issue below.
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Box direction="Column" gap="100">
|
||||||
|
<Text size="L400">Category</Text>
|
||||||
|
<Box
|
||||||
|
as="select"
|
||||||
|
value={category}
|
||||||
|
onChange={(e: React.ChangeEvent<HTMLSelectElement>) =>
|
||||||
|
setCategory(e.target.value as ReportCategory)
|
||||||
|
}
|
||||||
|
style={{
|
||||||
|
padding: `${config.space.S200} ${config.space.S300}`,
|
||||||
|
borderRadius: config.radii.R300,
|
||||||
|
border: '1px solid var(--mx-border)',
|
||||||
|
background: 'var(--mx-bg-surface)',
|
||||||
|
color: 'var(--mx-c-surface-on)',
|
||||||
|
fontSize: 'inherit',
|
||||||
|
fontFamily: 'inherit',
|
||||||
|
width: '100%',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{(Object.keys(CATEGORY_LABELS) as ReportCategory[]).map((key) => (
|
||||||
|
<option key={key} value={key}>
|
||||||
|
{CATEGORY_LABELS[key]}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box direction="Column" gap="100">
|
||||||
|
<Text size="L400">Reason</Text>
|
||||||
|
<Input name="reasonInput" variant="Background" required />
|
||||||
|
{reportState.status === AsyncStatus.Error && (
|
||||||
|
<Text style={{ color: color.Critical.Main }} size="T300">
|
||||||
|
Failed to submit report. Please try again.
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
{reportState.status === AsyncStatus.Success && (
|
||||||
|
<Text style={{ color: color.Success.Main }} size="T300">
|
||||||
|
Room has been reported to the server.
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box gap="200" justifyContent="End">
|
||||||
|
<Button type="button" variant="Secondary" fill="None" radii="300" onClick={onClose}>
|
||||||
|
<Text size="B400">Cancel</Text>
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant="Critical"
|
||||||
|
radii="300"
|
||||||
|
before={
|
||||||
|
reportState.status === AsyncStatus.Loading ? (
|
||||||
|
<Spinner fill="Solid" variant="Critical" size="200" />
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
|
aria-disabled={
|
||||||
|
reportState.status === AsyncStatus.Loading ||
|
||||||
|
reportState.status === AsyncStatus.Success
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Text size="B400">
|
||||||
|
{reportState.status === AsyncStatus.Loading ? 'Reporting...' : 'Report Room'}
|
||||||
|
</Text>
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</FocusTrap>
|
||||||
|
</OverlayCenter>
|
||||||
|
</Overlay>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -603,6 +603,16 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (room.getType() === 'm.server_notice') {
|
||||||
|
return (
|
||||||
|
<div ref={ref} style={{ padding: config.space.S300, textAlign: 'center' }}>
|
||||||
|
<Text size="T300" priority="300">
|
||||||
|
This is a server notice room — you cannot send messages here.
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={ref}>
|
<div ref={ref}>
|
||||||
{selectedFiles.length > 0 && (
|
{selectedFiles.length > 0 && (
|
||||||
|
|||||||
@@ -128,6 +128,7 @@ import { useAccessiblePowerTagColors, useGetMemberPowerTag } from '../../hooks/u
|
|||||||
import { useTheme } from '../../hooks/useTheme';
|
import { useTheme } from '../../hooks/useTheme';
|
||||||
import { useRoomCreatorsTag } from '../../hooks/useRoomCreatorsTag';
|
import { useRoomCreatorsTag } from '../../hooks/useRoomCreatorsTag';
|
||||||
import { usePowerLevelTags } from '../../hooks/usePowerLevelTags';
|
import { usePowerLevelTags } from '../../hooks/usePowerLevelTags';
|
||||||
|
import { EditHistoryModal } from './message/EditHistoryModal';
|
||||||
|
|
||||||
const TimelineFloat = as<'div', css.TimelineFloatVariants>(
|
const TimelineFloat = as<'div', css.TimelineFloatVariants>(
|
||||||
({ position, className, ...props }, ref) => (
|
({ position, className, ...props }, ref) => (
|
||||||
@@ -480,6 +481,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||||||
const canSendReaction = permissions.event(MessageEvent.Reaction, mx.getSafeUserId());
|
const canSendReaction = permissions.event(MessageEvent.Reaction, mx.getSafeUserId());
|
||||||
const canPinEvent = permissions.stateEvent(StateEvent.RoomPinnedEvents, mx.getSafeUserId());
|
const canPinEvent = permissions.stateEvent(StateEvent.RoomPinnedEvents, mx.getSafeUserId());
|
||||||
const [editId, setEditId] = useState<string>();
|
const [editId, setEditId] = useState<string>();
|
||||||
|
const [editHistoryEvent, setEditHistoryEvent] = useState<MatrixEvent | undefined>();
|
||||||
|
|
||||||
const roomToParents = useAtomValue(roomToParentsAtom);
|
const roomToParents = useAtomValue(roomToParentsAtom);
|
||||||
const unread = useRoomUnread(room.roomId, roomToUnreadAtom);
|
const unread = useRoomUnread(room.roomId, roomToUnreadAtom);
|
||||||
@@ -1123,6 +1125,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||||||
msgType={mEvent.getContent().msgtype ?? ''}
|
msgType={mEvent.getContent().msgtype ?? ''}
|
||||||
ts={mEvent.getTs()}
|
ts={mEvent.getTs()}
|
||||||
edited={!!editedEvent}
|
edited={!!editedEvent}
|
||||||
|
onEditHistoryClick={editedEvent ? () => setEditHistoryEvent(mEvent) : undefined}
|
||||||
getContent={getContent}
|
getContent={getContent}
|
||||||
mediaAutoLoad={mediaAutoLoad}
|
mediaAutoLoad={mediaAutoLoad}
|
||||||
urlPreview={showUrlPreview}
|
urlPreview={showUrlPreview}
|
||||||
@@ -1229,6 +1232,9 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||||||
msgType={mEvent.getContent().msgtype ?? ''}
|
msgType={mEvent.getContent().msgtype ?? ''}
|
||||||
ts={mEvent.getTs()}
|
ts={mEvent.getTs()}
|
||||||
edited={!!editedEvent}
|
edited={!!editedEvent}
|
||||||
|
onEditHistoryClick={
|
||||||
|
editedEvent ? () => setEditHistoryEvent(mEvent) : undefined
|
||||||
|
}
|
||||||
getContent={getContent}
|
getContent={getContent}
|
||||||
mediaAutoLoad={mediaAutoLoad}
|
mediaAutoLoad={mediaAutoLoad}
|
||||||
urlPreview={showUrlPreview}
|
urlPreview={showUrlPreview}
|
||||||
@@ -2191,6 +2197,13 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||||||
</TimelineFloat>
|
</TimelineFloat>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
{editHistoryEvent && (
|
||||||
|
<EditHistoryModal
|
||||||
|
room={room}
|
||||||
|
mEvent={editHistoryEvent}
|
||||||
|
onClose={() => setEditHistoryEvent(undefined)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</ReadPositionsContext.Provider>
|
</ReadPositionsContext.Provider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
PopOut,
|
PopOut,
|
||||||
RectCords,
|
RectCords,
|
||||||
Badge,
|
Badge,
|
||||||
|
Chip,
|
||||||
Spinner,
|
Spinner,
|
||||||
Button,
|
Button,
|
||||||
} from 'folds';
|
} from 'folds';
|
||||||
@@ -67,6 +68,7 @@ import { JumpToTime } from './jump-to-time';
|
|||||||
import { useRoomNavigate } from '../../hooks/useRoomNavigate';
|
import { useRoomNavigate } from '../../hooks/useRoomNavigate';
|
||||||
import { useRoomCreators } from '../../hooks/useRoomCreators';
|
import { useRoomCreators } from '../../hooks/useRoomCreators';
|
||||||
import { useRoomPermissions } from '../../hooks/useRoomPermissions';
|
import { useRoomPermissions } from '../../hooks/useRoomPermissions';
|
||||||
|
import { ReportRoomModal } from './ReportRoomModal';
|
||||||
import { InviteUserPrompt } from '../../components/invite-user-prompt';
|
import { InviteUserPrompt } from '../../components/invite-user-prompt';
|
||||||
import { ContainerColor } from '../../styles/ContainerColor.css';
|
import { ContainerColor } from '../../styles/ContainerColor.css';
|
||||||
import { RoomSettingsPage } from '../../state/roomSettings';
|
import { RoomSettingsPage } from '../../state/roomSettings';
|
||||||
@@ -92,6 +94,7 @@ const RoomMenu = forwardRef<HTMLDivElement, RoomMenuProps>(({ room, requestClose
|
|||||||
const { navigateRoom } = useRoomNavigate();
|
const { navigateRoom } = useRoomNavigate();
|
||||||
|
|
||||||
const [invitePrompt, setInvitePrompt] = useState(false);
|
const [invitePrompt, setInvitePrompt] = useState(false);
|
||||||
|
const [reportRoomOpen, setReportRoomOpen] = useState(false);
|
||||||
|
|
||||||
const handleMarkAsRead = () => {
|
const handleMarkAsRead = () => {
|
||||||
markAsRead(mx, room.roomId, hideActivity);
|
markAsRead(mx, room.roomId, hideActivity);
|
||||||
@@ -127,6 +130,15 @@ const RoomMenu = forwardRef<HTMLDivElement, RoomMenuProps>(({ room, requestClose
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{reportRoomOpen && (
|
||||||
|
<ReportRoomModal
|
||||||
|
roomId={room.roomId}
|
||||||
|
onClose={() => {
|
||||||
|
setReportRoomOpen(false);
|
||||||
|
requestClose();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
|
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
|
||||||
<MenuItem
|
<MenuItem
|
||||||
onClick={handleMarkAsRead}
|
onClick={handleMarkAsRead}
|
||||||
@@ -227,6 +239,19 @@ const RoomMenu = forwardRef<HTMLDivElement, RoomMenuProps>(({ room, requestClose
|
|||||||
</Box>
|
</Box>
|
||||||
<Line variant="Surface" size="300" />
|
<Line variant="Surface" size="300" />
|
||||||
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
|
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
|
||||||
|
<MenuItem
|
||||||
|
onClick={() => setReportRoomOpen(true)}
|
||||||
|
variant="Critical"
|
||||||
|
fill="None"
|
||||||
|
size="300"
|
||||||
|
after={<Icon size="100" src={Icons.Warning} />}
|
||||||
|
radii="300"
|
||||||
|
aria-pressed={reportRoomOpen}
|
||||||
|
>
|
||||||
|
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
|
||||||
|
Report Room
|
||||||
|
</Text>
|
||||||
|
</MenuItem>
|
||||||
<UseStateProvider initial={false}>
|
<UseStateProvider initial={false}>
|
||||||
{(promptLeave, setPromptLeave) => (
|
{(promptLeave, setPromptLeave) => (
|
||||||
<>
|
<>
|
||||||
@@ -478,9 +503,16 @@ export function RoomViewHeader({ callView }: { callView?: boolean }) {
|
|||||||
</Avatar>
|
</Avatar>
|
||||||
)}
|
)}
|
||||||
<Box direction="Column">
|
<Box direction="Column">
|
||||||
<Text size={topic ? 'H5' : 'H3'} truncate>
|
<Box alignItems="Center" gap="200">
|
||||||
{name}
|
<Text size={topic ? 'H5' : 'H3'} truncate>
|
||||||
</Text>
|
{name}
|
||||||
|
</Text>
|
||||||
|
{room.getType() === 'm.server_notice' && (
|
||||||
|
<Chip size="400" variant="Warning" radii="Pill" outlined>
|
||||||
|
<Text size="T200">Server Notice</Text>
|
||||||
|
</Chip>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
{topic && (
|
{topic && (
|
||||||
<UseStateProvider initial={false}>
|
<UseStateProvider initial={false}>
|
||||||
{(viewTopic, setViewTopic) => (
|
{(viewTopic, setViewTopic) => (
|
||||||
@@ -512,7 +544,7 @@ export function RoomViewHeader({ callView }: { callView?: boolean }) {
|
|||||||
priority="300"
|
priority="300"
|
||||||
truncate
|
truncate
|
||||||
>
|
>
|
||||||
{topic}
|
{topic.topic}
|
||||||
</Text>
|
</Text>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -0,0 +1,210 @@
|
|||||||
|
import React, { useCallback, useEffect } from 'react';
|
||||||
|
import FocusTrap from 'focus-trap-react';
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Header,
|
||||||
|
Icon,
|
||||||
|
IconButton,
|
||||||
|
Icons,
|
||||||
|
Modal,
|
||||||
|
Overlay,
|
||||||
|
OverlayBackdrop,
|
||||||
|
OverlayCenter,
|
||||||
|
Scroll,
|
||||||
|
Spinner,
|
||||||
|
Text,
|
||||||
|
} from 'folds';
|
||||||
|
import { MatrixEvent, Method, Room } from 'matrix-js-sdk';
|
||||||
|
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||||
|
import { stopPropagation } from '../../../utils/keyboard';
|
||||||
|
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
|
||||||
|
import { timeDayMonYear, timeHourMinute } from '../../../utils/time';
|
||||||
|
import { useSetting } from '../../../state/hooks/settings';
|
||||||
|
import { settingsAtom } from '../../../state/settings';
|
||||||
|
|
||||||
|
type EditHistoryResponse = {
|
||||||
|
chunk: Array<Record<string, unknown>>;
|
||||||
|
next_batch?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type EditHistoryModalProps = {
|
||||||
|
room: Room;
|
||||||
|
mEvent: MatrixEvent;
|
||||||
|
onClose: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function EditHistoryModal({ room, mEvent, onClose }: EditHistoryModalProps) {
|
||||||
|
const mx = useMatrixClient();
|
||||||
|
const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock');
|
||||||
|
const [dateFormatString] = useSetting(settingsAtom, 'dateFormatString');
|
||||||
|
|
||||||
|
const eventId = mEvent.getId();
|
||||||
|
const roomId = room.roomId;
|
||||||
|
|
||||||
|
const [historyState, fetchHistory] = useAsyncCallback<MatrixEvent[], unknown, []>(
|
||||||
|
useCallback(async () => {
|
||||||
|
const path = `/rooms/${encodeURIComponent(roomId)}/relations/${encodeURIComponent(eventId ?? '')}/m.replace`;
|
||||||
|
const res = await mx.http.authedRequest<EditHistoryResponse>(Method.Get, path, {
|
||||||
|
limit: '50',
|
||||||
|
});
|
||||||
|
const rawEvents = res.chunk ?? [];
|
||||||
|
// Sort oldest first
|
||||||
|
const events = rawEvents
|
||||||
|
.map(
|
||||||
|
(raw) =>
|
||||||
|
// Build a lightweight representation for display; we just need content + ts
|
||||||
|
raw as {
|
||||||
|
type: string;
|
||||||
|
content: Record<string, unknown>;
|
||||||
|
origin_server_ts: number;
|
||||||
|
event_id: string;
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.sort((a, b) => a.origin_server_ts - b.origin_server_ts);
|
||||||
|
|
||||||
|
// Convert to MatrixEvent-like objects using the raw data
|
||||||
|
// We use MatrixEvent if available from the timeline, otherwise parse the raw data
|
||||||
|
return events.map((raw) => {
|
||||||
|
const existing = room.findEventById(raw.event_id);
|
||||||
|
if (existing) return existing;
|
||||||
|
// Create a minimal event wrapper
|
||||||
|
const evt = new MatrixEvent({
|
||||||
|
type: raw.type,
|
||||||
|
content: raw.content,
|
||||||
|
origin_server_ts: raw.origin_server_ts,
|
||||||
|
event_id: raw.event_id,
|
||||||
|
room_id: roomId,
|
||||||
|
sender: mEvent.getSender() ?? '',
|
||||||
|
});
|
||||||
|
return evt;
|
||||||
|
});
|
||||||
|
}, [mx, roomId, eventId, room, mEvent]),
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchHistory();
|
||||||
|
}, [fetchHistory]);
|
||||||
|
|
||||||
|
const formatTs = (ts: number): string => {
|
||||||
|
const time = timeHourMinute(ts, hour24Clock);
|
||||||
|
const date = timeDayMonYear(ts, dateFormatString);
|
||||||
|
return `${date} at ${time}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getVersionBody = (evt: MatrixEvent): string => {
|
||||||
|
const content = evt.getContent();
|
||||||
|
const newContent = content['m.new_content'] as Record<string, unknown> | undefined;
|
||||||
|
const body = newContent?.body ?? content.body;
|
||||||
|
return typeof body === 'string' ? body : '(no text)';
|
||||||
|
};
|
||||||
|
|
||||||
|
// The original message body (before any edits)
|
||||||
|
const originalBody = (() => {
|
||||||
|
const content = mEvent.getContent();
|
||||||
|
const body = content.body;
|
||||||
|
return typeof body === 'string' ? body : '(no text)';
|
||||||
|
})();
|
||||||
|
|
||||||
|
const originalTs = mEvent.getTs();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Overlay open backdrop={<OverlayBackdrop />}>
|
||||||
|
<OverlayCenter>
|
||||||
|
<FocusTrap
|
||||||
|
focusTrapOptions={{
|
||||||
|
initialFocus: false,
|
||||||
|
clickOutsideDeactivates: true,
|
||||||
|
onDeactivate: onClose,
|
||||||
|
escapeDeactivates: stopPropagation,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Modal variant="Surface" size="500">
|
||||||
|
<Header
|
||||||
|
variant="Surface"
|
||||||
|
size="500"
|
||||||
|
style={{ padding: '0 var(--mx-spacing-s200) 0 var(--mx-spacing-s400)' }}
|
||||||
|
>
|
||||||
|
<Box grow="Yes">
|
||||||
|
<Text as="h2" size="H4" truncate>
|
||||||
|
Edit History
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
<IconButton size="300" onClick={onClose} radii="300" aria-label="Close">
|
||||||
|
<Icon src={Icons.Cross} />
|
||||||
|
</IconButton>
|
||||||
|
</Header>
|
||||||
|
|
||||||
|
<Scroll size="300" hideTrack style={{ maxHeight: '60vh' }}>
|
||||||
|
<Box
|
||||||
|
direction="Column"
|
||||||
|
gap="200"
|
||||||
|
style={{
|
||||||
|
padding: 'var(--mx-spacing-s400)',
|
||||||
|
paddingBottom: 'var(--mx-spacing-s700)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{historyState.status === AsyncStatus.Loading && (
|
||||||
|
<Box
|
||||||
|
justifyContent="Center"
|
||||||
|
alignItems="Center"
|
||||||
|
style={{ padding: 'var(--mx-spacing-s400)' }}
|
||||||
|
>
|
||||||
|
<Spinner size="200" />
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
{historyState.status === AsyncStatus.Error && (
|
||||||
|
<Text size="T300" priority="300">
|
||||||
|
Failed to load edit history.
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
{historyState.status === AsyncStatus.Success && (
|
||||||
|
<Box direction="Column" gap="300">
|
||||||
|
{/* Original message always shown first */}
|
||||||
|
<Box direction="Column" gap="100">
|
||||||
|
<Box gap="200" alignItems="Center">
|
||||||
|
<Text size="L400">Original</Text>
|
||||||
|
<Text size="T200" priority="300">
|
||||||
|
{formatTs(originalTs)}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
<Text size="T300" style={{ whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}>
|
||||||
|
{originalBody}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{historyState.data.map((editEvt, index) => (
|
||||||
|
<Box key={editEvt.getId() ?? index} direction="Column" gap="100">
|
||||||
|
<Box gap="200" alignItems="Center">
|
||||||
|
<Text size="L400">
|
||||||
|
{index === historyState.data.length - 1
|
||||||
|
? `Edit ${index + 1} (current)`
|
||||||
|
: `Edit ${index + 1}`}
|
||||||
|
</Text>
|
||||||
|
<Text size="T200" priority="300">
|
||||||
|
{formatTs(editEvt.getTs())}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
<Text
|
||||||
|
size="T300"
|
||||||
|
style={{ whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}
|
||||||
|
>
|
||||||
|
{getVersionBody(editEvt)}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{historyState.data.length === 0 && (
|
||||||
|
<Text size="T300" priority="300">
|
||||||
|
No edit history found.
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Scroll>
|
||||||
|
</Modal>
|
||||||
|
</FocusTrap>
|
||||||
|
</OverlayCenter>
|
||||||
|
</Overlay>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import React from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { Box, Text, IconButton, Icon, Icons, Scroll, Button, config, toRem } from 'folds';
|
import { Box, Text, IconButton, Icon, Icons, Scroll, Button, config, toRem } from 'folds';
|
||||||
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';
|
||||||
@@ -8,12 +8,49 @@ import LotusLogo from '../../../../../public/res/Lotus.png';
|
|||||||
import pkg from '../../../../../package.json';
|
import pkg from '../../../../../package.json';
|
||||||
import { clearCacheAndReload } from '../../../../client/initMatrix';
|
import { clearCacheAndReload } from '../../../../client/initMatrix';
|
||||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||||
|
import { lotusTerminalBodyClass } from '../../../../lotus-terminal.css';
|
||||||
|
|
||||||
|
type MSC1929Contact = {
|
||||||
|
matrix_id?: string;
|
||||||
|
email_address?: string;
|
||||||
|
role?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type MSC1929Support = {
|
||||||
|
contacts?: MSC1929Contact[];
|
||||||
|
support_page?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function useServerSupport(): MSC1929Support | null {
|
||||||
|
const mx = useMatrixClient();
|
||||||
|
const [support, setSupport] = useState<MSC1929Support | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const baseUrl = (mx as unknown as { baseUrl: string }).baseUrl;
|
||||||
|
fetch(`${baseUrl}/.well-known/matrix/support`)
|
||||||
|
.then((res) => {
|
||||||
|
if (!res.ok) return null;
|
||||||
|
return res.json() as Promise<MSC1929Support>;
|
||||||
|
})
|
||||||
|
.then((data) => {
|
||||||
|
if (data && (data.contacts?.length || data.support_page)) {
|
||||||
|
setSupport(data);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
// Graceful degradation — server may not have this configured
|
||||||
|
});
|
||||||
|
}, [mx]);
|
||||||
|
|
||||||
|
return support;
|
||||||
|
}
|
||||||
|
|
||||||
type AboutProps = {
|
type AboutProps = {
|
||||||
requestClose: () => void;
|
requestClose: () => void;
|
||||||
};
|
};
|
||||||
export function About({ requestClose }: AboutProps) {
|
export function About({ requestClose }: AboutProps) {
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
|
const serverSupport = useServerSupport();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Page>
|
<Page>
|
||||||
@@ -108,6 +145,67 @@ export function About({ requestClose }: AboutProps) {
|
|||||||
/>
|
/>
|
||||||
</SequenceCard>
|
</SequenceCard>
|
||||||
</Box>
|
</Box>
|
||||||
|
{serverSupport && (
|
||||||
|
<Box direction="Column" gap="100">
|
||||||
|
<Text size="L400">Homeserver Support</Text>
|
||||||
|
<SequenceCard
|
||||||
|
className={SequenceCardStyle}
|
||||||
|
variant="SurfaceVariant"
|
||||||
|
direction="Column"
|
||||||
|
gap="400"
|
||||||
|
>
|
||||||
|
<Box direction="Column" gap="200" style={{ padding: config.space.S200 }}>
|
||||||
|
{serverSupport.contacts && serverSupport.contacts.length > 0 && (
|
||||||
|
<Box direction="Column" gap="100">
|
||||||
|
{serverSupport.contacts.map((contact, i) => (
|
||||||
|
<Box key={i} alignItems="Center" gap="200">
|
||||||
|
<Text size="T300" priority="300">
|
||||||
|
{contact.role === 'm.role.admin'
|
||||||
|
? 'Admin'
|
||||||
|
: contact.role === 'm.role.security'
|
||||||
|
? 'Security'
|
||||||
|
: 'Contact'}
|
||||||
|
:
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
size="T300"
|
||||||
|
style={{
|
||||||
|
color: document.body.classList.contains(lotusTerminalBodyClass)
|
||||||
|
? 'var(--lt-accent-cyan)'
|
||||||
|
: undefined,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{contact.matrix_id ?? contact.email_address ?? ''}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
{serverSupport.support_page && (
|
||||||
|
<Box alignItems="Center" gap="200">
|
||||||
|
<Text size="T300" priority="300">
|
||||||
|
Support Page:
|
||||||
|
</Text>
|
||||||
|
<Text size="T300">
|
||||||
|
<a
|
||||||
|
href={serverSupport.support_page}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer noopener"
|
||||||
|
style={{
|
||||||
|
color: document.body.classList.contains(lotusTerminalBodyClass)
|
||||||
|
? 'var(--lt-accent-cyan)'
|
||||||
|
: undefined,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{serverSupport.support_page}
|
||||||
|
</a>
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</SequenceCard>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
<Box direction="Column" gap="100">
|
<Box direction="Column" gap="100">
|
||||||
<Text size="L400">Credits</Text>
|
<Text size="L400">Credits</Text>
|
||||||
<SequenceCard
|
<SequenceCard
|
||||||
|
|||||||
@@ -1202,6 +1202,7 @@ function Messages() {
|
|||||||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||||||
<SettingTile
|
<SettingTile
|
||||||
title="Url Preview in Encrypted Room"
|
title="Url Preview in Encrypted Room"
|
||||||
|
description="URL previews in encrypted rooms are fetched by your homeserver, which sees the URL but not the message content."
|
||||||
after={<Switch variant="Primary" value={encUrlPreview} onChange={setEncUrlPreview} />}
|
after={<Switch variant="Primary" value={encUrlPreview} onChange={setEncUrlPreview} />}
|
||||||
/>
|
/>
|
||||||
</SequenceCard>
|
</SequenceCard>
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
import { RoomJoinRulesEventContent } from 'matrix-js-sdk/lib/types';
|
import { RoomJoinRulesEventContent } from 'matrix-js-sdk/lib/types';
|
||||||
import { Room, RoomEvent, RoomEventHandlerMap } from 'matrix-js-sdk';
|
import { ClientEvent, MatrixEvent, Room, RoomEvent, RoomEventHandlerMap } from 'matrix-js-sdk';
|
||||||
import { StateEvent } from '../../types/matrix/room';
|
import { StateEvent } from '../../types/matrix/room';
|
||||||
import { useStateEvent } from './useStateEvent';
|
import { useStateEvent } from './useStateEvent';
|
||||||
|
import { useMatrixClient } from './useMatrixClient';
|
||||||
|
|
||||||
export const useRoomAvatar = (room: Room, dm?: boolean): string | undefined => {
|
export const useRoomAvatar = (room: Room, dm?: boolean): string | undefined => {
|
||||||
const avatarEvent = useStateEvent(room, StateEvent.RoomAvatar);
|
const avatarEvent = useStateEvent(room, StateEvent.RoomAvatar);
|
||||||
@@ -34,13 +35,102 @@ export const useRoomName = (room: Room): string => {
|
|||||||
return name;
|
return name;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const useRoomTopic = (room: Room): string | undefined => {
|
export const LOCAL_ROOM_NAMES_KEY = 'io.lotus.room_names';
|
||||||
|
|
||||||
|
export type LocalRoomNamesContent = { rooms: Record<string, string> };
|
||||||
|
|
||||||
|
function getLocalRoomNamesContent(mx: ReturnType<typeof useMatrixClient>): LocalRoomNamesContent {
|
||||||
|
// Use any-cast because LOCAL_ROOM_NAMES_KEY is not in matrix-js-sdk AccountDataEvents
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const raw: unknown = (mx as any).getAccountData(LOCAL_ROOM_NAMES_KEY)?.getContent();
|
||||||
|
if (
|
||||||
|
raw &&
|
||||||
|
typeof raw === 'object' &&
|
||||||
|
'rooms' in raw &&
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
typeof (raw as any).rooms === 'object'
|
||||||
|
) {
|
||||||
|
return raw as LocalRoomNamesContent;
|
||||||
|
}
|
||||||
|
return { rooms: {} };
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useLocalRoomName = (room: Room): string => {
|
||||||
|
const mx = useMatrixClient();
|
||||||
|
|
||||||
|
const getLocalName = useCallback((): string => {
|
||||||
|
const content = getLocalRoomNamesContent(mx);
|
||||||
|
return content.rooms[room.roomId] ?? room.name;
|
||||||
|
}, [mx, room]);
|
||||||
|
|
||||||
|
const [name, setName] = useState(getLocalName);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setName(getLocalName());
|
||||||
|
|
||||||
|
const handleAccountData = (event: MatrixEvent) => {
|
||||||
|
if (event.getType() !== LOCAL_ROOM_NAMES_KEY) return;
|
||||||
|
setName(getLocalName());
|
||||||
|
};
|
||||||
|
mx.on(ClientEvent.AccountData, handleAccountData);
|
||||||
|
|
||||||
|
const handleRoomNameChange: RoomEventHandlerMap[RoomEvent.Name] = () => {
|
||||||
|
setName(getLocalName());
|
||||||
|
};
|
||||||
|
room.on(RoomEvent.Name, handleRoomNameChange);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
mx.removeListener(ClientEvent.AccountData, handleAccountData);
|
||||||
|
room.removeListener(RoomEvent.Name, handleRoomNameChange);
|
||||||
|
};
|
||||||
|
}, [mx, room, getLocalName]);
|
||||||
|
|
||||||
|
return name;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useHasLocalRoomName = (roomId: string): boolean => {
|
||||||
|
const mx = useMatrixClient();
|
||||||
|
|
||||||
|
const check = useCallback((): boolean => {
|
||||||
|
const content = getLocalRoomNamesContent(mx);
|
||||||
|
return !!content.rooms[roomId];
|
||||||
|
}, [mx, roomId]);
|
||||||
|
|
||||||
|
const [hasLocal, setHasLocal] = useState(check);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setHasLocal(check());
|
||||||
|
|
||||||
|
const handleAccountData = (event: MatrixEvent) => {
|
||||||
|
if (event.getType() !== LOCAL_ROOM_NAMES_KEY) return;
|
||||||
|
setHasLocal(check());
|
||||||
|
};
|
||||||
|
mx.on(ClientEvent.AccountData, handleAccountData);
|
||||||
|
return () => {
|
||||||
|
mx.removeListener(ClientEvent.AccountData, handleAccountData);
|
||||||
|
};
|
||||||
|
}, [mx, check]);
|
||||||
|
|
||||||
|
return hasLocal;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RoomTopicContent = {
|
||||||
|
topic: string;
|
||||||
|
formatted_body?: string;
|
||||||
|
format?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useRoomTopic = (room: Room): RoomTopicContent | undefined => {
|
||||||
const topicEvent = useStateEvent(room, StateEvent.RoomTopic);
|
const topicEvent = useStateEvent(room, StateEvent.RoomTopic);
|
||||||
|
|
||||||
const content = topicEvent?.getContent();
|
const content = topicEvent?.getContent();
|
||||||
const topic = content && typeof content.topic === 'string' ? content.topic : undefined;
|
if (!content || typeof content.topic !== 'string') return undefined;
|
||||||
|
|
||||||
return topic;
|
return {
|
||||||
|
topic: content.topic,
|
||||||
|
formatted_body: typeof content.formatted_body === 'string' ? content.formatted_body : undefined,
|
||||||
|
format: typeof content.format === 'string' ? content.format : undefined,
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export const useRoomJoinRule = (room: Room): RoomJoinRulesEventContent | undefined => {
|
export const useRoomJoinRule = (room: Room): RoomJoinRulesEventContent | undefined => {
|
||||||
|
|||||||
@@ -101,7 +101,7 @@ const defaultSettings: Settings = {
|
|||||||
hideNickAvatarEvents: true,
|
hideNickAvatarEvents: true,
|
||||||
mediaAutoLoad: true,
|
mediaAutoLoad: true,
|
||||||
urlPreview: true,
|
urlPreview: true,
|
||||||
encUrlPreview: false,
|
encUrlPreview: true,
|
||||||
showHiddenEvents: false,
|
showHiddenEvents: false,
|
||||||
legacyUsernameColor: false,
|
legacyUsernameColor: false,
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user