initial commit

This commit is contained in:
Hampus Kraft
2026-01-01 20:42:59 +00:00
commit 2f557eda8c
9029 changed files with 1490197 additions and 0 deletions

View File

@@ -0,0 +1,256 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {useLingui} from '@lingui/react/macro';
import {FlagCheckeredIcon, PushPinSlashIcon, SparkleIcon, XIcon} from '@phosphor-icons/react';
import {clsx} from 'clsx';
import {observer} from 'mobx-react-lite';
import React from 'react';
import * as ChannelPinActionCreators from '~/actions/ChannelPinsActionCreators';
import * as ModalActionCreators from '~/actions/ModalActionCreators';
import {modal} from '~/actions/ModalActionCreators';
import {ChannelTypes, MessagePreviewContext, Permissions} from '~/Constants';
import {Message} from '~/components/channel/Message';
import {LongPressable} from '~/components/LongPressable';
import {ConfirmModal} from '~/components/modals/ConfirmModal';
import FocusRing from '~/components/uikit/FocusRing/FocusRing';
import {MenuBottomSheet} from '~/components/uikit/MenuBottomSheet/MenuBottomSheet';
import {Scroller} from '~/components/uikit/Scroller';
import {Spinner} from '~/components/uikit/Spinner';
import type {ChannelRecord} from '~/records/ChannelRecord';
import type {MessageRecord} from '~/records/MessageRecord';
import ChannelPinsStore from '~/stores/ChannelPinsStore';
import ChannelStore from '~/stores/ChannelStore';
import MobileLayoutStore from '~/stores/MobileLayoutStore';
import PermissionStore from '~/stores/PermissionStore';
import ReadStateStore from '~/stores/ReadStateStore';
import {goToMessage} from '~/utils/MessageNavigator';
import previewStyles from './MessagePreview.module.css';
interface ChannelPinsContentProps {
channel: ChannelRecord;
onJump?: () => void;
}
export const ChannelPinsContent = observer(({channel, onJump}: ChannelPinsContentProps) => {
const {t} = useLingui();
const pinnedPins = ChannelPinsStore.getPins(channel.id);
const fetched = ChannelPinsStore.isFetched(channel.id);
const hasMore = ChannelPinsStore.getHasMore(channel.id);
const isLoading = ChannelPinsStore.getIsLoading(channel.id);
const isDMChannel = channel.type === ChannelTypes.DM || channel.type === ChannelTypes.GROUP_DM;
const canUnpin = isDMChannel || PermissionStore.can(Permissions.MANAGE_MESSAGES, channel);
const mobileLayout = MobileLayoutStore;
const [menuOpen, setMenuOpen] = React.useState(false);
const [selectedMessage, setSelectedMessage] = React.useState<MessageRecord | null>(null);
React.useEffect(() => {
if (!fetched && !isLoading) {
ChannelPinActionCreators.fetch(channel.id);
}
}, [fetched, isLoading, channel.id]);
React.useEffect(() => {
ReadStateStore.ackPins(channel.id);
}, [channel.id]);
const handleScroll = React.useCallback(
(event: React.UIEvent<HTMLDivElement>) => {
const target = event.currentTarget;
const scrollPercentage = (target.scrollTop + target.offsetHeight) / target.scrollHeight;
if (scrollPercentage > 0.8 && hasMore && !isLoading) {
ChannelPinActionCreators.loadMore(channel.id);
}
},
[channel.id, hasMore, isLoading],
);
const handleUnpin = (message: MessageRecord, event?: React.MouseEvent) => {
if (event?.shiftKey) {
ChannelPinActionCreators.unpin(message.channelId, message.id);
} else {
ModalActionCreators.push(
modal(() => (
<ConfirmModal
title={t`Unpin Message`}
description={t`Do you want to send this pin back in time?`}
message={message}
primaryText={t`Unpin it`}
onPrimary={() => ChannelPinActionCreators.unpin(message.channelId, message.id)}
/>
)),
);
}
setMenuOpen(false);
};
const renderUnpinButton = (message: MessageRecord) => {
if (!canUnpin) return null;
return (
<FocusRing offset={-2}>
<button
type="button"
className={previewStyles.actionIconButton}
onClick={(event) => handleUnpin(message, event)}
>
<XIcon weight="regular" className={previewStyles.actionIcon} />
</button>
</FocusRing>
);
};
const handleJump = (channelId: string, messageId: string) => {
goToMessage(channelId, messageId);
onJump?.();
};
const handleTap = (message: MessageRecord) => {
if (mobileLayout.enabled) {
handleJump(message.channelId, message.id);
}
};
const endStateDescription = channel.guildId
? t`Members with the "Pin Messages" permission can pin messages for everyone to see.`
: t`You can pin messages in this conversation for everyone to see.`;
if (pinnedPins.length === 0) {
return (
<div className={previewStyles.emptyState}>
<div className={previewStyles.emptyStateContent}>
<SparkleIcon className={previewStyles.emptyStateIcon} />
<div className={previewStyles.emptyStateTextContainer}>
<h3 className={previewStyles.emptyStateTitle}>{t`No Pinned Messages`}</h3>
<p
className={previewStyles.emptyStateDescription}
>{t`Whenever someone pins a message, it'll appear here.`}</p>
</div>
</div>
</div>
);
}
return (
<>
<Scroller
className={clsx(previewStyles.scroller, mobileLayout.enabled && previewStyles.scrollerMobile)}
key="channel-pins-scroller"
onScroll={handleScroll}
reserveScrollbarTrack={false}
>
{mobileLayout.enabled && <div className={previewStyles.topSpacer} />}
{pinnedPins.slice().map(({message}) => {
const cardClasses = clsx(previewStyles.previewCard, mobileLayout.enabled && previewStyles.previewCardMobile);
if (mobileLayout.enabled) {
return (
<LongPressable
key={message.id}
className={cardClasses}
role="button"
tabIndex={0}
onClick={() => handleTap(message)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
handleTap(message);
}
}}
onLongPress={() => {
if (!canUnpin) return;
setSelectedMessage(message);
setMenuOpen(true);
}}
>
<Message
message={message}
channel={ChannelStore.getChannel(message.channelId)!}
previewContext={MessagePreviewContext.LIST_POPOUT}
/>
</LongPressable>
);
}
return (
<div key={message.id} className={cardClasses} role="button" tabIndex={-1}>
<Message
message={message}
channel={ChannelStore.getChannel(message.channelId)!}
previewContext={MessagePreviewContext.LIST_POPOUT}
/>
<div className={previewStyles.actionButtons}>
<FocusRing offset={-2}>
<button
type="button"
className={previewStyles.actionButton}
onClick={() => handleJump(message.channelId, message.id)}
>
{t`Jump`}
</button>
</FocusRing>
{renderUnpinButton(message)}
</div>
</div>
);
})}
{isLoading && (
<div className={previewStyles.loadingState}>
<Spinner />
</div>
)}
{!hasMore && (
<div className={previewStyles.endState}>
<div className={previewStyles.endStateContent}>
<FlagCheckeredIcon className={previewStyles.endStateIcon} />
<div className={previewStyles.endStateTextContainer}>
<h3 className={previewStyles.endStateTitle}>{t`You've reached the end`}</h3>
<p className={previewStyles.endStateDescription}>{endStateDescription}</p>
</div>
</div>
</div>
)}
</Scroller>
{mobileLayout.enabled && selectedMessage && (
<MenuBottomSheet
isOpen={menuOpen}
onClose={() => {
setMenuOpen(false);
setSelectedMessage(null);
}}
groups={[
{
items: [
{
icon: <PushPinSlashIcon className={previewStyles.menuIcon} />,
label: t`Unpin Message`,
onClick: () => handleUnpin(selectedMessage),
danger: true,
},
],
},
]}
/>
)}
</>
);
});

View File

@@ -0,0 +1,47 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
.preview {
width: 100%;
border-radius: var(--radius-lg);
border: 1px solid var(--background-modifier-accent);
background-color: var(--background-secondary);
overflow: hidden;
}
.image {
object-fit: cover;
object-position: center;
display: block;
}
.placeholder {
width: 100%;
border-radius: var(--radius-lg);
border: 1px dashed var(--background-modifier-accent);
background-color: var(--background-tertiary);
display: flex;
align-items: center;
justify-content: center;
padding: 1rem;
font-size: 0.875rem;
color: var(--text-primary-muted);
text-align: center;
min-height: 80px;
}

View File

@@ -0,0 +1,91 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type React from 'react';
import styles from './ImagePreviewField.module.css';
export interface ImagePreviewFieldProps {
imageUrl: string | null | undefined;
showPlaceholder: boolean;
placeholderText: React.ReactNode;
altText: string;
aspectRatio?: string | number;
className?: string;
objectFit?: 'cover' | 'contain';
}
export const ImagePreviewField: React.FC<ImagePreviewFieldProps> = ({
imageUrl,
showPlaceholder,
placeholderText,
altText,
aspectRatio,
className,
objectFit = 'cover',
}) => {
const innerContainerStyle: React.CSSProperties = aspectRatio
? {
position: 'relative',
width: '100%',
paddingBottom: `${(1 / Number(aspectRatio)) * 100}%`,
}
: {};
const imageContainerStyle: React.CSSProperties = aspectRatio
? {
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
}
: {};
const imageStyle: React.CSSProperties = {
objectFit,
width: '100%',
height: '100%',
};
if (showPlaceholder || !imageUrl) {
return (
<div className={`${styles.placeholder} ${className ?? ''}`} style={aspectRatio ? innerContainerStyle : {}}>
{aspectRatio ? (
<div style={imageContainerStyle}>
<span>{placeholderText}</span>
</div>
) : (
<span>{placeholderText}</span>
)}
</div>
);
}
return (
<div className={`${styles.preview} ${className ?? ''}`} style={aspectRatio ? innerContainerStyle : {}}>
{aspectRatio ? (
<div style={imageContainerStyle}>
<img src={imageUrl} alt={altText} className={styles.image} style={imageStyle} />
</div>
) : (
<img src={imageUrl} alt={altText} className={styles.image} style={imageStyle} />
)}
</div>
);
};

View File

@@ -0,0 +1,146 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
.channelHeader {
margin-top: 1rem;
margin-bottom: 0.5rem;
display: flex;
align-items: center;
gap: 0.5rem;
padding-left: 0.5rem;
padding-right: 0.5rem;
}
.channelHeader:first-child {
margin-top: 0;
}
.channelHeaderCompact {
margin-top: 0.5rem;
margin-bottom: 0.25rem;
}
.channelIcon {
height: 1.25rem;
width: 1.25rem;
flex-shrink: 0;
color: var(--text-primary-muted);
}
.channelIconAvatar {
display: inline-flex;
align-items: center;
justify-content: center;
height: 1.25rem;
width: 1.25rem;
flex-shrink: 0;
}
.channelIconAvatarImage {
height: 1.25rem;
width: 1.25rem;
border-radius: 50%;
}
.channelNameButton {
border: none;
background: none;
color: var(--text-primary);
font-weight: 600;
font-size: 0.875rem;
padding: 0;
cursor: pointer;
text-align: left;
font-family: inherit;
display: inline-flex;
flex-direction: column;
align-items: flex-start;
gap: 0.125rem;
}
.channelNameButton:hover,
.channelNameButton:focus-visible {
text-decoration: underline;
}
.channelNameButton:focus-visible {
outline: none;
}
.channelNameText {
display: inline-flex;
flex-direction: column;
align-items: flex-start;
gap: 0.125rem;
}
.channelNamePrimary {
font-weight: 600;
font-size: 0.875rem;
line-height: 1.2;
}
.channelScopeRow {
display: inline-flex;
align-items: center;
gap: 0.25rem;
font-size: 0.75rem;
color: var(--text-primary-muted);
}
.channelScopeGuildIcon {
display: inline-flex;
align-items: center;
justify-content: center;
--guild-icon-size: 0.75rem;
}
.channelScopeGuildName {
font-weight: 600;
color: var(--text-primary);
line-height: 1;
}
.channelScopeChevron {
height: 0.75rem;
width: 0.75rem;
color: var(--text-primary-muted);
}
.channelScopeChannelInfo {
display: inline-flex;
align-items: center;
gap: 0.25rem;
}
.channelScopeChannelIcon {
height: 0.75rem;
width: 0.75rem;
color: var(--text-primary-muted);
}
.channelScopeChannelName {
font-weight: 600;
color: var(--text-primary);
line-height: 1;
}
.focusRingTight {
border-radius: 0.5rem;
}

View File

@@ -0,0 +1,117 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {CaretRightIcon} from '@phosphor-icons/react';
import {observer} from 'mobx-react-lite';
import type React from 'react';
import {GroupDMAvatar} from '~/components/common/GroupDMAvatar';
import {GuildIcon} from '~/components/popouts/GuildIcon';
import {Avatar} from '~/components/uikit/Avatar';
import FocusRing from '~/components/uikit/FocusRing/FocusRing';
import type {ChannelRecord} from '~/records/ChannelRecord';
import GuildStore from '~/stores/GuildStore';
import UserStore from '~/stores/UserStore';
import * as ChannelUtils from '~/utils/ChannelUtils';
import styles from './MessageContextPrefix.module.css';
const getChannelDisplayName = (channel: ChannelRecord): string => {
if (channel.isPrivate()) {
return ChannelUtils.getDMDisplayName(channel);
}
return channel.name?.trim() || ChannelUtils.getName(channel);
};
const renderChannelIcon = (channel: ChannelRecord): React.ReactNode => {
if (channel.isPersonalNotes()) {
return ChannelUtils.getIcon(channel, {className: styles.channelIcon});
}
if (channel.isDM()) {
const recipientId = channel.recipientIds[0];
const recipient = recipientId ? UserStore.getUser(recipientId) : null;
if (recipient) {
return (
<div className={styles.channelIconAvatar}>
<Avatar user={recipient} size={20} status={null} className={styles.channelIconAvatarImage} />
</div>
);
}
return ChannelUtils.getIcon(channel, {className: styles.channelIcon});
}
if (channel.isGroupDM()) {
return (
<div className={styles.channelIconAvatar}>
<GroupDMAvatar channel={channel} size={20} disableStatusIndicator />
</div>
);
}
return ChannelUtils.getIcon(channel, {className: styles.channelIcon});
};
export interface MessageContextPrefixProps {
channel: ChannelRecord;
showGuildMeta?: boolean;
compact?: boolean;
onClick?: () => void;
}
export const MessageContextPrefix = observer(
({channel, showGuildMeta = false, compact = false, onClick}: MessageContextPrefixProps) => {
const guild = channel.guildId ? (GuildStore.getGuild(channel.guildId) ?? null) : null;
const effectiveShowGuildMeta = Boolean(showGuildMeta && guild);
const channelDisplayName = getChannelDisplayName(channel);
return (
<div className={[styles.channelHeader, compact && styles.channelHeaderCompact].filter(Boolean).join(' ')}>
{!effectiveShowGuildMeta && renderChannelIcon(channel)}
<FocusRing offset={-2} ringClassName={styles.focusRingTight}>
<button type="button" className={styles.channelNameButton} onClick={onClick}>
{effectiveShowGuildMeta ? (
<span className={styles.channelScopeRow}>
<GuildIcon
id={guild!.id}
name={guild!.name}
icon={guild!.icon}
className={styles.channelScopeGuildIcon}
sizePx={12}
/>
<span className={styles.channelScopeGuildName}>{guild!.name}</span>
<CaretRightIcon className={styles.channelScopeChevron} size={12} weight="bold" />
<span className={styles.channelScopeChannelInfo}>
{ChannelUtils.getIcon(channel, {className: styles.channelScopeChannelIcon})}
<span className={styles.channelScopeChannelName}>{channelDisplayName}</span>
</span>
</span>
) : (
<span className={styles.channelNameText}>
<span className={styles.channelNamePrimary}>{channelDisplayName}</span>
</span>
)}
</button>
</FocusRing>
</div>
);
},
);

View File

@@ -0,0 +1,239 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
.previewCard {
position: relative;
margin-bottom: 8px;
cursor: default;
user-select: none;
-webkit-user-select: none;
overflow: hidden;
flex-shrink: 0;
border-radius: 6px;
border: 1px solid var(--background-header-secondary);
background-color: var(--background-secondary);
padding-top: 8px;
padding-bottom: 8px;
}
.previewCardMobile {
margin-bottom: 12px;
cursor: pointer;
border-radius: 14px;
border: none;
background-color: var(--background-modifier-hover);
}
.actionButtons {
display: none;
position: absolute;
top: 12px;
right: 12px;
gap: 4px;
}
.previewCard:hover .actionButtons {
display: flex;
}
.actionButton {
position: relative;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
user-select: none;
-webkit-user-select: none;
font-weight: 600;
line-height: 20px;
padding: 0 8px;
font-size: 11px;
border-radius: 4px;
text-align: center;
color: var(--text-primary-muted);
background-color: var(--background-primary);
transition: color 0.2s;
}
.actionButton:hover {
color: var(--text-primary);
}
.actionIconButton {
position: relative;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
user-select: none;
-webkit-user-select: none;
padding: 2px;
border-radius: 4px;
color: var(--text-primary-muted);
background-color: var(--background-primary);
transition: color 0.2s;
}
.actionIconButton:hover {
color: var(--text-primary);
}
.actionIcon {
width: 16px;
height: 16px;
display: block;
}
.scroller {
flex: 1;
min-height: 0;
min-width: 0;
padding: 8px;
}
.scrollerMobile {
padding: 0 16px 16px;
}
.topSpacer {
height: 8px;
flex-shrink: 0;
}
.emptyState {
display: flex;
height: 100%;
min-height: 400px;
align-items: center;
justify-content: center;
}
.emptyStateContent {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
}
.emptyStateIcon {
height: 80px;
width: 80px;
color: var(--text-primary-muted);
}
.emptyStateTextContainer {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
text-align: center;
}
.emptyStateTitle {
font-weight: 600;
font-size: 20px;
line-height: 28px;
color: var(--text-primary);
}
.emptyStateDescription {
font-size: 14px;
line-height: 20px;
color: var(--text-primary-muted);
}
.endState {
display: flex;
height: 100%;
min-height: 200px;
align-items: center;
justify-content: center;
}
.endStateContent {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
}
.endStateIcon {
height: 48px;
width: 48px;
color: var(--text-primary-muted);
}
.endStateTextContainer {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
text-align: center;
}
.endStateTitle {
font-weight: 600;
font-size: 20px;
line-height: 28px;
color: var(--text-primary);
}
.endStateDescription {
font-size: 14px;
line-height: 20px;
color: var(--text-primary-muted);
}
.lostMessageInner {
display: flex;
align-items: center;
gap: 10px;
padding: 12px;
color: var(--text-warning);
}
.lostMessageIcon {
width: 20px;
height: 20px;
color: var(--text-warning);
}
.lostMessageText {
font-size: 14px;
line-height: 20px;
color: var(--text-warning);
}
.loadingState {
display: flex;
height: 80px;
align-items: center;
justify-content: center;
}
.loadingText {
font-size: 14px;
line-height: 20px;
color: var(--text-primary-muted);
}
.menuIcon {
height: 20px;
width: 20px;
}

View File

@@ -0,0 +1,48 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {useLingui} from '@lingui/react/macro';
import {WarningCircleIcon} from '@phosphor-icons/react';
import previewStyles from '~/components/shared/MessagePreview.module.css';
import FocusRing from '~/components/uikit/FocusRing/FocusRing';
interface SavedMessageMissingCardProps {
entryId: string;
onRemove: () => void;
}
export const SavedMessageMissingCard = ({entryId, onRemove}: SavedMessageMissingCardProps) => {
const {t} = useLingui();
return (
<div key={`lost-${entryId}`} className={previewStyles.previewCard}>
<div className={previewStyles.lostMessageInner}>
<WarningCircleIcon className={previewStyles.lostMessageIcon} weight="duotone" />
<p className={previewStyles.lostMessageText}>{t`You lost access to this saved message. Remove?`}</p>
</div>
<div className={previewStyles.actionButtons}>
<FocusRing offset={-2}>
<button type="button" className={previewStyles.actionButton} onClick={onRemove}>
{t`Remove`}
</button>
</FocusRing>
</div>
</div>
);
};