init commit,

This commit is contained in:
louiscklaw
2025-05-28 09:55:51 +08:00
commit efe70ceb69
8042 changed files with 951668 additions and 0 deletions

View File

@@ -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" />;
}

View 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");
}

View File

@@ -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>
);
});

View File

@@ -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();
}}
/>
);
});

View 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>
);
}

View 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>
);
}

View 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());
};

View File

@@ -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>
</>
);
}

View File

@@ -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>
);
}

View File

@@ -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")}</>;
}