Browse Source

fix: disabled message persistance until after release

pull/586/head
Dan Ditomaso 1 year ago
parent
commit
3da5464c12
  1. 170
      src/components/PageComponents/Messages/MessageItem.tsx
  2. 4
      src/components/PageLayout.tsx
  3. 3
      src/components/Sidebar.tsx
  4. 302
      src/core/stores/messageStore/index.ts
  5. 5
      src/core/stores/messageStore/types.ts
  6. 6
      src/core/subscriptions.ts

170
src/components/PageComponents/Messages/MessageItem.tsx

@ -5,100 +5,89 @@ import {
TooltipProvider, TooltipProvider,
TooltipTrigger, TooltipTrigger,
} from "@components/UI/Tooltip.tsx"; } from "@components/UI/Tooltip.tsx";
import { useDevice, useDeviceStore } from "@core/stores/deviceStore.ts"; import { useDevice } from "@core/stores/deviceStore.ts";
import { cn } from "@core/utils/cn.ts"; import { cn } from "@core/utils/cn.ts";
import { Avatar } from "@components/UI/Avatar.tsx"; import { Avatar } from "@components/UI/Avatar.tsx";
import { AlertCircle, CheckCircle2, CircleEllipsis } from "lucide-react"; import { AlertCircle, CheckCircle2, CircleEllipsis } from "lucide-react";
import type { LucideIcon } from "lucide-react"; import type { LucideIcon } from "lucide-react";
import { ReactNode, useMemo } from "react"; import { ReactNode, useMemo } from "react";
import { Message, MessageState } from "../../../core/stores/messageStore/index.ts"; import { MessageState, useMessageStore } from "@core/stores/messageStore/index.ts";
import { Protobuf } from "@meshtastic/js"; import { Protobuf, Types } from "@meshtastic/js";
import { MessageActionsMenu } from "@components/PageComponents/Messages/MessageActionsMenu.tsx"; import { Message } from "@core/stores/messageStore/types.ts";
// import { MessageActionsMenu } from "@components/PageComponents/Messages/MessageActionsMenu.tsx"; // Uncomment if needed later
interface MessageProps { interface MessageStatusInfo {
message: Message;
// locale?: string; // locale
}
interface MessageStatus {
state: MessageState;
displayText: string; displayText: string;
icon: LucideIcon; icon: LucideIcon;
ariaLabel: string; ariaLabel: string;
iconClassName?: string; iconClassName?: string;
} }
const MESSAGE_STATUS: Record<MessageState, MessageStatus> = { const MESSAGE_STATUS_MAP: Record<MessageState, MessageStatusInfo> = {
[MessageState.Ack]: { state: MessageState.Ack, displayText: "Message delivered", icon: CheckCircle2, ariaLabel: "Message delivered", iconClassName: "text-green-500 dark:text-green-500" }, [MessageState.Ack]: { displayText: "Message delivered", icon: CheckCircle2, ariaLabel: "Message delivered", iconClassName: "text-green-500" },
[MessageState.Waiting]: { state: MessageState.Waiting, displayText: "Waiting for delivery", icon: CircleEllipsis, ariaLabel: "Sending message", iconClassName: "text-slate-400 dark:text-slate-400" }, [MessageState.Waiting]: { displayText: "Waiting for delivery", icon: CircleEllipsis, ariaLabel: "Sending message", iconClassName: "text-slate-400" },
[MessageState.Failed]: { state: MessageState.Failed, displayText: "Delivery failed", icon: AlertCircle, ariaLabel: "Message delivery failed", iconClassName: "text-red-500 dark:text-red-400" }, [MessageState.Failed]: { displayText: "Delivery failed", icon: AlertCircle, ariaLabel: "Message delivery failed", iconClassName: "text-red-500 dark:text-red-400" },
}; };
const getMessageStatus = (state: MessageState): MessageStatus => const UNKNOWN_STATUS: MessageStatusInfo = { displayText: "Unknown state", icon: AlertCircle, ariaLabel: "Message status unknown", iconClassName: "text-red-500 dark:text-red-400" };
MESSAGE_STATUS[state] ?? { state: MessageState.Failed, displayText: "Unknown state", icon: AlertCircle, ariaLabel: "Message status unknown" };
const StatusTooltip = ({ status, children }: { status: MessageStatus; children: ReactNode }) => ( const getMessageStatusInfo = (state: MessageState): MessageStatusInfo =>
MESSAGE_STATUS_MAP[state] ?? UNKNOWN_STATUS;
const StatusTooltip = ({ statusInfo, children }: { statusInfo: MessageStatusInfo; children: ReactNode }) => (
<TooltipProvider delayDuration={300}> <TooltipProvider delayDuration={300}>
<Tooltip> <Tooltip>
<TooltipTrigger asChild>{children}</TooltipTrigger> <TooltipTrigger asChild>{children}</TooltipTrigger>
<TooltipContent className="bg-slate-800 dark:bg-slate-600 text-white px-4 py-1 rounded text-xs"> <TooltipContent className="bg-slate-800 dark:bg-slate-600 text-white px-4 py-1 rounded text-xs">
{status.displayText} {statusInfo.displayText}
<TooltipArrow className="fill-slate-800" /> <TooltipArrow className="fill-slate-800" />
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
</TooltipProvider> </TooltipProvider>
); );
const StatusIcon = ({ status }: { status: MessageStatus }) => { interface MessageItemProps {
const Icon = status.icon; message: Message;
const iconClass = cn("size-4 shrink-0", status.iconClassName); }
return (
<StatusTooltip status={status}>
<span aria-label={status.ariaLabel} role="img">
<Icon className={iconClass} aria-hidden="true" />
</span>
</StatusTooltip>
);
};
const TimeDisplay = ({ date, className }: { date: number; className?: string }) => { export const MessageItem = ({ message }: MessageItemProps) => {
const _date = useMemo(() => new Date(date), [date]); const { getNode } = useDevice();
const locale = 'en-US'; // TODO: Make dynamic const { getMyNodeNum } = useMessageStore()
const formattedTime = useMemo(() => _date.toLocaleTimeString(locale, { hour: 'numeric', minute: '2-digit', hour12: true }), [_date, locale]);
const fullDate = useMemo(() => _date.toLocaleString(locale, { dateStyle: 'medium', timeStyle: 'short' }), [_date, locale]); const messageUser: Protobuf.Mesh.NodeInfo | null | undefined = useMemo(() => {
return message.from != null ? getNode(message.from) : null;
}, [getNode, message.from]);
return (
<time dateTime={_date.toISOString()} className={cn("text-xs", className)}>
<span aria-hidden="true">{formattedTime}</span>
<span className="sr-only">{fullDate}</span>
</time>
);
};
export const MessageItem = ({ message }: MessageProps) => { const myNodeNum = useMemo(() => getMyNodeNum(), [getMyNodeNum]);
const { getDevices } = useDeviceStore(); const { displayName, shortName } = useMemo(() => {
const { getNode } = useDevice() const userIdHex = message.from.toString(16).toUpperCase().padStart(2, '0');
const last4 = userIdHex.slice(-4);
const messageUser: Protobuf.Mesh.NodeInfo | null = useMemo(() => { const fallbackName = `Meshtastic ${last4}`
if (message?.from === null || message?.from === undefined) return null;
const fromNode = getNode(message.from);
if (fromNode) {
return fromNode ?? null;
}
return null;
}, [getDevices, message.from]);
const { shortName, displayName, } = useMemo(() => {
const fallbackName = message.from
const longName = messageUser?.user?.longName; const longName = messageUser?.user?.longName;
const shortName = messageUser?.user?.shortName ?? fallbackName; const derivedShortName = messageUser?.user?.shortName || fallbackName;
const displayName = longName || fallbackName; const derivedDisplayName = longName || derivedShortName;
return { shortName, displayName, from: message.from }; return { displayName: derivedDisplayName, shortName: derivedShortName };
}, [messageUser, message.from]); }, [messageUser, message.from]);
const messageStatus = getMessageStatus(message.state); const messageStatusInfo = getMessageStatusInfo(message.state);
const messageText = message?.message ?? ""; const StatusIconComponent = messageStatusInfo.icon;
const messageDate = message?.date;
const messageDate = useMemo(() => message.date ? new Date(message.date) : null, [message.date]);
const locale = 'en-US'; // TODO: Make dynamic via props or context
const formattedTime = useMemo(() =>
messageDate?.toLocaleTimeString(locale, { hour: 'numeric', minute: '2-digit', hour12: true }) ?? '',
[messageDate, locale]);
const fullDateTime = useMemo(() =>
messageDate?.toLocaleString(locale, { dateStyle: 'medium', timeStyle: 'short' }) ?? '',
[messageDate, locale]);
const isSender = myNodeNum !== undefined && message.from === myNodeNum;
const isOnPrimaryChannel = message.channel === Types.ChannelNumber.Primary; // Use the enum
const shouldShowStatusIcon = isSender && isOnPrimaryChannel;
const messageItemWrapperClass = cn( const messageItemWrapperClass = cn(
"group w-full py-2 relative list-none", "group w-full py-2 relative list-none",
@ -106,39 +95,48 @@ export const MessageItem = ({ message }: MessageProps) => {
"hover:bg-slate-300/15 dark:hover:bg-slate-600/20", "hover:bg-slate-300/15 dark:hover:bg-slate-600/20",
"transition-colors duration-100 ease-in-out", "transition-colors duration-100 ease-in-out",
); );
const dateTextStyle = "text-xs text-slate-500 dark:text-slate-400";
const gridGapClass = "gap-x-2";
const baseTextStyle = "text-sm text-slate-800 dark:text-slate-200";
const nameTextStyle = "font-medium text-slate-900 dark:text-slate-100 mr-2";
const dateTextStyle = "text-slate-500 dark:text-slate-400";
return ( return (
<li className={messageItemWrapperClass}> <li className={messageItemWrapperClass}>
<div className={cn("grid grid-cols-[auto_1fr]", gridGapClass)}> <div className="grid grid-cols-[auto_1fr] gap-x-2">
<Avatar size="sm" text={shortName} className="pt-0.5" /> <Avatar size="sm" text={shortName} className="pt-0.5" />
<div className="flex flex-col gap-1.5 min-w-0"> <div className="flex flex-col gap-0.5 min-w-0">
{messageDate != null ? ( <div className="flex items-center gap-1.5">
<div className="flex items-center gap-1.5"> <span className="font-medium text-sm text-slate-900 dark:text-slate-100 truncate mr-1">
<span className={nameTextStyle} aria-hidden="true"> {displayName}
{displayName} </span>
</span> {messageDate && (
<TimeDisplay date={messageDate} className={dateTextStyle} /> <time dateTime={messageDate.toISOString()} className={dateTextStyle}>
<StatusIcon status={messageStatus} /> <span aria-hidden="true">{formattedTime}</span>
</div> <span className="sr-only">{fullDateTime}</span>
) : null} </time>
)}
<div className={cn(baseTextStyle, "whitespace-pre-wrap")}> {shouldShowStatusIcon && (
{messageText} <StatusTooltip statusInfo={messageStatusInfo}>
<span aria-label={messageStatusInfo.ariaLabel} role="img">
<StatusIconComponent
className={cn("size-4 shrink-0", messageStatusInfo.iconClassName)}
aria-hidden="true"
/>
</span>
</StatusTooltip>
)}
</div> </div>
{message?.message && (
<div className="text-sm text-slate-800 dark:text-slate-200 whitespace-pre-wrap break-words">
{message.message}
</div>
)}
</div> </div>
</div> </div>
{/* TODO: Restore when reactions/emojis are functional */} {/* Actions Menu Placeholder */}
{/* <MessageActionsMenu {/* <div className="absolute top-1 right-1">
onReply={() => console.log("Reply to message:", message.messageId)} <MessageActionsMenu onReply={() => console.log("Reply")} />
/> */} </div> */}
</li> </li>
); );
}; };

4
src/components/PageLayout.tsx

@ -48,7 +48,7 @@ export const PageLayout = ({
{leftBar && ( {leftBar && (
<aside <aside
className={cn( className={cn(
"px-2 shrink-0 border-r-[0.5px] border-slate-300 dark:border-slate-700 ", "px-2 pr-0 shrink-0 border-r-[0.5px] border-slate-300 dark:border-slate-700 ",
leftBarClassName leftBarClassName
)} )}
> >
@ -113,7 +113,7 @@ export const PageLayout = ({
{rightBar && ( {rightBar && (
<aside <aside
className={cn( className={cn(
"w-64 shrink-0 border-l border-slate-300 dark:border-slate-700 px-2 overflow-hidden", "w-48 lg:w-[270px] shrink-0 border-l border-slate-300 dark:border-slate-700 px-2 overflow-hidden",
rightBarClassName rightBarClassName
)} )}
> >

3
src/components/Sidebar.tsx

@ -46,7 +46,7 @@ const CollapseToggleButton = () => {
aria-label={buttonLabel} aria-label={buttonLabel}
onClick={toggleSidebar} onClick={toggleSidebar}
className={cn( className={cn(
'absolute top-21 -right-2 z-10 p-0.5 rounded-full transform translate-x-1/2', 'absolute top-20 right-0 z-10 p-0.5 rounded-full transform translate-x-1/2',
'transition-colors duration-300 ease-in-out', 'transition-colors duration-300 ease-in-out',
'border border-slate-300 dark:border-slate-200', 'border border-slate-300 dark:border-slate-200',
'text-slate-500 dark:text-slate-200 hover:text-slate-400 dark:hover:text-slate-400', 'text-slate-500 dark:text-slate-200 hover:text-slate-400 dark:hover:text-slate-400',
@ -137,7 +137,6 @@ export const Sidebar = ({ children }: SidebarProps) => {
</SidebarSection> </SidebarSection>
<div className={cn( <div className={cn(
'',
'flex-1 min-h-0', 'flex-1 min-h-0',
isCollapsed && 'overflow-hidden' isCollapsed && 'overflow-hidden'
)} )}

302
src/core/stores/messageStore/index.ts

@ -50,163 +50,163 @@ export interface MessageStore {
const CURRENT_STORE_VERSION = 0; const CURRENT_STORE_VERSION = 0;
export const useMessageStore = create<MessageStore>()( export const useMessageStore = create<MessageStore>()(
persist( // persist(
(set, get) => ({ (set, get) => ({
messages: { messages: {
direct: new Map<ConversationId, MessageLogMap>(), direct: new Map<ConversationId, MessageLogMap>(),
broadcast: new Map<ChannelId, MessageLogMap>(), broadcast: new Map<ChannelId, MessageLogMap>(),
}, },
draft: new Map<number, string>(), draft: new Map<number, string>(),
activeChat: 0, activeChat: 0,
chatType: MessageType.Broadcast, chatType: MessageType.Broadcast,
nodeNum: 0, nodeNum: 0,
setNodeNum: (nodeNum) => { setNodeNum: (nodeNum) => {
set(produce((state: MessageStore) => { set(produce((state: MessageStore) => {
state.nodeNum = nodeNum; state.nodeNum = nodeNum;
})); }));
}, },
getMyNodeNum: () => get().nodeNum, getMyNodeNum: () => get().nodeNum,
setActiveChat: (chat) => { setActiveChat: (chat) => {
set(produce((state: MessageStore) => { set(produce((state: MessageStore) => {
state.activeChat = chat; state.activeChat = chat;
})); }));
}, },
setChatType: (type) => { setChatType: (type) => {
set(produce((state: MessageStore) => { set(produce((state: MessageStore) => {
state.chatType = type; state.chatType = type;
})); }));
}, },
saveMessage: (message: Message) => { saveMessage: (message: Message) => {
set( set(
produce((state: MessageStore) => { produce((state: MessageStore) => {
if (message.type === MessageType.Direct) { if (message.type === MessageType.Direct) {
const conversationId = getConversationId(message.from, message.to); const conversationId = getConversationId(message.from, message.to);
if (!state.messages.direct.has(conversationId)) { if (!state.messages.direct.has(conversationId)) {
state.messages.direct.set(conversationId, new Map<MessageId, Message>()); state.messages.direct.set(conversationId, new Map<MessageId, Message>());
}
state.messages.direct.get(conversationId)!.set(message.messageId, message);
} else if (message.type === MessageType.Broadcast) {
const channelId = message.channel as ChannelId;
if (!state.messages.broadcast.has(channelId)) {
state.messages.broadcast.set(channelId, new Map<MessageId, Message>());
}
state.messages.broadcast.get(channelId)!.set(message.messageId, message);
} }
}) state.messages.direct.get(conversationId)!.set(message.messageId, message);
); } else if (message.type === MessageType.Broadcast) {
}, const channelId = message.channel as ChannelId;
if (!state.messages.broadcast.has(channelId)) {
setMessageState: (params: SetMessageStateParams) => { state.messages.broadcast.set(channelId, new Map<MessageId, Message>());
set(
produce((state: MessageStore) => {
let messageLog: MessageLogMap | undefined;
let targetMessage: Message | undefined;
if (params.type === MessageType.Direct) {
const conversationId = getConversationId(params.nodeA, params.nodeB);
messageLog = state.messages.direct.get(conversationId);
if (messageLog) {
targetMessage = messageLog.get(params.messageId);
}
} else { // Broadcast
messageLog = state.messages.broadcast.get(params.channelId);
if (messageLog) {
targetMessage = messageLog.get(params.messageId);
}
} }
state.messages.broadcast.get(channelId)!.set(message.messageId, message);
if (targetMessage) { }
targetMessage.state = params.newState ?? MessageState.Ack; })
} else { );
console.warn(`Message or conversation/channel not found for state update. Params: ${JSON.stringify(params)}`); },
setMessageState: (params: SetMessageStateParams) => {
set(
produce((state: MessageStore) => {
let messageLog: MessageLogMap | undefined;
let targetMessage: Message | undefined;
if (params.type === MessageType.Direct) {
const conversationId = getConversationId(params.nodeA, params.nodeB);
messageLog = state.messages.direct.get(conversationId);
if (messageLog) {
targetMessage = messageLog.get(params.messageId);
} }
}) } else { // Broadcast
); messageLog = state.messages.broadcast.get(params.channelId);
}, if (messageLog) {
getMessages: (params: GetMessagesParams): Message[] => { targetMessage = messageLog.get(params.messageId);
const state = get();
let messageMap: MessageLogMap | undefined;
if (params.type === MessageType.Direct) {
const conversationId = getConversationId(params.nodeA, params.nodeB);
messageMap = state.messages.direct.get(conversationId);
} else {
messageMap = state.messages.broadcast.get(params.channelId);
}
if (messageMap === undefined) {
return [];
}
const messagesArray = Array.from(messageMap.values());
messagesArray.sort((a, b) => a.date - b.date);
return messagesArray;
},
clearMessageByMessageId: (params: ClearMessageParams) => {
set(
produce((state: MessageStore) => {
let messageLog: MessageLogMap | undefined;
let parentMap: Map<ConversationId | ChannelId, MessageLogMap>;
let parentKey: ConversationId | ChannelId;
if (params.type === MessageType.Direct) {
parentKey = getConversationId(params.nodeA, params.nodeB);
parentMap = state.messages.direct;
messageLog = parentMap.get(parentKey);
} else {
parentKey = params.channelId;
parentMap = state.messages.broadcast;
messageLog = parentMap.get(parentKey);
} }
}
if (targetMessage) {
targetMessage.state = params.newState ?? MessageState.Ack;
} else {
console.warn(`Message or conversation/channel not found for state update. Params: ${JSON.stringify(params)}`);
}
})
);
},
getMessages: (params: GetMessagesParams): Message[] => {
const state = get();
let messageMap: MessageLogMap | undefined;
if (params.type === MessageType.Direct) {
const conversationId = getConversationId(params.nodeA, params.nodeB);
messageMap = state.messages.direct.get(conversationId);
} else {
messageMap = state.messages.broadcast.get(params.channelId);
}
if (messageLog) { if (messageMap === undefined) {
const deleted = messageLog.delete(params.messageId); return [];
}
if (deleted) {
console.log(`Deleted message ${params.messageId} from ${params.type} message ${parentKey}`); const messagesArray = Array.from(messageMap.values());
// Clean up empty MessageLogMap and its entry in the parent map messagesArray.sort((a, b) => a.date - b.date);
if (messageLog.size === 0) { return messagesArray;
parentMap.delete(parentKey); },
console.log(`Cleaned up empty message entry for ${parentKey}`);
} clearMessageByMessageId: (params: ClearMessageParams) => {
} else { set(
console.warn(`Message ${params.messageId} not found in ${params.type} chat ${parentKey} for deletion.`); produce((state: MessageStore) => {
let messageLog: MessageLogMap | undefined;
let parentMap: Map<ConversationId | ChannelId, MessageLogMap>;
let parentKey: ConversationId | ChannelId;
if (params.type === MessageType.Direct) {
parentKey = getConversationId(params.nodeA, params.nodeB);
parentMap = state.messages.direct;
messageLog = parentMap.get(parentKey);
} else {
parentKey = params.channelId;
parentMap = state.messages.broadcast;
messageLog = parentMap.get(parentKey);
}
if (messageLog) {
const deleted = messageLog.delete(params.messageId);
if (deleted) {
console.log(`Deleted message ${params.messageId} from ${params.type} message ${parentKey}`);
// Clean up empty MessageLogMap and its entry in the parent map
if (messageLog.size === 0) {
parentMap.delete(parentKey);
console.log(`Cleaned up empty message entry for ${parentKey}`);
} }
} else { } else {
console.warn(`Message entry ${parentKey} not found for message deletion.`); console.warn(`Message ${params.messageId} not found in ${params.type} chat ${parentKey} for deletion.`);
} }
}) } else {
); console.warn(`Message entry ${parentKey} not found for message deletion.`);
}, }
getDraft: (key) => { })
return get().draft.get(key) ?? ''; );
}, },
setDraft: (key, message) => { getDraft: (key) => {
set(produce((state: MessageStore) => { return get().draft.get(key) ?? '';
state.draft.set(key, message); },
})); setDraft: (key, message) => {
}, set(produce((state: MessageStore) => {
clearDraft: (key) => { state.draft.set(key, message);
set(produce((state: MessageStore) => { }));
state.draft.delete(key); },
})); clearDraft: (key) => {
}, set(produce((state: MessageStore) => {
deleteAllMessages: () => { state.draft.delete(key);
set(produce((state: MessageStore) => { }));
state.messages.direct = new Map<ConversationId, MessageLogMap>(); },
state.messages.broadcast = new Map<ChannelId, MessageLogMap>(); deleteAllMessages: () => {
})); set(produce((state: MessageStore) => {
} state.messages.direct = new Map<ConversationId, MessageLogMap>();
}), state.messages.broadcast = new Map<ChannelId, MessageLogMap>();
{ }));
name: 'meshtastic-message-store', }
storage: storageWithMapSupport, }),
version: CURRENT_STORE_VERSION, // {
partialize: (state) => ({ // name: 'meshtastic-message-store',
messages: state.messages, // storage: storageWithMapSupport,
nodeNum: state.nodeNum, // version: CURRENT_STORE_VERSION,
}), // partialize: (state) => ({
}) // messages: state.messages,
// nodeNum: state.nodeNum,
// }),
// })
) )

5
src/core/stores/messageStore/types.ts

@ -7,7 +7,7 @@ type ChannelId = Types.ChannelNumber;
type ConversationId = string; type ConversationId = string;
type MessageLogMap = Map<MessageId, Message>; type MessageLogMap = Map<MessageId, Message>;
export interface MessageBase { interface MessageBase {
channel: Types.ChannelNumber; channel: Types.ChannelNumber;
to: number; to: number;
from: number; from: number;
@ -21,7 +21,7 @@ interface GenericMessage<T extends MessageType> extends MessageBase {
type: T; type: T;
} }
export type Message = GenericMessage<MessageType.Direct> | GenericMessage<MessageType.Broadcast>; type Message = GenericMessage<MessageType.Direct> | GenericMessage<MessageType.Broadcast>;
type GetMessagesParams = type GetMessagesParams =
@ -58,6 +58,7 @@ type ClearMessageParams =
}; };
export type { export type {
Message,
ConversationId, ConversationId,
NodeNum, NodeNum,
MessageLogMap, MessageLogMap,

6
src/core/subscriptions.ts

@ -69,10 +69,8 @@ export const subscribeAll = (
}); });
connection.events.onNodeInfoPacket.subscribe((nodeInfo) => { connection.events.onNodeInfoPacket.subscribe((nodeInfo) => {
// const nodeWithUser = NodeInfoFactory.ensureDefaultUser(nodeInfo); const nodeWithUser = NodeInfoFactory.ensureDefaultUser(nodeInfo);
// console.log("Node Info", nodeWithUser); device.addNodeInfo(nodeWithUser);
device.addNodeInfo(nodeInfo);
}); });
connection.events.onChannelPacket.subscribe((channel) => { connection.events.onChannelPacket.subscribe((channel) => {

Loading…
Cancel
Save