init commit,
This commit is contained in:
@@ -0,0 +1,11 @@
|
||||
import { useAppSelector } from "../../store";
|
||||
import { loggedInSelector } from "../auth/authSelectors";
|
||||
import InitialPageRedirectBootstrapper from "../community/list/InitialPageRedirectBootstrapper";
|
||||
|
||||
export default function BoxesRedirectBootstrapper() {
|
||||
const loggedIn = useAppSelector(loggedInSelector);
|
||||
|
||||
if (!loggedIn) return;
|
||||
|
||||
return <InitialPageRedirectBootstrapper to="/inbox/all" />;
|
||||
}
|
341
99_references/voyager-main/src/features/inbox/InboxItem.tsx
Normal file
341
99_references/voyager-main/src/features/inbox/InboxItem.tsx
Normal file
@@ -0,0 +1,341 @@
|
||||
import {
|
||||
CommentReplyView,
|
||||
PersonMentionView,
|
||||
PrivateMessageView,
|
||||
} from "lemmy-js-client";
|
||||
import CommentMarkdown from "../comment/CommentMarkdown";
|
||||
import { IonIcon, IonItem } from "@ionic/react";
|
||||
import { albums, chatbubble, mail, personCircle } from "ionicons/icons";
|
||||
import Ago from "../labels/Ago";
|
||||
import { useBuildGeneralBrowseLink } from "../../helpers/routes";
|
||||
import { getHandle } from "../../helpers/lemmy";
|
||||
import { useAppDispatch, useAppSelector } from "../../store";
|
||||
import { getInboxItemId, markRead as markReadAction } from "./inboxSlice";
|
||||
import { isPostReply } from "../../routes/pages/inbox/RepliesPage";
|
||||
import { maxWidthCss } from "../shared/AppContent";
|
||||
import VoteArrow from "./VoteArrow";
|
||||
import SlidingInbox from "../shared/sliding/SlidingInbox";
|
||||
import useAppToast from "../../helpers/useAppToast";
|
||||
import InboxItemMoreActions, {
|
||||
InboxItemMoreActionsHandle,
|
||||
} from "./InboxItemMoreActions";
|
||||
import { styled } from "@linaria/react";
|
||||
import { css, cx } from "@linaria/core";
|
||||
import { isTouchDevice } from "../../helpers/device";
|
||||
import PersonLink from "../labels/links/PersonLink";
|
||||
import CommunityLink from "../labels/links/CommunityLink";
|
||||
import { useCallback, useRef } from "react";
|
||||
import { useLongPress } from "use-long-press";
|
||||
import { filterEvents } from "../../helpers/longPress";
|
||||
import { stopIonicTapClick } from "../../helpers/ionic";
|
||||
|
||||
const labelStyles = css`
|
||||
display: inline-flex;
|
||||
max-width: 100%;
|
||||
|
||||
font-weight: 500;
|
||||
|
||||
a {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
`;
|
||||
|
||||
const Hr = styled.div`
|
||||
${maxWidthCss}
|
||||
|
||||
position: relative;
|
||||
height: 1px;
|
||||
|
||||
&::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
|
||||
--right-offset: calc(23px + 1lh);
|
||||
|
||||
width: calc(100% - var(--right-offset));
|
||||
left: var(--right-offset);
|
||||
top: 0;
|
||||
border-bottom: 1px solid
|
||||
var(
|
||||
--ion-item-border-color,
|
||||
var(--ion-border-color, var(--ion-background-color-step-250, #c8c7cc))
|
||||
);
|
||||
}
|
||||
`;
|
||||
|
||||
const StyledIonItem = styled(IonItem)`
|
||||
--ion-item-border-color: transparent;
|
||||
--padding-start: 12px;
|
||||
`;
|
||||
|
||||
const itemUnreadCss = css`
|
||||
--background: var(--unread-item-background-color);
|
||||
`;
|
||||
|
||||
const Container = styled.div`
|
||||
display: flex;
|
||||
gap: var(--padding-start);
|
||||
|
||||
${maxWidthCss}
|
||||
|
||||
padding: 0.5rem 0;
|
||||
|
||||
font-size: 0.875em;
|
||||
|
||||
strong {
|
||||
font-weight: 500;
|
||||
}
|
||||
`;
|
||||
|
||||
const StartContent = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--padding-start);
|
||||
|
||||
ion-icon {
|
||||
width: 1lh;
|
||||
height: 1lh;
|
||||
}
|
||||
`;
|
||||
|
||||
const TypeIcon = styled(IonIcon)`
|
||||
color: var(--ion-color-medium2);
|
||||
`;
|
||||
|
||||
const Content = styled.div`
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
`;
|
||||
|
||||
const Header = styled.div``;
|
||||
|
||||
const Body = styled.div`
|
||||
color: var(--ion-color-medium);
|
||||
`;
|
||||
|
||||
const Footer = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
|
||||
color: var(--ion-color-medium);
|
||||
|
||||
> div {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
> aside {
|
||||
margin-left: auto;
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
|
||||
color: var(--ion-color-medium2);
|
||||
}
|
||||
`;
|
||||
|
||||
export type InboxItemView =
|
||||
| PersonMentionView
|
||||
| CommentReplyView
|
||||
| PrivateMessageView;
|
||||
|
||||
interface InboxItemProps {
|
||||
item: InboxItemView;
|
||||
}
|
||||
|
||||
export default function InboxItem({ item }: InboxItemProps) {
|
||||
const buildGeneralBrowseLink = useBuildGeneralBrowseLink();
|
||||
const dispatch = useAppDispatch();
|
||||
const readByInboxItemId = useAppSelector(
|
||||
(state) => state.inbox.readByInboxItemId,
|
||||
);
|
||||
const presentToast = useAppToast();
|
||||
const commentVotesById = useAppSelector(
|
||||
(state) => state.comment.commentVotesById,
|
||||
);
|
||||
|
||||
const vote =
|
||||
"comment" in item
|
||||
? (commentVotesById[item.comment.id] ??
|
||||
(item.my_vote as 1 | 0 | -1 | undefined))
|
||||
: undefined;
|
||||
|
||||
function renderHeader() {
|
||||
if ("person_mention" in item) {
|
||||
return (
|
||||
<>
|
||||
<strong>{item.creator.name}</strong> mentioned you on the post{" "}
|
||||
<strong>{item.post.name}</strong>
|
||||
</>
|
||||
);
|
||||
}
|
||||
if ("comment_reply" in item) {
|
||||
if (isPostReply(item)) {
|
||||
return (
|
||||
<>
|
||||
<strong>{item.creator.name}</strong> replied to your post{" "}
|
||||
<strong>{item.post.name}</strong>
|
||||
</>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<>
|
||||
<strong>{item.creator.name}</strong> replied to your comment in{" "}
|
||||
<strong>{item.post.name}</strong>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
if ("private_message" in item) {
|
||||
return (
|
||||
<>
|
||||
<strong>{getHandle(item.creator)}</strong> sent you a private message
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function renderContents() {
|
||||
if ("comment" in item) {
|
||||
return item.comment.content;
|
||||
}
|
||||
|
||||
return item.private_message.content;
|
||||
}
|
||||
|
||||
function renderFooterDetails() {
|
||||
if ("comment" in item) {
|
||||
return (
|
||||
<>
|
||||
<PersonLink
|
||||
person={item.creator}
|
||||
className={labelStyles}
|
||||
showBadge={false}
|
||||
/>{" "}
|
||||
in{" "}
|
||||
<CommunityLink
|
||||
community={item.community}
|
||||
subscribed={item.subscribed}
|
||||
hideIcon
|
||||
className={labelStyles}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function getLink() {
|
||||
if ("comment" in item) {
|
||||
return buildGeneralBrowseLink(
|
||||
`/c/${getHandle(item.community)}/comments/${item.post.id}/${
|
||||
item.comment.path
|
||||
}`,
|
||||
);
|
||||
}
|
||||
|
||||
return `/inbox/messages/${getHandle(item.creator)}`;
|
||||
}
|
||||
|
||||
function getDate() {
|
||||
if ("comment" in item) return item.counts.published;
|
||||
|
||||
return item.private_message.published;
|
||||
}
|
||||
|
||||
function getIcon() {
|
||||
if ("person_mention" in item) return personCircle;
|
||||
if ("comment_reply" in item) {
|
||||
if (isPostReply(item)) return albums;
|
||||
return chatbubble;
|
||||
}
|
||||
if ("private_message" in item) return mail;
|
||||
}
|
||||
|
||||
async function markRead() {
|
||||
try {
|
||||
await dispatch(markReadAction(item, true));
|
||||
} catch (error) {
|
||||
presentToast({
|
||||
message: "Failed to mark item as read",
|
||||
color: "danger",
|
||||
});
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
const read = !!readByInboxItemId[getInboxItemId(item)];
|
||||
|
||||
const ellipsisHandleRef = useRef<InboxItemMoreActionsHandle>(null);
|
||||
|
||||
const onCommentLongPress = useCallback(() => {
|
||||
ellipsisHandleRef.current?.present();
|
||||
stopIonicTapClick();
|
||||
}, []);
|
||||
|
||||
const bind = useLongPress(onCommentLongPress, {
|
||||
threshold: 800,
|
||||
cancelOnMovement: 15,
|
||||
filterEvents,
|
||||
});
|
||||
|
||||
const contents = (
|
||||
<StyledIonItem
|
||||
mode="ios" // Use iOS style activatable tap highlight
|
||||
className={cx(
|
||||
!read && itemUnreadCss,
|
||||
isTouchDevice() && "ion-activatable",
|
||||
)}
|
||||
routerLink={getLink()}
|
||||
href={undefined}
|
||||
detail={false}
|
||||
onClick={markRead}
|
||||
{...bind()}
|
||||
>
|
||||
<Container>
|
||||
<StartContent>
|
||||
<TypeIcon icon={getIcon()} />
|
||||
<VoteArrow vote={vote} />
|
||||
</StartContent>
|
||||
<Content>
|
||||
<Header>{renderHeader()}</Header>
|
||||
<Body>
|
||||
<CommentMarkdown id={getItemId(item)}>
|
||||
{renderContents()}
|
||||
</CommentMarkdown>
|
||||
</Body>
|
||||
<Footer>
|
||||
<div>{renderFooterDetails()}</div>
|
||||
<aside>
|
||||
<InboxItemMoreActions item={item} ref={ellipsisHandleRef} />
|
||||
<Ago date={getDate()} />
|
||||
</aside>
|
||||
</Footer>
|
||||
</Content>
|
||||
</Container>
|
||||
</StyledIonItem>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<SlidingInbox item={item}>{contents}</SlidingInbox>
|
||||
<Hr />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function getItemId(item: InboxItemView): string {
|
||||
switch (true) {
|
||||
case "person_mention" in item:
|
||||
return `mention-${item.person_mention.id}`;
|
||||
case "comment_reply" in item:
|
||||
return `comment-reply-${item.comment_reply.id}`;
|
||||
case "private_message" in item:
|
||||
return `private-message-${item.private_message.id}`;
|
||||
}
|
||||
|
||||
// typescript should be smarter (this shouldn't be necessary)
|
||||
throw new Error("getItemId: Unexpected item");
|
||||
}
|
@@ -0,0 +1,62 @@
|
||||
import { mailOutline, mailUnreadOutline } from "ionicons/icons";
|
||||
import { InboxItemView } from "./InboxItem";
|
||||
import MoreActions from "../comment/CommentEllipsis";
|
||||
import { forwardRef, useMemo } from "react";
|
||||
import { styled } from "@linaria/react";
|
||||
import { PlainButton } from "../shared/PlainButton";
|
||||
import { useAppDispatch, useAppSelector } from "../../store";
|
||||
import { getInboxItemId, markRead } from "./inboxSlice";
|
||||
import PrivateMessageMoreActions from "./PrivateMessageMoreActions";
|
||||
|
||||
const StyledPlainButton = styled(PlainButton)`
|
||||
font-size: 1.12em;
|
||||
`;
|
||||
|
||||
interface InboxItemMoreActionsProps {
|
||||
item: InboxItemView;
|
||||
}
|
||||
|
||||
export type InboxItemMoreActionsHandle = {
|
||||
present: () => void;
|
||||
};
|
||||
|
||||
export default forwardRef<
|
||||
InboxItemMoreActionsHandle,
|
||||
InboxItemMoreActionsProps
|
||||
>(function InboxItemMoreActions({ item }, ref) {
|
||||
const dispatch = useAppDispatch();
|
||||
const readByInboxItemId = useAppSelector(
|
||||
(state) => state.inbox.readByInboxItemId,
|
||||
);
|
||||
const isRead = readByInboxItemId[getInboxItemId(item)];
|
||||
|
||||
const markReadAction = useMemo(
|
||||
() => ({
|
||||
text: isRead ? "Mark Unread" : "Mark Read",
|
||||
icon: isRead ? mailUnreadOutline : mailOutline,
|
||||
handler: () => {
|
||||
dispatch(markRead(item, !isRead));
|
||||
},
|
||||
}),
|
||||
[dispatch, isRead, item],
|
||||
);
|
||||
|
||||
return (
|
||||
<StyledPlainButton>
|
||||
{"person_mention" in item || "comment_reply" in item ? (
|
||||
<MoreActions
|
||||
comment={item}
|
||||
rootIndex={undefined}
|
||||
appendActions={[markReadAction]}
|
||||
ref={ref}
|
||||
/>
|
||||
) : (
|
||||
<PrivateMessageMoreActions
|
||||
item={item}
|
||||
markReadAction={markReadAction}
|
||||
ref={ref}
|
||||
/>
|
||||
)}
|
||||
</StyledPlainButton>
|
||||
);
|
||||
});
|
@@ -0,0 +1,141 @@
|
||||
import {
|
||||
arrowUndoOutline,
|
||||
ellipsisHorizontal,
|
||||
flagOutline,
|
||||
personOutline,
|
||||
removeCircleOutline,
|
||||
textOutline,
|
||||
} from "ionicons/icons";
|
||||
import { ActionSheetButton, IonIcon, useIonActionSheet } from "@ionic/react";
|
||||
import {
|
||||
forwardRef,
|
||||
useCallback,
|
||||
useContext,
|
||||
useImperativeHandle,
|
||||
} from "react";
|
||||
import { PageContext } from "../auth/PageContext";
|
||||
import useAppNavigation from "../../helpers/useAppNavigation";
|
||||
import { useUserDetails } from "../user/useUserDetails";
|
||||
import { getHandle } from "../../helpers/lemmy";
|
||||
import { PrivateMessageView } from "lemmy-js-client";
|
||||
import { styled } from "@linaria/react";
|
||||
import { markRead, syncMessages } from "./inboxSlice";
|
||||
import store, { useAppDispatch } from "../../store";
|
||||
|
||||
const StyledIonIcon = styled(IonIcon)`
|
||||
font-size: 1.2em;
|
||||
`;
|
||||
|
||||
interface PrivateMessageMoreActionsHandle {
|
||||
present: () => void;
|
||||
}
|
||||
|
||||
interface PrivateMessageMoreActionsProps {
|
||||
item: PrivateMessageView;
|
||||
markReadAction: ActionSheetButton;
|
||||
}
|
||||
|
||||
export default forwardRef<
|
||||
PrivateMessageMoreActionsHandle,
|
||||
PrivateMessageMoreActionsProps
|
||||
>(function PrivateMessageMoreActions({ item, markReadAction }, ref) {
|
||||
const dispatch = useAppDispatch();
|
||||
const [presentActionSheet] = useIonActionSheet();
|
||||
const { presentReport, presentSelectText, presentPrivateMessageCompose } =
|
||||
useContext(PageContext);
|
||||
|
||||
const { navigateToUser } = useAppNavigation();
|
||||
|
||||
const { isBlocked, blockOrUnblock } = useUserDetails(getHandle(item.creator));
|
||||
|
||||
const present = useCallback(() => {
|
||||
presentActionSheet({
|
||||
cssClass: "left-align-buttons",
|
||||
buttons: [
|
||||
markReadAction,
|
||||
{
|
||||
text: "Reply",
|
||||
icon: arrowUndoOutline,
|
||||
handler: () => {
|
||||
(async () => {
|
||||
await presentPrivateMessageCompose({
|
||||
private_message: {
|
||||
recipient:
|
||||
item.private_message.creator_id ===
|
||||
store.getState().site.response?.my_user?.local_user_view
|
||||
?.local_user?.person_id
|
||||
? item.recipient
|
||||
: item.creator,
|
||||
},
|
||||
});
|
||||
|
||||
await dispatch(markRead(item, true));
|
||||
dispatch(syncMessages());
|
||||
})();
|
||||
},
|
||||
},
|
||||
{
|
||||
text: "Select Text",
|
||||
icon: textOutline,
|
||||
handler: () => {
|
||||
presentSelectText(item.private_message.content);
|
||||
},
|
||||
},
|
||||
{
|
||||
text: getHandle(item.creator),
|
||||
icon: personOutline,
|
||||
handler: () => {
|
||||
navigateToUser(item.creator);
|
||||
},
|
||||
},
|
||||
{
|
||||
text: "Report",
|
||||
icon: flagOutline,
|
||||
handler: () => {
|
||||
presentReport(item);
|
||||
},
|
||||
},
|
||||
{
|
||||
text: !isBlocked ? "Block User" : "Unblock User",
|
||||
icon: removeCircleOutline,
|
||||
handler: () => {
|
||||
blockOrUnblock();
|
||||
},
|
||||
},
|
||||
{
|
||||
text: "Cancel",
|
||||
role: "cancel",
|
||||
},
|
||||
],
|
||||
});
|
||||
}, [
|
||||
presentActionSheet,
|
||||
markReadAction,
|
||||
item,
|
||||
isBlocked,
|
||||
presentPrivateMessageCompose,
|
||||
dispatch,
|
||||
presentSelectText,
|
||||
navigateToUser,
|
||||
presentReport,
|
||||
blockOrUnblock,
|
||||
]);
|
||||
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
() => ({
|
||||
present,
|
||||
}),
|
||||
[present],
|
||||
);
|
||||
|
||||
return (
|
||||
<StyledIonIcon
|
||||
icon={ellipsisHorizontal}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
present();
|
||||
}}
|
||||
/>
|
||||
);
|
||||
});
|
185
99_references/voyager-main/src/features/inbox/SendMessageBox.tsx
Normal file
185
99_references/voyager-main/src/features/inbox/SendMessageBox.tsx
Normal file
@@ -0,0 +1,185 @@
|
||||
import { styled } from "@linaria/react";
|
||||
import { MaxWidthContainer } from "../shared/AppContent";
|
||||
import TextareaAutosize, {
|
||||
TextareaAutosizeProps,
|
||||
} from "react-textarea-autosize";
|
||||
import { IonButton, IonIcon } from "@ionic/react";
|
||||
import { KeyboardEvent, useCallback, useContext, useState } from "react";
|
||||
import useClient from "../../helpers/useClient";
|
||||
import useAppToast from "../../helpers/useAppToast";
|
||||
import { receivedMessages } from "./inboxSlice";
|
||||
import { useAppDispatch } from "../../store";
|
||||
import { resize, send as sendIcon } from "ionicons/icons";
|
||||
import { privateMessageSendFailed } from "../../helpers/toastMessages";
|
||||
import { css } from "@linaria/core";
|
||||
import { PageContext } from "../auth/PageContext";
|
||||
import { Person } from "lemmy-js-client";
|
||||
|
||||
const MaxSizeContainer = styled(MaxWidthContainer)`
|
||||
height: 100%;
|
||||
`;
|
||||
|
||||
const SendContainer = styled.div`
|
||||
position: relative;
|
||||
|
||||
padding: 0.5rem;
|
||||
`;
|
||||
|
||||
const InputContainer = styled.div`
|
||||
position: relative;
|
||||
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 4px;
|
||||
`;
|
||||
|
||||
const Input = styled(TextareaAutosize)`
|
||||
border: 1px solid
|
||||
var(
|
||||
--ion-tab-bar-border-color,
|
||||
var(
|
||||
--ion-border-color,
|
||||
var(
|
||||
--ion-color-step-150,
|
||||
var(--ion-background-color-step-150, rgba(0, 0, 0, 0.2))
|
||||
)
|
||||
)
|
||||
);
|
||||
border-radius: 1rem;
|
||||
|
||||
// Exact px measurements to prevent
|
||||
// pixel rounding browser inconsistencies
|
||||
padding: 8px 12px;
|
||||
line-height: 18px;
|
||||
font-size: 16px;
|
||||
margin: 0;
|
||||
|
||||
background: var(--ion-background-color);
|
||||
color: var(--ion-text-color);
|
||||
outline: none;
|
||||
|
||||
width: 100%;
|
||||
resize: none;
|
||||
appearance: none;
|
||||
`;
|
||||
|
||||
const IconButton = styled(IonButton)`
|
||||
margin: 0;
|
||||
min-height: 36px;
|
||||
|
||||
ion-icon {
|
||||
font-size: 22px;
|
||||
}
|
||||
`;
|
||||
|
||||
interface SendMessageBoxProps {
|
||||
recipient: Person;
|
||||
onHeightChange?: TextareaAutosizeProps["onHeightChange"];
|
||||
scrollToBottom?: () => void;
|
||||
}
|
||||
|
||||
export default function SendMessageBox({
|
||||
recipient,
|
||||
onHeightChange,
|
||||
scrollToBottom,
|
||||
}: SendMessageBoxProps) {
|
||||
const dispatch = useAppDispatch();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [value, setValue] = useState("");
|
||||
const client = useClient();
|
||||
const presentToast = useAppToast();
|
||||
const { presentPrivateMessageCompose } = useContext(PageContext);
|
||||
|
||||
const send = useCallback(async () => {
|
||||
setLoading(true);
|
||||
|
||||
let message;
|
||||
|
||||
try {
|
||||
message = await client.createPrivateMessage({
|
||||
content: value,
|
||||
recipient_id: recipient.id,
|
||||
});
|
||||
} catch (error) {
|
||||
presentToast(privateMessageSendFailed);
|
||||
setLoading(false);
|
||||
throw error;
|
||||
}
|
||||
|
||||
dispatch(receivedMessages([message.private_message_view]));
|
||||
|
||||
setLoading(false);
|
||||
setValue("");
|
||||
|
||||
scrollToBottom?.();
|
||||
}, [client, dispatch, presentToast, recipient, value, scrollToBottom]);
|
||||
|
||||
const onKeyDown = useCallback(
|
||||
(e: KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (!e.ctrlKey && !e.metaKey) return;
|
||||
if (e.key !== "Enter") return;
|
||||
|
||||
send();
|
||||
},
|
||||
[send],
|
||||
);
|
||||
|
||||
return (
|
||||
<SendContainer>
|
||||
<MaxSizeContainer>
|
||||
<InputContainer>
|
||||
<IconButton
|
||||
shape="round"
|
||||
fill="clear"
|
||||
onClick={async () => {
|
||||
const message = await presentPrivateMessageCompose({
|
||||
private_message: { recipient },
|
||||
value,
|
||||
});
|
||||
|
||||
if (!message) return;
|
||||
|
||||
scrollToBottom?.();
|
||||
setValue("");
|
||||
}}
|
||||
aria-label="Open fullsize editor"
|
||||
>
|
||||
<IonIcon
|
||||
icon={resize}
|
||||
slot="icon-only"
|
||||
onClick={send}
|
||||
className={css`
|
||||
transform: scale(1.1);
|
||||
`}
|
||||
/>
|
||||
</IconButton>
|
||||
<Input
|
||||
disabled={loading}
|
||||
placeholder="Message"
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
value={value}
|
||||
rows={1}
|
||||
maxRows={5}
|
||||
onKeyDown={onKeyDown}
|
||||
onHeightChange={onHeightChange}
|
||||
/>
|
||||
<IconButton
|
||||
disabled={!value.trim() || loading}
|
||||
shape="round"
|
||||
fill="clear"
|
||||
onClick={send}
|
||||
aria-label="Send message"
|
||||
>
|
||||
<IonIcon
|
||||
icon={sendIcon}
|
||||
slot="icon-only"
|
||||
className={css`
|
||||
transform: rotate(270deg);
|
||||
`}
|
||||
/>
|
||||
</IconButton>
|
||||
</InputContainer>
|
||||
</MaxSizeContainer>
|
||||
</SendContainer>
|
||||
);
|
||||
}
|
55
99_references/voyager-main/src/features/inbox/VoteArrow.tsx
Normal file
55
99_references/voyager-main/src/features/inbox/VoteArrow.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import { IonIcon } from "@ionic/react";
|
||||
import { styled } from "@linaria/react";
|
||||
import { arrowDown, arrowUp } from "ionicons/icons";
|
||||
import {
|
||||
VOTE_COLORS,
|
||||
bgColorToVariable,
|
||||
} from "../settings/appearance/themes/votesTheme/VotesTheme";
|
||||
import { useAppSelector } from "../../store";
|
||||
|
||||
const Container = styled.div`
|
||||
font-size: 1.4em;
|
||||
|
||||
width: 100%;
|
||||
height: 1rem;
|
||||
|
||||
position: relative;
|
||||
`;
|
||||
|
||||
const VoteIcon = styled(IonIcon)`
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
`;
|
||||
|
||||
interface VoteArrowProps {
|
||||
vote: 1 | 0 | -1 | undefined;
|
||||
}
|
||||
|
||||
export default function VoteArrow({ vote }: VoteArrowProps) {
|
||||
const votesTheme = useAppSelector(
|
||||
(state) => state.settings.appearance.votesTheme,
|
||||
);
|
||||
|
||||
if (!vote) return null;
|
||||
|
||||
if (vote === 1)
|
||||
return (
|
||||
<Container>
|
||||
<VoteIcon
|
||||
icon={arrowUp}
|
||||
style={{ color: bgColorToVariable(VOTE_COLORS.UPVOTE[votesTheme]) }}
|
||||
/>
|
||||
</Container>
|
||||
);
|
||||
if (vote === -1)
|
||||
return (
|
||||
<Container>
|
||||
<VoteIcon
|
||||
icon={arrowDown}
|
||||
style={{ color: bgColorToVariable(VOTE_COLORS.DOWNVOTE[votesTheme]) }}
|
||||
/>
|
||||
</Container>
|
||||
);
|
||||
}
|
288
99_references/voyager-main/src/features/inbox/inboxSlice.ts
Normal file
288
99_references/voyager-main/src/features/inbox/inboxSlice.ts
Normal file
@@ -0,0 +1,288 @@
|
||||
import { PayloadAction, createSelector, createSlice } from "@reduxjs/toolkit";
|
||||
import { GetUnreadCountResponse, PrivateMessageView } from "lemmy-js-client";
|
||||
import { AppDispatch, RootState } from "../../store";
|
||||
import { InboxItemView } from "./InboxItem";
|
||||
import { differenceBy, groupBy, sortBy, uniqBy } from "lodash";
|
||||
import { receivedUsers } from "../user/userSlice";
|
||||
import { clientSelector, jwtSelector } from "../auth/authSelectors";
|
||||
|
||||
interface PostState {
|
||||
counts: {
|
||||
mentions: number;
|
||||
messages: number;
|
||||
replies: number;
|
||||
};
|
||||
lastUpdatedCounts: number;
|
||||
readByInboxItemId: Record<string, boolean>;
|
||||
messageSyncState: "init" | "syncing" | "synced";
|
||||
messages: PrivateMessageView[];
|
||||
}
|
||||
|
||||
const initialState: PostState = {
|
||||
counts: {
|
||||
mentions: 0,
|
||||
messages: 0,
|
||||
replies: 0,
|
||||
},
|
||||
lastUpdatedCounts: 0,
|
||||
readByInboxItemId: {},
|
||||
messageSyncState: "init",
|
||||
messages: [],
|
||||
};
|
||||
|
||||
export const inboxSlice = createSlice({
|
||||
name: "inbox",
|
||||
initialState,
|
||||
reducers: {
|
||||
receivedInboxCounts: (
|
||||
state,
|
||||
action: PayloadAction<GetUnreadCountResponse>,
|
||||
) => {
|
||||
state.counts.mentions = action.payload.mentions;
|
||||
state.counts.messages = action.payload.private_messages;
|
||||
state.counts.replies = action.payload.replies;
|
||||
state.lastUpdatedCounts = Date.now();
|
||||
},
|
||||
receivedInboxItems: (state, action: PayloadAction<InboxItemView[]>) => {
|
||||
for (const item of action.payload) {
|
||||
state.readByInboxItemId[getInboxItemId(item)] =
|
||||
getInboxItemReadStatus(item);
|
||||
}
|
||||
},
|
||||
setReadStatus: (
|
||||
state,
|
||||
action: PayloadAction<{ item: InboxItemView; read: boolean }>,
|
||||
) => {
|
||||
state.readByInboxItemId[getInboxItemId(action.payload.item)] =
|
||||
action.payload.read;
|
||||
},
|
||||
setAllReadStatus: (state) => {
|
||||
for (const [id, read] of Object.entries(state.readByInboxItemId)) {
|
||||
if (read) continue;
|
||||
|
||||
state.readByInboxItemId[id] = true;
|
||||
}
|
||||
},
|
||||
receivedMessages: (state, action: PayloadAction<PrivateMessageView[]>) => {
|
||||
state.messages = uniqBy(
|
||||
[...action.payload, ...state.messages],
|
||||
(m) => m.private_message.id,
|
||||
);
|
||||
},
|
||||
sync: (state) => {
|
||||
state.messageSyncState = "syncing";
|
||||
},
|
||||
syncComplete: (state) => {
|
||||
state.messageSyncState = "synced";
|
||||
},
|
||||
syncFail: (state) => {
|
||||
if (state.messageSyncState === "syncing") state.messageSyncState = "init";
|
||||
},
|
||||
resetInbox: () => initialState,
|
||||
resetMessages: (state) => {
|
||||
state.messageSyncState = "init";
|
||||
state.messages = [];
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Action creators are generated for each case reducer function
|
||||
export const {
|
||||
receivedInboxCounts,
|
||||
receivedInboxItems,
|
||||
setReadStatus,
|
||||
receivedMessages,
|
||||
resetInbox,
|
||||
resetMessages,
|
||||
sync,
|
||||
syncComplete,
|
||||
syncFail,
|
||||
setAllReadStatus: markAllReadInCache,
|
||||
} = inboxSlice.actions;
|
||||
|
||||
export default inboxSlice.reducer;
|
||||
|
||||
export const totalUnreadSelector = (state: RootState) =>
|
||||
state.inbox.counts.mentions +
|
||||
state.inbox.counts.messages +
|
||||
state.inbox.counts.replies;
|
||||
|
||||
export const getInboxCounts =
|
||||
() => async (dispatch: AppDispatch, getState: () => RootState) => {
|
||||
const jwt = jwtSelector(getState());
|
||||
|
||||
if (!jwt) {
|
||||
dispatch(resetInbox());
|
||||
return;
|
||||
}
|
||||
|
||||
const lastUpdatedCounts = getState().inbox.lastUpdatedCounts;
|
||||
|
||||
if (Date.now() - lastUpdatedCounts < 3_000) return;
|
||||
|
||||
const result = await clientSelector(getState()).getUnreadCount();
|
||||
|
||||
if (result) dispatch(receivedInboxCounts(result));
|
||||
};
|
||||
|
||||
export const syncMessages =
|
||||
() => async (dispatch: AppDispatch, getState: () => RootState) => {
|
||||
const jwt = jwtSelector(getState());
|
||||
|
||||
if (!jwt) {
|
||||
dispatch(resetInbox());
|
||||
return;
|
||||
}
|
||||
|
||||
const syncState = getState().inbox.messageSyncState;
|
||||
|
||||
switch (syncState) {
|
||||
case "syncing":
|
||||
break;
|
||||
case "init":
|
||||
case "synced": {
|
||||
dispatch(sync());
|
||||
|
||||
let page = 1;
|
||||
|
||||
while (true) {
|
||||
let privateMessages;
|
||||
|
||||
try {
|
||||
const results = await clientSelector(getState()).getPrivateMessages(
|
||||
{
|
||||
limit: (() => {
|
||||
if (syncState === "init") return 50; // initial sync, expect many messages
|
||||
if (page === 1) return 1; // poll to check for new messages
|
||||
|
||||
return 20; // detected new messages, kick off sync
|
||||
})(),
|
||||
page,
|
||||
},
|
||||
);
|
||||
privateMessages = results.private_messages;
|
||||
} catch (e) {
|
||||
dispatch(syncFail());
|
||||
throw e;
|
||||
}
|
||||
|
||||
const newMessages = differenceBy(
|
||||
privateMessages,
|
||||
getState().inbox.messages,
|
||||
(msg) => msg.private_message.id,
|
||||
);
|
||||
|
||||
dispatch(receivedMessages(privateMessages));
|
||||
dispatch(receivedUsers(privateMessages.map((msg) => msg.creator)));
|
||||
dispatch(receivedUsers(privateMessages.map((msg) => msg.recipient)));
|
||||
|
||||
if (!newMessages.length || page > 10) break;
|
||||
page++;
|
||||
}
|
||||
|
||||
dispatch(syncComplete());
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const markAllRead =
|
||||
() => async (dispatch: AppDispatch, getState: () => RootState) => {
|
||||
await clientSelector(getState()).markAllAsRead();
|
||||
|
||||
dispatch(markAllReadInCache());
|
||||
dispatch(getInboxCounts());
|
||||
};
|
||||
|
||||
export const conversationsByPersonIdSelector = createSelector(
|
||||
[
|
||||
(state: RootState) => state.inbox.messages,
|
||||
(state: RootState) =>
|
||||
state.site.response?.my_user?.local_user_view?.local_user?.person_id,
|
||||
],
|
||||
(messages, myUserId) => {
|
||||
return sortBy(
|
||||
Object.values(
|
||||
groupBy(messages, (m) =>
|
||||
m.private_message.creator_id === myUserId
|
||||
? m.private_message.recipient_id
|
||||
: m.private_message.creator_id,
|
||||
),
|
||||
).map((messages) =>
|
||||
sortBy(messages, (m) => -Date.parse(m.private_message.published)),
|
||||
),
|
||||
(group) => -Date.parse(group[0]!.private_message.published),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
export function getInboxItemId(item: InboxItemView): string {
|
||||
if ("comment_reply" in item) {
|
||||
return `repl_${item.comment_reply.id}`;
|
||||
}
|
||||
|
||||
if ("private_message" in item) {
|
||||
return `dm_${item.private_message.id}`;
|
||||
}
|
||||
|
||||
return `mention_${item.person_mention.id}`;
|
||||
}
|
||||
|
||||
export function getInboxItemReadStatus(item: InboxItemView): boolean {
|
||||
if ("comment_reply" in item) {
|
||||
return item.comment_reply.read;
|
||||
}
|
||||
|
||||
if ("private_message" in item) {
|
||||
return item.private_message.read;
|
||||
}
|
||||
|
||||
return item.person_mention.read;
|
||||
}
|
||||
|
||||
export function getInboxItemPublished(item: InboxItemView): string {
|
||||
if ("comment_reply" in item) {
|
||||
return item.comment_reply.published;
|
||||
}
|
||||
|
||||
if ("private_message" in item) {
|
||||
return item.private_message.published;
|
||||
}
|
||||
|
||||
return item.person_mention.published;
|
||||
}
|
||||
|
||||
export const markRead =
|
||||
(item: InboxItemView, read: boolean) =>
|
||||
async (dispatch: AppDispatch, getState: () => RootState) => {
|
||||
const client = clientSelector(getState());
|
||||
|
||||
const initialRead =
|
||||
!!getState().inbox.readByInboxItemId[getInboxItemId(item)];
|
||||
|
||||
dispatch(setReadStatus({ item, read }));
|
||||
|
||||
try {
|
||||
if ("person_mention" in item) {
|
||||
await client.markPersonMentionAsRead({
|
||||
read,
|
||||
person_mention_id: item.person_mention.id,
|
||||
});
|
||||
} else if ("comment_reply" in item) {
|
||||
await client.markCommentReplyAsRead({
|
||||
read,
|
||||
comment_reply_id: item.comment_reply.id,
|
||||
});
|
||||
} else if ("private_message" in item) {
|
||||
await client.markPrivateMessageAsRead({
|
||||
read,
|
||||
private_message_id: item.private_message.id,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
dispatch(setReadStatus({ item, read: initialRead }));
|
||||
|
||||
throw error;
|
||||
}
|
||||
|
||||
dispatch(getInboxCounts());
|
||||
};
|
@@ -0,0 +1,217 @@
|
||||
import { styled } from "@linaria/react";
|
||||
import {
|
||||
IonIcon,
|
||||
IonItem,
|
||||
IonItemOption,
|
||||
IonItemOptions,
|
||||
IonItemSliding,
|
||||
IonLoading,
|
||||
useIonAlert,
|
||||
} from "@ionic/react";
|
||||
import { PrivateMessageView } from "lemmy-js-client";
|
||||
import { useAppDispatch, useAppSelector } from "../../../store";
|
||||
import { getHandle } from "../../../helpers/lemmy";
|
||||
import ItemIcon from "../../labels/img/ItemIcon";
|
||||
import { chevronForwardOutline } from "ionicons/icons";
|
||||
import Time from "./Time";
|
||||
import { useState } from "react";
|
||||
import { clientSelector } from "../../auth/authSelectors";
|
||||
import { blockUser } from "../../user/userSlice";
|
||||
|
||||
const StyledItemIcon = styled(ItemIcon)`
|
||||
margin: 0.75rem 0;
|
||||
`;
|
||||
|
||||
const MessageContent = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
margin: 0.5rem 0;
|
||||
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
`;
|
||||
|
||||
const MessageLine = styled.div`
|
||||
display: flex;
|
||||
gap: 0.3rem;
|
||||
min-width: 0;
|
||||
white-space: nowrap;
|
||||
`;
|
||||
|
||||
const PersonLabel = styled.h3`
|
||||
flex: 1;
|
||||
|
||||
font-size: 1rem;
|
||||
margin: 0;
|
||||
min-width: 0;
|
||||
|
||||
display: inline;
|
||||
margin-right: auto;
|
||||
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
`;
|
||||
|
||||
const OpenDetails = styled.span`
|
||||
flex: 0;
|
||||
|
||||
font-size: 0.875em;
|
||||
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
|
||||
color: var(--ion-color-medium);
|
||||
`;
|
||||
|
||||
const MessagePreview = styled.div`
|
||||
--line-height: 1.3rem;
|
||||
--num-lines: 2;
|
||||
|
||||
height: 2.5rem;
|
||||
line-height: var(--line-height);
|
||||
font-size: 0.875em;
|
||||
height: calc(var(--line-height) * var(--num-lines));
|
||||
|
||||
color: var(--ion-color-medium);
|
||||
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: var(--num-lines);
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
`;
|
||||
|
||||
const Dot = styled.div`
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 0.25rem;
|
||||
transform: translateY(-50%);
|
||||
|
||||
background: var(--ion-color-primary);
|
||||
|
||||
--size: 8px;
|
||||
|
||||
border-radius: calc(var(--size) / 2);
|
||||
|
||||
width: var(--size);
|
||||
height: var(--size);
|
||||
`;
|
||||
|
||||
interface ConversationItemProps {
|
||||
messages: PrivateMessageView[];
|
||||
}
|
||||
|
||||
export default function ConversationItem({ messages }: ConversationItemProps) {
|
||||
const dispatch = useAppDispatch();
|
||||
const [present] = useIonAlert();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const myUserId = useAppSelector(
|
||||
(state) =>
|
||||
state.site.response?.my_user?.local_user_view?.local_user?.person_id,
|
||||
);
|
||||
const client = useAppSelector(clientSelector);
|
||||
|
||||
const previewMsg = messages[0]!; // presorted, newest => oldest
|
||||
|
||||
const person =
|
||||
previewMsg.creator.id === myUserId
|
||||
? previewMsg.recipient
|
||||
: previewMsg.creator;
|
||||
|
||||
const unread = !!messages.find(
|
||||
(msg) =>
|
||||
!msg.private_message.read && msg.private_message.creator_id !== myUserId,
|
||||
);
|
||||
|
||||
async function onDelete() {
|
||||
const theirs = messages.filter((m) => m.creator.id !== myUserId);
|
||||
|
||||
const theirPotentialRecentMessage = theirs.pop();
|
||||
|
||||
if (!theirPotentialRecentMessage) {
|
||||
present(
|
||||
"This user hasn't messaged you, so there's nothing to block/report.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await present("Block and report conversation?", [
|
||||
{
|
||||
text: "Just block",
|
||||
role: "destructive",
|
||||
handler: () => {
|
||||
blockAndReportIfNeeded(theirPotentialRecentMessage);
|
||||
},
|
||||
},
|
||||
{
|
||||
text: "Block + Report",
|
||||
role: "destructive",
|
||||
handler: () => {
|
||||
blockAndReportIfNeeded(theirPotentialRecentMessage, true);
|
||||
},
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
async function blockAndReportIfNeeded(
|
||||
theirRecentMessage: PrivateMessageView,
|
||||
report = false,
|
||||
) {
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
if (report) {
|
||||
await client.createPrivateMessageReport({
|
||||
private_message_id: theirRecentMessage.private_message.id,
|
||||
reason: "Spam or abuse",
|
||||
});
|
||||
}
|
||||
|
||||
dispatch(blockUser(true, theirRecentMessage.creator.id));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<IonLoading isOpen={loading} />
|
||||
<IonItemSliding>
|
||||
<IonItemOptions side="end" onIonSwipe={onDelete}>
|
||||
<IonItemOption color="danger" expandable onClick={onDelete}>
|
||||
Block
|
||||
</IonItemOption>
|
||||
</IonItemOptions>
|
||||
|
||||
<IonItem
|
||||
routerLink={`/inbox/messages/${getHandle(person)}`}
|
||||
href={undefined}
|
||||
draggable={false}
|
||||
detail={false}
|
||||
>
|
||||
<div slot="start">
|
||||
{unread ? <Dot /> : ""}
|
||||
<StyledItemIcon item={person} size={44} />
|
||||
</div>
|
||||
<MessageContent>
|
||||
<MessageLine>
|
||||
<PersonLabel>
|
||||
{person.display_name ?? getHandle(person)}
|
||||
</PersonLabel>
|
||||
<OpenDetails>
|
||||
<span>
|
||||
<Time date={previewMsg.private_message.published} />
|
||||
</span>
|
||||
<IonIcon icon={chevronForwardOutline} />
|
||||
</OpenDetails>
|
||||
</MessageLine>
|
||||
<MessagePreview color="medium">
|
||||
{previewMsg.private_message.content}
|
||||
</MessagePreview>
|
||||
</MessageContent>
|
||||
</IonItem>
|
||||
</IonItemSliding>
|
||||
</>
|
||||
);
|
||||
}
|
@@ -0,0 +1,175 @@
|
||||
import { PrivateMessageView } from "lemmy-js-client";
|
||||
import { useAppDispatch, useAppSelector } from "../../../store";
|
||||
import { useCallback, useContext, useEffect, useRef, useState } from "react";
|
||||
import useClient from "../../../helpers/useClient";
|
||||
import { getInboxCounts, receivedMessages } from "../inboxSlice";
|
||||
import { useIonViewDidLeave, useIonViewWillEnter } from "@ionic/react";
|
||||
import { PageContext } from "../../auth/PageContext";
|
||||
import { useLongPress } from "use-long-press";
|
||||
import Markdown from "../../shared/markdown/Markdown";
|
||||
import { styled } from "@linaria/react";
|
||||
import { css } from "@linaria/core";
|
||||
|
||||
const Container = styled.div<{ first: boolean }>`
|
||||
position: relative; /* Setup a relative container for our pseudo elements */
|
||||
max-width: min(75%, 400px);
|
||||
padding: 10px 15px;
|
||||
line-height: 1.3;
|
||||
word-wrap: break-word; /* Make sure the text wraps to multiple lines if long */
|
||||
|
||||
font-size: 1rem;
|
||||
|
||||
--border-radius: 20px;
|
||||
|
||||
border-radius: var(--border-radius);
|
||||
|
||||
--bg: var(--ion-background-color);
|
||||
--sentColor: var(--ion-color-primary);
|
||||
--receiveColor: #eee;
|
||||
|
||||
.ion-palette-dark & {
|
||||
--receiveColor: var(--ion-color-medium);
|
||||
}
|
||||
|
||||
&:before {
|
||||
width: 20px;
|
||||
}
|
||||
|
||||
&:after {
|
||||
width: 26px;
|
||||
background-color: var(--bg); /* All tails have the same bg cutout */
|
||||
}
|
||||
|
||||
a {
|
||||
color: white;
|
||||
}
|
||||
|
||||
&:before,
|
||||
&:after {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
height: var(
|
||||
--border-radius
|
||||
); /* height of our bubble "tail" - should match the border-radius above */
|
||||
content: "";
|
||||
}
|
||||
|
||||
margin-bottom: 15px;
|
||||
|
||||
margin-top: ${({ first }) => (first ? "15px" : "0")};
|
||||
`;
|
||||
|
||||
const sentCss = css`
|
||||
align-self: flex-end;
|
||||
color: white;
|
||||
background: var(--sentColor);
|
||||
|
||||
&:before {
|
||||
right: -7px;
|
||||
background-color: var(--sentColor);
|
||||
border-bottom-left-radius: 16px 14px;
|
||||
}
|
||||
|
||||
&:after {
|
||||
right: -26px;
|
||||
border-bottom-left-radius: 10px;
|
||||
}
|
||||
`;
|
||||
|
||||
const receivedCss = css`
|
||||
align-self: flex-start;
|
||||
color: black;
|
||||
background: var(--receiveColor);
|
||||
|
||||
&:before {
|
||||
left: -7px;
|
||||
background-color: var(--receiveColor);
|
||||
border-bottom-right-radius: 16px 14px;
|
||||
}
|
||||
|
||||
&:after {
|
||||
left: -26px;
|
||||
border-bottom-right-radius: 10px;
|
||||
}
|
||||
`;
|
||||
|
||||
interface MessageProps {
|
||||
message: PrivateMessageView;
|
||||
first?: boolean;
|
||||
}
|
||||
|
||||
export default function Message({ message, first }: MessageProps) {
|
||||
const dispatch = useAppDispatch();
|
||||
const { presentReport } = useContext(PageContext);
|
||||
const myUserId = useAppSelector(
|
||||
(state) =>
|
||||
state.site.response?.my_user?.local_user_view?.local_user?.person_id,
|
||||
);
|
||||
|
||||
const thisIsMyMessage = message.private_message.creator_id === myUserId;
|
||||
const thisIsASelfMessage =
|
||||
message.private_message.creator_id === message.private_message.recipient_id;
|
||||
const [loading, setLoading] = useState(false);
|
||||
const client = useClient();
|
||||
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const [focused, setFocused] = useState(true);
|
||||
|
||||
useIonViewWillEnter(() => setFocused(true));
|
||||
useIonViewDidLeave(() => setFocused(false));
|
||||
|
||||
const onMessageLongPress = useCallback(() => {
|
||||
presentReport(message);
|
||||
}, [message, presentReport]);
|
||||
|
||||
const bind = useLongPress(onMessageLongPress, { cancelOnMovement: 15 });
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
message.private_message.read ||
|
||||
(thisIsMyMessage && !thisIsASelfMessage) ||
|
||||
!focused
|
||||
)
|
||||
return;
|
||||
|
||||
setRead();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [focused, message, thisIsMyMessage]);
|
||||
|
||||
async function setRead() {
|
||||
if (loading) return;
|
||||
|
||||
setLoading(true);
|
||||
|
||||
let response;
|
||||
|
||||
try {
|
||||
response = await client.markPrivateMessageAsRead({
|
||||
private_message_id: message.private_message.id,
|
||||
read: true,
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
await dispatch(receivedMessages([response.private_message_view]));
|
||||
await dispatch(getInboxCounts());
|
||||
}
|
||||
|
||||
return (
|
||||
<Container
|
||||
first={!!first}
|
||||
className={thisIsMyMessage ? sentCss : receivedCss}
|
||||
ref={containerRef}
|
||||
{...bind()}
|
||||
>
|
||||
<Markdown
|
||||
id={`private-message_${message.private_message.id}`}
|
||||
className="collapse-md-margins"
|
||||
>
|
||||
{message.private_message.content}
|
||||
</Markdown>
|
||||
</Container>
|
||||
);
|
||||
}
|
@@ -0,0 +1,19 @@
|
||||
import { differenceInDays, differenceInHours, format } from "date-fns";
|
||||
|
||||
interface TimeProps {
|
||||
date: string;
|
||||
}
|
||||
|
||||
export default function Time({ date: dateStr }: TimeProps) {
|
||||
const date = new Date(dateStr);
|
||||
|
||||
if (differenceInDays(new Date(), date) > 6) {
|
||||
return <>{format(date, "PP")}</>;
|
||||
}
|
||||
|
||||
if (differenceInHours(new Date(), date) > 24) {
|
||||
return <>{format(date, "iiii")}</>;
|
||||
}
|
||||
|
||||
return <>{format(date, "p")}</>;
|
||||
}
|
Reference in New Issue
Block a user