|
@@ -1,19 +1,19 @@
|
|
|
import { Text } from 'app/design/typography'
|
|
import { Text } from 'app/design/typography'
|
|
|
import { View, Row } from 'app/design/view'
|
|
import { View, Row } from 'app/design/view'
|
|
|
import { fetcher } from 'app/lib/fetcher';
|
|
import { fetcher } from 'app/lib/fetcher';
|
|
|
-import { useState, useEffect, useRef, useCallback, useMemo } from 'react';
|
|
|
|
|
|
|
+import { useState, useEffect, useRef, useCallback, useMemo, type ComponentType, type ReactNode } from 'react';
|
|
|
import { appSetting, isNativeTabsEnabled } from 'app/lib/util'
|
|
import { appSetting, isNativeTabsEnabled } from 'app/lib/util'
|
|
|
-import { Platform } from 'react-native'
|
|
|
|
|
|
|
+import { Platform, type ViewStyle } from 'react-native'
|
|
|
import useFetchForm from 'app/lib/hooks/use-fetch-form'
|
|
import useFetchForm from 'app/lib/hooks/use-fetch-form'
|
|
|
import { playCue } from 'app/lib/sound'
|
|
import { playCue } from 'app/lib/sound'
|
|
|
import { useCurrentUser } from 'app/context/user';
|
|
import { useCurrentUser } from 'app/context/user';
|
|
|
import { subscribe } from 'app/ui/atoms/socket';
|
|
import { subscribe } from 'app/ui/atoms/socket';
|
|
|
import { KbStickyView } from 'app/ui/atoms/kb-avoiding-view';
|
|
import { KbStickyView } from 'app/ui/atoms/kb-avoiding-view';
|
|
|
-import CreateConvo, { CreateConvoButton } from 'app/components/elements/messenger/parts/new-convo';
|
|
|
|
|
|
|
+import CreateConvo, { CreateConvoButton as CreateConvoButtonJs } from 'app/components/elements/messenger/parts/new-convo';
|
|
|
import Msg from 'app/ui/molecules/dialogs/msg';
|
|
import Msg from 'app/ui/molecules/dialogs/msg';
|
|
|
import { useBottomSheetData } from 'app/context/bottomsheet';
|
|
import { useBottomSheetData } from 'app/context/bottomsheet';
|
|
|
import Profile from 'app/ui/molecules/profile/profile'
|
|
import Profile from 'app/ui/molecules/profile/profile'
|
|
|
-import ElementMsg from 'app/components/elements/msg';
|
|
|
|
|
|
|
+import ElementMsgJs from 'app/components/elements/msg';
|
|
|
import emitter, { EVENTS } from 'app/context/emitter';
|
|
import emitter, { EVENTS } from 'app/context/emitter';
|
|
|
import { getWindowSafeAreaInsets, useCurrentTabPath, useIsFocused, useRouter, useSafeAreaInsets } from 'app/lib/hooks/router'
|
|
import { getWindowSafeAreaInsets, useCurrentTabPath, useIsFocused, useRouter, useSafeAreaInsets } from 'app/lib/hooks/router'
|
|
|
import { useIsPushedScreen } from 'app/context/tab-chrome';
|
|
import { useIsPushedScreen } from 'app/context/tab-chrome';
|
|
@@ -22,13 +22,17 @@ import { useTranslation } from 'react-i18next'
|
|
|
import ChatPanels from 'app/components/elements/chat/parts/chat-panels'
|
|
import ChatPanels from 'app/components/elements/chat/parts/chat-panels'
|
|
|
import { useIsDesktop } from 'app/context/measure';
|
|
import { useIsDesktop } from 'app/context/measure';
|
|
|
import { getNativeTabBarHeight } from 'app/components/nav/tabs/tab-menu';
|
|
import { getNativeTabBarHeight } from 'app/components/nav/tabs/tab-menu';
|
|
|
-import { useFooterHeight, useSetPageBottomBlur } from 'app/context/jotai/layout';
|
|
|
|
|
-import { PageHeaderOptions } from 'app/ui/molecules/header/options';
|
|
|
|
|
|
|
+import { useFooterHeight, useHeaderHeight, useSetPageBottomBlur } from 'app/context/jotai/layout';
|
|
|
|
|
+import { PageHeaderOptions as PageHeaderOptionsJs } from 'app/ui/molecules/header/options';
|
|
|
import ConvosList from 'app/components/elements/chat/parts/convos-list';
|
|
import ConvosList from 'app/components/elements/chat/parts/convos-list';
|
|
|
import ItemConvo from 'app/components/elements/messenger/parts/item-convo';
|
|
import ItemConvo from 'app/components/elements/messenger/parts/item-convo';
|
|
|
import JotsPanel, { ChatEmptyState } from 'app/components/elements/messenger/parts/jots-panel';
|
|
import JotsPanel, { ChatEmptyState } from 'app/components/elements/messenger/parts/jots-panel';
|
|
|
-import Composer from 'app/components/elements/messenger/parts/composer';
|
|
|
|
|
-import { MobileListHeader, MobileChatHeader, ConvoActionsMenu } from 'app/components/elements/messenger/parts/headers';
|
|
|
|
|
|
|
+import ComposerJs from 'app/components/elements/messenger/parts/composer';
|
|
|
|
|
+import { MobileListHeader, MobileChatHeader, ConvoActionsMenu, ConvoSearchButton, ConvoSearchField } from 'app/components/elements/messenger/parts/headers';
|
|
|
|
|
+import Confirm from 'app/ui/molecules/dialogs/confirm';
|
|
|
|
|
+import ConvoSettings from 'app/components/elements/messenger/parts/convo-settings';
|
|
|
|
|
+import ConvoFiles from 'app/components/elements/messenger/parts/convo-files';
|
|
|
|
|
+import ConvoSearchResults from 'app/components/elements/messenger/parts/convo-search';
|
|
|
import {
|
|
import {
|
|
|
PanelHeader,
|
|
PanelHeader,
|
|
|
PANEL_HEADER_HEIGHT,
|
|
PANEL_HEADER_HEIGHT,
|
|
@@ -37,28 +41,62 @@ import {
|
|
|
CHAT_OVERLAY_COMPOSER_FADE,
|
|
CHAT_OVERLAY_COMPOSER_FADE,
|
|
|
} from 'app/components/elements/chat/parts/headers';
|
|
} from 'app/components/elements/chat/parts/headers';
|
|
|
import { EdgeBlurView, edgeBlurConfig } from 'app/ui/atoms/edge-blur';
|
|
import { EdgeBlurView, edgeBlurConfig } from 'app/ui/atoms/edge-blur';
|
|
|
|
|
+import type { AddButtons, ConvoAction, Convo, Jot, JotsState, MessengerMenuItem } from 'app/components/elements/messenger/parts/messenger.types';
|
|
|
|
|
|
|
|
const isWeb = Platform.OS === 'web';
|
|
const isWeb = Platform.OS === 'web';
|
|
|
|
|
|
|
|
// Keep in sync with tabBarStyle.height in app/components/nav/tabs/native-tabs.js
|
|
// Keep in sync with tabBarStyle.height in app/components/nav/tabs/native-tabs.js
|
|
|
const TAB_BAR_HEIGHT = Platform.OS === 'ios' ? 52 : 56
|
|
const TAB_BAR_HEIGHT = Platform.OS === 'ios' ? 52 : 56
|
|
|
|
|
|
|
|
|
|
+// Untyped JS components: their inferred props mark every prop as required.
|
|
|
|
|
+const ConfirmDialog = Confirm as unknown as ComponentType<Record<string, any>>;
|
|
|
|
|
+const MsgDialog = Msg as unknown as ComponentType<Record<string, any>>;
|
|
|
|
|
+const CreateConvoForm = CreateConvo as unknown as ComponentType<Record<string, any>>;
|
|
|
|
|
+const CreateConvoButton = CreateConvoButtonJs as unknown as ComponentType<Record<string, any>>;
|
|
|
|
|
+const ElementMsg = ElementMsgJs as unknown as ComponentType<Record<string, any>>;
|
|
|
|
|
+const PageHeaderOptions = PageHeaderOptionsJs as unknown as ComponentType<Record<string, any>>;
|
|
|
|
|
+const Composer = ComposerJs as unknown as ComponentType<Record<string, any>>;
|
|
|
|
|
+
|
|
|
|
|
+type LoosePayload = Record<string, any>;
|
|
|
|
|
+
|
|
|
|
|
+// expo-router on native, the Next router on web: only the native branch pushes tab pages.
|
|
|
|
|
+type MessengerRouter = { push: (href: unknown) => void; back: () => void; canGoBack?: () => boolean };
|
|
|
|
|
+
|
|
|
|
|
+type MessengerProps = {
|
|
|
|
|
+ defaultConvoId: string;
|
|
|
|
|
+ selectedMenu: string;
|
|
|
|
|
+ convos: { data?: Convo[]; menu?: string } | false;
|
|
|
|
|
+ fetchConvos: (term?: string) => void;
|
|
|
|
|
+ data: LoosePayload;
|
|
|
|
|
+ pageData?: LoosePayload;
|
|
|
|
|
+ onSave: (data: LoosePayload) => void;
|
|
|
|
|
+ addButtons?: AddButtons;
|
|
|
|
|
+ menuItems?: MessengerMenuItem[];
|
|
|
|
|
+ menuIndex: number;
|
|
|
|
|
+ onMenuChange: (index: number) => void;
|
|
|
|
|
+};
|
|
|
|
|
+
|
|
|
/**
|
|
/**
|
|
|
* Messenger root: owns conversation selection, panel visibility, URL/history
|
|
* Messenger root: owns conversation selection, panel visibility, URL/history
|
|
|
* sync and the injected page header. Composes ConvosList / JotsPanel /
|
|
* sync and the injected page header. Composes ConvosList / JotsPanel /
|
|
|
* Composer (small screens: stacked list ↔ chat; web desktop: resizable
|
|
* Composer (small screens: stacked list ↔ chat; web desktop: resizable
|
|
|
* two-column split inside the UNA block).
|
|
* two-column split inside the UNA block).
|
|
|
*/
|
|
*/
|
|
|
-export default function Messenger({ defaultConvoId, selectedMenu, convos, fetchConvos, data, pageData, onSave, addButtons, menuItems, menuIndex, onMenuChange }) {
|
|
|
|
|
|
|
+export default function Messenger({ defaultConvoId, selectedMenu, convos, fetchConvos, data, pageData, onSave, addButtons, menuItems, menuIndex, onMenuChange }: MessengerProps) {
|
|
|
const { t } = useTranslation();
|
|
const { t } = useTranslation();
|
|
|
const insets = useSafeAreaInsets();
|
|
const insets = useSafeAreaInsets();
|
|
|
const footerHeight = useFooterHeight();
|
|
const footerHeight = useFooterHeight();
|
|
|
|
|
+ const headerHeight = useHeaderHeight();
|
|
|
const { setBottomSheetData } = useBottomSheetData();
|
|
const { setBottomSheetData } = useBottomSheetData();
|
|
|
const [convoId, setConvoId] = useState(defaultConvoId);
|
|
const [convoId, setConvoId] = useState(defaultConvoId);
|
|
|
|
|
+ const selectedMenuRef = useRef(selectedMenu);
|
|
|
|
|
+ useEffect(() => {
|
|
|
|
|
+ selectedMenuRef.current = selectedMenu;
|
|
|
|
|
+ }, [selectedMenu]);
|
|
|
const convoIdRef = useRef(convoId);
|
|
const convoIdRef = useRef(convoId);
|
|
|
convoIdRef.current = convoId;
|
|
convoIdRef.current = convoId;
|
|
|
- const [jots, setJots] = useState(false);
|
|
|
|
|
- const [listError, setListError] = useState(false);
|
|
|
|
|
|
|
+ const [jots, setJots] = useState<JotsState | false>(false);
|
|
|
|
|
+ const [listError, setListError] = useState<unknown>(false);
|
|
|
const isDesktop = useIsDesktop();
|
|
const isDesktop = useIsDesktop();
|
|
|
// Side-by-side panels exist only on web. Native (incl. iPad) is always
|
|
// Side-by-side panels exist only on web. Native (incl. iPad) is always
|
|
|
// stack navigation: list → conversation. Width-based "desktop" would leave
|
|
// stack navigation: list → conversation. Width-based "desktop" would leave
|
|
@@ -72,19 +110,22 @@ export default function Messenger({ defaultConvoId, selectedMenu, convos, fetchC
|
|
|
const [panelsVisible, setPanelsVisible] = useState({ convos: true, jots: isSmallScreen ? false : true });
|
|
const [panelsVisible, setPanelsVisible] = useState({ convos: true, jots: isSmallScreen ? false : true });
|
|
|
const panelsVisibleRef = useRef(panelsVisible);
|
|
const panelsVisibleRef = useRef(panelsVisible);
|
|
|
panelsVisibleRef.current = panelsVisible;
|
|
panelsVisibleRef.current = panelsVisible;
|
|
|
- const [commentForm, setCommentForm] = useState(false);
|
|
|
|
|
- const [jotUpdated, setJotUpdated] = useState(false);
|
|
|
|
|
|
|
+ const [commentForm, setCommentForm] = useState<FormData | false>(false);
|
|
|
|
|
+ const [jotUpdated, setJotUpdated] = useState<LoosePayload | false>(false);
|
|
|
const [formHeight, setFormHeight] = useState(0);
|
|
const [formHeight, setFormHeight] = useState(0);
|
|
|
const [splitHeight, setSplitHeight] = useState(0);
|
|
const [splitHeight, setSplitHeight] = useState(0);
|
|
|
- const [showMsg, setShowMsg] = useState(false);
|
|
|
|
|
- const refListJots = useRef();
|
|
|
|
|
|
|
+ const [showMsg, setShowMsg] = useState<string | false>(false);
|
|
|
|
|
+ const refListJots = useRef<any>(null);
|
|
|
const isFetchingJots = useRef(false);
|
|
const isFetchingJots = useRef(false);
|
|
|
const hasMoreJots = useRef(true);
|
|
const hasMoreJots = useRef(true);
|
|
|
- const jotsPaginationRef = useRef(null);
|
|
|
|
|
- const [convosData, setConvosData] = useState(convos?.data || []);
|
|
|
|
|
|
|
+ const jotsPaginationRef = useRef<LoosePayload | null>(null);
|
|
|
|
|
+ // Saved messages removed from the open list since the last page: UNA pages by offset.
|
|
|
|
|
+ const removedJotsRef = useRef(0);
|
|
|
|
|
+ const [convosData, setConvosData] = useState<Convo[]>((convos && convos.data) || []);
|
|
|
const selectedConvoIndex = convosData && convoId ? convosData.findIndex(item => item.id === convoId) : -1;
|
|
const selectedConvoIndex = convosData && convoId ? convosData.findIndex(item => item.id === convoId) : -1;
|
|
|
- const selectedConvo = convosData ? convosData[selectedConvoIndex] : false;
|
|
|
|
|
|
|
+ const selectedConvo: Convo | undefined = selectedConvoIndex >= 0 ? convosData[selectedConvoIndex] : undefined;
|
|
|
const { currentUser } = useCurrentUser();
|
|
const { currentUser } = useCurrentUser();
|
|
|
|
|
+ const currentUserId = currentUser ? currentUser.id : undefined;
|
|
|
// NativeTabs screens run under the tab bar (iOS floats it, Android draws
|
|
// NativeTabs screens run under the tab bar (iOS floats it, Android draws
|
|
|
// it over the page), so lists pad and the composer rests this far up.
|
|
// it over the page), so lists pad and the composer rests this far up.
|
|
|
// Window insets, not `insets`: NativeTabs can report the keyboard height
|
|
// Window insets, not `insets`: NativeTabs can report the keyboard height
|
|
@@ -99,7 +140,7 @@ export default function Messenger({ defaultConvoId, selectedMenu, convos, fetchC
|
|
|
const stickyOpened = isWeb
|
|
const stickyOpened = isWeb
|
|
|
? 0
|
|
? 0
|
|
|
: tabBarOverlayInset || TAB_BAR_HEIGHT + (Platform.OS === 'android' ? insets.bottom : 0);
|
|
: tabBarOverlayInset || TAB_BAR_HEIGHT + (Platform.OS === 'android' ? insets.bottom : 0);
|
|
|
- const [replyItem, setReplyItem] = useState(false);
|
|
|
|
|
|
|
+ const [replyItem, setReplyItem] = useState<Jot | false>(false);
|
|
|
// iOS: one blur from the screen bottom up through the composer, instead of
|
|
// iOS: one blur from the screen bottom up through the composer, instead of
|
|
|
// the tab screen's bar blur plus a separate one behind the composer.
|
|
// the tab screen's bar blur plus a separate one behind the composer.
|
|
|
const footerBlur = edgeBlurConfig('footer');
|
|
const footerBlur = edgeBlurConfig('footer');
|
|
@@ -114,7 +155,7 @@ export default function Messenger({ defaultConvoId, selectedMenu, convos, fetchC
|
|
|
return () => setPageBottomBlur(false);
|
|
return () => setPageBottomBlur(false);
|
|
|
}, [ownsBottomBlur, setPageBottomBlur]);
|
|
}, [ownsBottomBlur, setPageBottomBlur]);
|
|
|
|
|
|
|
|
- const { data: dynamicData } = useFetchForm('/api.php?r=bx_messenger/get_send_form/Services¶ms=' + JSON.stringify({ id: selectedConvo?.id, convo_id: selectedConvo?.id, reply_id: replyItem ? replyItem?.id : 0 }), commentForm);
|
|
|
|
|
|
|
+ const { data: dynamicData } = useFetchForm('/api.php?r=bx_messenger/get_send_form/Services¶ms=' + JSON.stringify({ id: selectedConvo?.id, convo_id: selectedConvo?.id, reply_id: replyItem ? replyItem?.id : 0 }), commentForm || null);
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
useEffect(() => {
|
|
|
data.form.data.inputs.message.value = '';
|
|
data.form.data.inputs.message.value = '';
|
|
@@ -134,43 +175,76 @@ export default function Messenger({ defaultConvoId, selectedMenu, convos, fetchC
|
|
|
|
|
|
|
|
}, [dynamicData]);
|
|
}, [dynamicData]);
|
|
|
|
|
|
|
|
- const handleSearch = useCallback((term) => {
|
|
|
|
|
|
|
+ // Text of the list search; the list isn't refreshed by new messages while it is set.
|
|
|
|
|
+ const listSearchRef = useRef('');
|
|
|
|
|
+ const handleSearch = useCallback((term: string) => {
|
|
|
|
|
+ listSearchRef.current = term;
|
|
|
fetchConvos(term);
|
|
fetchConvos(term);
|
|
|
}, [fetchConvos]);
|
|
}, [fetchConvos]);
|
|
|
|
|
|
|
|
|
|
+ // UNA sends 'profile_<id>' to every participant on a new message (broadcastNewJot).
|
|
|
|
|
+ // A message in another talk refreshes the list: unread counts, order and previews come
|
|
|
|
|
+ // from UNA. Bursts are merged; own messages and the open talk (fed by its own channel) are skipped.
|
|
|
useEffect(() => {
|
|
useEffect(() => {
|
|
|
- setConvosData(convos?.data || []);
|
|
|
|
|
- }, [convos?.data]);
|
|
|
|
|
|
|
+ if (!currentUserId) return;
|
|
|
|
|
|
|
|
- const fetchItems = useCallback(async (targetConvoId, isAddJots) => {
|
|
|
|
|
|
|
+ let timer: ReturnType<typeof setTimeout> | undefined;
|
|
|
|
|
+ const unsubscribe = subscribe('bx_messenger', 'profile_' + currentUserId, (event) => {
|
|
|
|
|
+ if (event?.user_id == currentUserId || listSearchRef.current) return;
|
|
|
|
|
+ if (event?.id && event.id == convoIdRef.current && panelsVisibleRef.current.jots) return;
|
|
|
|
|
+
|
|
|
|
|
+ clearTimeout(timer);
|
|
|
|
|
+ timer = setTimeout(() => fetchConvos(), 400);
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ return () => {
|
|
|
|
|
+ clearTimeout(timer);
|
|
|
|
|
+ unsubscribe();
|
|
|
|
|
+ };
|
|
|
|
|
+ }, [currentUserId, fetchConvos]);
|
|
|
|
|
+
|
|
|
|
|
+ useEffect(() => {
|
|
|
|
|
+ setConvosData((convos && convos.data) || []);
|
|
|
|
|
+ }, [convos]);
|
|
|
|
|
+
|
|
|
|
|
+ const fetchItems = useCallback(async (targetConvoId: string, isAddJots: boolean) => {
|
|
|
if (isAddJots && (isFetchingJots.current || !hasMoreJots.current)) return;
|
|
if (isAddJots && (isFetchingJots.current || !hasMoreJots.current)) return;
|
|
|
|
|
|
|
|
const params = jotsPaginationRef.current;
|
|
const params = jotsPaginationRef.current;
|
|
|
let start = 0;
|
|
let start = 0;
|
|
|
|
|
|
|
|
if (params?.limit && isAddJots)
|
|
if (params?.limit && isAddJots)
|
|
|
- start = (params.start ?? 0) + params.limit;
|
|
|
|
|
|
|
+ start = Math.max(0, (params.start ?? 0) + params.limit - removedJotsRef.current);
|
|
|
|
|
|
|
|
isFetchingJots.current = true;
|
|
isFetchingJots.current = true;
|
|
|
try {
|
|
try {
|
|
|
- let request_url = '/api.php?r=bx_messenger/get_convo_messages/Services¶ms=' + JSON.stringify({ lot: targetConvoId, jot: 0, start: start });
|
|
|
|
|
|
|
+ // Saved: only the messages the viewer saved in this talk, as on the site.
|
|
|
|
|
+ const area = selectedMenuRef.current === 'saved' ? { area_type: 'saved' } : {};
|
|
|
|
|
+ let request_url = '/api.php?r=bx_messenger/get_convo_messages/Services¶ms=' + JSON.stringify({ lot: targetConvoId, jot: 0, start: start, ...area });
|
|
|
const sResponse = await fetcher(request_url);
|
|
const sResponse = await fetcher(request_url);
|
|
|
|
|
|
|
|
- const newJots = sResponse.data?.jots ?? [];
|
|
|
|
|
|
|
+ const newJots: Jot[] = sResponse.data?.jots ?? [];
|
|
|
if (isAddJots && newJots.length === 0) {
|
|
if (isAddJots && newJots.length === 0) {
|
|
|
hasMoreJots.current = false;
|
|
hasMoreJots.current = false;
|
|
|
return;
|
|
return;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
jotsPaginationRef.current = sResponse.data.params;
|
|
jotsPaginationRef.current = sResponse.data.params;
|
|
|
|
|
+ removedJotsRef.current = 0;
|
|
|
|
|
|
|
|
setJots(prevJots => ({
|
|
setJots(prevJots => ({
|
|
|
- ...prevJots,
|
|
|
|
|
|
|
+ ...(prevJots || {}),
|
|
|
data: {
|
|
data: {
|
|
|
params: sResponse.data.params,
|
|
params: sResponse.data.params,
|
|
|
- jots: prevJots && isAddJots ? [...newJots, ...prevJots?.data?.jots] : newJots
|
|
|
|
|
|
|
+ jots: prevJots && isAddJots ? [...newJots, ...(prevJots.data?.jots || [])] : newJots
|
|
|
|
|
+ },
|
|
|
|
|
+ index: prevJots && isAddJots ? prevJots.index : 0,
|
|
|
|
|
+ // Unread on opening the talk (UNA counts them before marking them read):
|
|
|
|
|
+ // the 'new messages' line stays where it was while the talk is open.
|
|
|
|
|
+ unread: prevJots && isAddJots ? prevJots.unread : {
|
|
|
|
|
+ count: parseInt(sResponse.data?.unread_jots) || 0,
|
|
|
|
|
+ firstId: parseInt(sResponse.data?.last_unread_jot) || 0,
|
|
|
},
|
|
},
|
|
|
- index: prevJots && isAddJots ? prevJots.index : 0
|
|
|
|
|
}));
|
|
}));
|
|
|
} finally {
|
|
} finally {
|
|
|
isFetchingJots.current = false;
|
|
isFetchingJots.current = false;
|
|
@@ -185,7 +259,7 @@ export default function Messenger({ defaultConvoId, selectedMenu, convos, fetchC
|
|
|
// Native (NativeTabs): a conversation is a page pushed onto the tab's
|
|
// Native (NativeTabs): a conversation is a page pushed onto the tab's
|
|
|
// stack, so the edge swipe / system back return to the list; its back
|
|
// stack, so the edge swipe / system back return to the list; its back
|
|
|
// arrow pops it.
|
|
// arrow pops it.
|
|
|
- const router = useRouter();
|
|
|
|
|
|
|
+ const router = useRouter() as unknown as MessengerRouter;
|
|
|
const currentTabPath = useCurrentTabPath();
|
|
const currentTabPath = useCurrentTabPath();
|
|
|
const isPushedScreen = useIsPushedScreen();
|
|
const isPushedScreen = useIsPushedScreen();
|
|
|
const stackedChat = !isWeb && isNativeTabsEnabled();
|
|
const stackedChat = !isWeb && isNativeTabsEnabled();
|
|
@@ -196,7 +270,7 @@ export default function Messenger({ defaultConvoId, selectedMenu, convos, fetchC
|
|
|
return path === messengerUrl || path.startsWith(messengerUrl + '/');
|
|
return path === messengerUrl || path.startsWith(messengerUrl + '/');
|
|
|
}, [messengerUrl]);
|
|
}, [messengerUrl]);
|
|
|
|
|
|
|
|
- const syncConvoUrl = useCallback((id, { push = false } = {}) => {
|
|
|
|
|
|
|
+ const syncConvoUrl = useCallback((id: string | undefined, { push = false }: { push?: boolean } = {}) => {
|
|
|
if (!isWeb || !id || !isOnMessengerPath()) return;
|
|
if (!isWeb || !id || !isOnMessengerPath()) return;
|
|
|
const target = `${messengerUrl}/${selectedMenu}/${id}/`;
|
|
const target = `${messengerUrl}/${selectedMenu}/${id}/`;
|
|
|
if (window.location.pathname === target) return;
|
|
if (window.location.pathname === target) return;
|
|
@@ -258,27 +332,26 @@ export default function Messenger({ defaultConvoId, selectedMenu, convos, fetchC
|
|
|
// Skip lists fetched for another menu (stale during an inbox/direct switch).
|
|
// Skip lists fetched for another menu (stale during an inbox/direct switch).
|
|
|
useEffect(() => {
|
|
useEffect(() => {
|
|
|
if (isSmallScreen) return;
|
|
if (isSmallScreen) return;
|
|
|
- if (convos?.menu && convos.menu !== selectedMenu) return;
|
|
|
|
|
- if (convoId == '' && convosData?.length > 0) {
|
|
|
|
|
- const first = convosData[0];
|
|
|
|
|
|
|
+ if (convos && convos.menu && convos.menu !== selectedMenu) return;
|
|
|
|
|
+ const first = convosData[0];
|
|
|
|
|
+ if (convoId == '' && first) {
|
|
|
setConvoId(first.id);
|
|
setConvoId(first.id);
|
|
|
syncConvoUrl(first.id);
|
|
syncConvoUrl(first.id);
|
|
|
}
|
|
}
|
|
|
- }, [convosData, convoId, isSmallScreen, syncConvoUrl, convos?.menu, selectedMenu]);
|
|
|
|
|
|
|
+ }, [convosData, convoId, isSmallScreen, syncConvoUrl, convos, selectedMenu]);
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
useEffect(() => {
|
|
|
if (!selectedConvo) return;
|
|
if (!selectedConvo) return;
|
|
|
|
|
|
|
|
const sub1 = subscribe('bx_messenger', 'convo_' + selectedConvo.id, onNewMessage);
|
|
const sub1 = subscribe('bx_messenger', 'convo_' + selectedConvo.id, onNewMessage);
|
|
|
- const sub2 = subscribe('bx_messenger', 'profile_' + currentUser.id, onCheckConvos);
|
|
|
|
|
hasMoreJots.current = true;
|
|
hasMoreJots.current = true;
|
|
|
jotsPaginationRef.current = null;
|
|
jotsPaginationRef.current = null;
|
|
|
|
|
+ removedJotsRef.current = 0;
|
|
|
setJots(false);
|
|
setJots(false);
|
|
|
fetchItems(selectedConvo.id, false);
|
|
fetchItems(selectedConvo.id, false);
|
|
|
|
|
|
|
|
return () => {
|
|
return () => {
|
|
|
sub1();
|
|
sub1();
|
|
|
- sub2();
|
|
|
|
|
};
|
|
};
|
|
|
}, [convoId]);
|
|
}, [convoId]);
|
|
|
|
|
|
|
@@ -286,26 +359,19 @@ export default function Messenger({ defaultConvoId, selectedMenu, convos, fetchC
|
|
|
setListError(null);
|
|
setListError(null);
|
|
|
}, [convoId]);
|
|
}, [convoId]);
|
|
|
|
|
|
|
|
- const onNewMessage = (data) => {
|
|
|
|
|
- if (data.id == convoId) {
|
|
|
|
|
|
|
+ const onNewMessage = (event: LoosePayload) => {
|
|
|
|
|
+ if (event.id == convoId) {
|
|
|
// Someone else's message in the open conversation: one subtle ready per update.
|
|
// Someone else's message in the open conversation: one subtle ready per update.
|
|
|
- const incoming = data.action == 'added' && Array.isArray(data.data?.jots) ? data.data.jots : [];
|
|
|
|
|
|
|
+ const incoming: Jot[] = event.action == 'added' && Array.isArray(event.data?.jots) ? event.data.jots : [];
|
|
|
const fromOthers = incoming.some((jot) => {
|
|
const fromOthers = incoming.some((jot) => {
|
|
|
const author = jot?.author_data?.id ?? jot?.author;
|
|
const author = jot?.author_data?.id ?? jot?.author;
|
|
|
- return author != null && author != currentUser?.id;
|
|
|
|
|
|
|
+ return author != null && author != currentUserId;
|
|
|
});
|
|
});
|
|
|
if (fromOthers) playCue('ready', { emphasis: 'subtle' });
|
|
if (fromOthers) playCue('ready', { emphasis: 'subtle' });
|
|
|
- setJotUpdated(data);
|
|
|
|
|
|
|
+ setJotUpdated(event);
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- const onCheckConvos = (data) => {
|
|
|
|
|
- /*if (convoId != data.id) {
|
|
|
|
|
- fetchConvos();
|
|
|
|
|
- }*/
|
|
|
|
|
- //MAY BE NEED TO RETURN
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
const scrolTo = () => {
|
|
const scrolTo = () => {
|
|
|
if (refListJots && refListJots?.current) {
|
|
if (refListJots && refListJots?.current) {
|
|
|
setTimeout(() => {
|
|
setTimeout(() => {
|
|
@@ -324,18 +390,19 @@ export default function Messenger({ defaultConvoId, selectedMenu, convos, fetchC
|
|
|
useEffect(() => {
|
|
useEffect(() => {
|
|
|
if (!jotUpdated) return;
|
|
if (!jotUpdated) return;
|
|
|
|
|
|
|
|
- const incoming = Array.isArray(jotUpdated.data?.jots) ? jotUpdated.data.jots : [];
|
|
|
|
|
|
|
+ const incoming: Jot[] = Array.isArray(jotUpdated.data?.jots) ? jotUpdated.data.jots : [];
|
|
|
|
|
|
|
|
- if (jotUpdated.action == 'added') {
|
|
|
|
|
|
|
+ // Saved section: the list holds the viewer's saved messages only, new ones aren't saved yet.
|
|
|
|
|
+ if (jotUpdated.action == 'added' && selectedMenuRef.current !== 'saved') {
|
|
|
if (incoming.length) {
|
|
if (incoming.length) {
|
|
|
const latestJot = incoming[incoming.length - 1];
|
|
const latestJot = incoming[incoming.length - 1];
|
|
|
if (latestJot) {
|
|
if (latestJot) {
|
|
|
setConvosData(prevConvos => {
|
|
setConvosData(prevConvos => {
|
|
|
const nextConvos = [...(prevConvos || [])];
|
|
const nextConvos = [...(prevConvos || [])];
|
|
|
const targetIndex = nextConvos.findIndex(convo => convo.id == convoId);
|
|
const targetIndex = nextConvos.findIndex(convo => convo.id == convoId);
|
|
|
- if (targetIndex === -1) return prevConvos;
|
|
|
|
|
-
|
|
|
|
|
const targetConvo = nextConvos[targetIndex];
|
|
const targetConvo = nextConvos[targetIndex];
|
|
|
|
|
+ if (targetIndex === -1 || !targetConvo) return prevConvos;
|
|
|
|
|
+
|
|
|
nextConvos[targetIndex] = {
|
|
nextConvos[targetIndex] = {
|
|
|
...targetConvo,
|
|
...targetConvo,
|
|
|
message: latestJot.message ?? targetConvo.message,
|
|
message: latestJot.message ?? targetConvo.message,
|
|
@@ -345,7 +412,7 @@ export default function Messenger({ defaultConvoId, selectedMenu, convos, fetchC
|
|
|
});
|
|
});
|
|
|
}
|
|
}
|
|
|
setJots(prevJots => {
|
|
setJots(prevJots => {
|
|
|
- const prevList = Array.isArray(prevJots?.data?.jots) ? prevJots.data.jots : [];
|
|
|
|
|
|
|
+ const prevList: Jot[] = prevJots ? prevJots.data?.jots || [] : [];
|
|
|
const seen = new Set(prevList.map(item => item?.id));
|
|
const seen = new Set(prevList.map(item => item?.id));
|
|
|
const merged = [...prevList];
|
|
const merged = [...prevList];
|
|
|
for (const jot of incoming) {
|
|
for (const jot of incoming) {
|
|
@@ -354,28 +421,29 @@ export default function Messenger({ defaultConvoId, selectedMenu, convos, fetchC
|
|
|
merged.push(jot);
|
|
merged.push(jot);
|
|
|
}
|
|
}
|
|
|
return {
|
|
return {
|
|
|
- ...(prevJots && typeof prevJots === 'object' ? prevJots : {}),
|
|
|
|
|
|
|
+ ...(prevJots || {}),
|
|
|
data: {
|
|
data: {
|
|
|
- ...(prevJots?.data || {}),
|
|
|
|
|
|
|
+ ...(prevJots ? prevJots.data : {}),
|
|
|
jots: merged,
|
|
jots: merged,
|
|
|
},
|
|
},
|
|
|
};
|
|
};
|
|
|
});
|
|
});
|
|
|
scrolTo();
|
|
scrolTo();
|
|
|
}
|
|
}
|
|
|
- if (jotUpdated.data?.msg) {
|
|
|
|
|
- setListError(jotUpdated.data.msg);
|
|
|
|
|
- }
|
|
|
|
|
}
|
|
}
|
|
|
- if (jotUpdated.action == 'edited' && incoming[0]) {
|
|
|
|
|
- const edited = incoming[0];
|
|
|
|
|
|
|
+ if (jotUpdated.action == 'added' && jotUpdated.data?.msg) {
|
|
|
|
|
+ setListError(jotUpdated.data.msg);
|
|
|
|
|
+ }
|
|
|
|
|
+ const edited = incoming[0];
|
|
|
|
|
+ if (jotUpdated.action == 'edited' && edited) {
|
|
|
setJots(prevJots => {
|
|
setJots(prevJots => {
|
|
|
- const prevList = Array.isArray(prevJots?.data?.jots) ? prevJots.data.jots : [];
|
|
|
|
|
|
|
+ const prevList: Jot[] = prevJots ? prevJots.data?.jots || [] : [];
|
|
|
return {
|
|
return {
|
|
|
- ...(prevJots && typeof prevJots === 'object' ? prevJots : {}),
|
|
|
|
|
|
|
+ ...(prevJots || {}),
|
|
|
data: {
|
|
data: {
|
|
|
- ...(prevJots?.data || {}),
|
|
|
|
|
- jots: prevList.map(item => item.id === edited.id ? edited : item),
|
|
|
|
|
|
|
+ ...(prevJots ? prevJots.data : {}),
|
|
|
|
|
+ // 'saved' in the broadcast is the editor's, keep the viewer's own.
|
|
|
|
|
+ jots: prevList.map(item => item.id === edited.id ? { ...edited, saved: item.saved } : item),
|
|
|
},
|
|
},
|
|
|
};
|
|
};
|
|
|
});
|
|
});
|
|
@@ -383,11 +451,11 @@ export default function Messenger({ defaultConvoId, selectedMenu, convos, fetchC
|
|
|
if (jotUpdated.action == 'deleted') {
|
|
if (jotUpdated.action == 'deleted') {
|
|
|
const idToRemove = jotUpdated.data;
|
|
const idToRemove = jotUpdated.data;
|
|
|
setJots(prevJots => {
|
|
setJots(prevJots => {
|
|
|
- const prevList = Array.isArray(prevJots?.data?.jots) ? prevJots.data.jots : [];
|
|
|
|
|
|
|
+ const prevList: Jot[] = prevJots ? prevJots.data?.jots || [] : [];
|
|
|
return {
|
|
return {
|
|
|
- ...(prevJots && typeof prevJots === 'object' ? prevJots : {}),
|
|
|
|
|
|
|
+ ...(prevJots || {}),
|
|
|
data: {
|
|
data: {
|
|
|
- ...(prevJots?.data || {}),
|
|
|
|
|
|
|
+ ...(prevJots ? prevJots.data : {}),
|
|
|
jots: prevList.filter(item => item.id !== idToRemove),
|
|
jots: prevList.filter(item => item.id !== idToRemove),
|
|
|
},
|
|
},
|
|
|
index: 0,
|
|
index: 0,
|
|
@@ -396,7 +464,7 @@ export default function Messenger({ defaultConvoId, selectedMenu, convos, fetchC
|
|
|
}
|
|
}
|
|
|
}, [jotUpdated]);
|
|
}, [jotUpdated]);
|
|
|
|
|
|
|
|
- const changeConvo = useCallback((convo) => {
|
|
|
|
|
|
|
+ const changeConvo = useCallback((convo: Convo) => {
|
|
|
if (stackedChat && isSmallScreenRef.current) {
|
|
if (stackedChat && isSmallScreenRef.current) {
|
|
|
// No trailing slash: the API redirects `…/<id>/` to `…/<id>`.
|
|
// No trailing slash: the API redirects `…/<id>/` to `…/<id>`.
|
|
|
router.push(nativeTabPageHref(`${messengerUrl}/${selectedMenu}/${convo.id}`, currentTabPath));
|
|
router.push(nativeTabPageHref(`${messengerUrl}/${selectedMenu}/${convo.id}`, currentTabPath));
|
|
@@ -429,7 +497,7 @@ export default function Messenger({ defaultConvoId, selectedMenu, convos, fetchC
|
|
|
|
|
|
|
|
// Bottom-tab reselect: leave open chat and show conversation list.
|
|
// Bottom-tab reselect: leave open chat and show conversation list.
|
|
|
useEffect(() => {
|
|
useEffect(() => {
|
|
|
- const subscription = emitter.addListener(EVENTS.conductor, (payload) => {
|
|
|
|
|
|
|
+ const subscription = emitter.addListener(EVENTS.conductor, (payload: LoosePayload) => {
|
|
|
if (!isFocusedRef.current) return;
|
|
if (!isFocusedRef.current) return;
|
|
|
if (payload?.action !== 'reset_to_first') return;
|
|
if (payload?.action !== 'reset_to_first') return;
|
|
|
if (!(isSmallScreen && panelsVisibleRef.current.jots && !panelsVisibleRef.current.convos)) return;
|
|
if (!(isSmallScreen && panelsVisibleRef.current.jots && !panelsVisibleRef.current.convos)) return;
|
|
@@ -438,79 +506,199 @@ export default function Messenger({ defaultConvoId, selectedMenu, convos, fetchC
|
|
|
return () => subscription.remove();
|
|
return () => subscription.remove();
|
|
|
}, [isSmallScreen, showConvo]);
|
|
}, [isSmallScreen, showConvo]);
|
|
|
|
|
|
|
|
|
|
+ // After delete / leave: the list is refetched, the first talk of the current one is opened.
|
|
|
|
|
+ const openFirstConvo = useCallback(() => {
|
|
|
|
|
+ fetchConvos();
|
|
|
|
|
+ const first = convos && convos.data ? convos.data[0] : undefined;
|
|
|
|
|
+ setConvoId(first ? first.id : '');
|
|
|
|
|
+ syncConvoUrl(first?.id);
|
|
|
|
|
+ }, [convos, fetchConvos, syncConvoUrl]);
|
|
|
|
|
+
|
|
|
const deleteConvo = useCallback(async () => {
|
|
const deleteConvo = useCallback(async () => {
|
|
|
- let request_url = '/api.php?r=bx_messenger/delete_convo/Services¶ms=' + JSON.stringify({ lot: selectedConvo.id2 });
|
|
|
|
|
|
|
+ let request_url = '/api.php?r=bx_messenger/delete_convo/Services¶ms=' + JSON.stringify({ lot: selectedConvo?.id2 });
|
|
|
const sResponse = await fetcher(request_url);
|
|
const sResponse = await fetcher(request_url);
|
|
|
if (sResponse.data?.message) {
|
|
if (sResponse.data?.message) {
|
|
|
setShowMsg(sResponse.data?.message)
|
|
setShowMsg(sResponse.data?.message)
|
|
|
}
|
|
}
|
|
|
else {
|
|
else {
|
|
|
- fetchConvos();
|
|
|
|
|
- setConvoId(convos.data[0].id);
|
|
|
|
|
- syncConvoUrl(convos.data[0].id);
|
|
|
|
|
|
|
+ openFirstConvo();
|
|
|
}
|
|
}
|
|
|
- }, [selectedConvo?.id2, convos.data, syncConvoUrl]);
|
|
|
|
|
|
|
+ }, [selectedConvo?.id2, openFirstConvo]);
|
|
|
|
|
|
|
|
const leaveConvo = useCallback(async () => {
|
|
const leaveConvo = useCallback(async () => {
|
|
|
- let request_url = '/api.php?r=bx_messenger/leave_convo/Services¶ms=' + JSON.stringify({ lot: selectedConvo.id2 });
|
|
|
|
|
|
|
+ let request_url = '/api.php?r=bx_messenger/leave_convo/Services¶ms=' + JSON.stringify({ lot: selectedConvo?.id2 });
|
|
|
const sResponse = await fetcher(request_url);
|
|
const sResponse = await fetcher(request_url);
|
|
|
if (sResponse.data?.message) {
|
|
if (sResponse.data?.message) {
|
|
|
setShowMsg(sResponse.data?.message)
|
|
setShowMsg(sResponse.data?.message)
|
|
|
}
|
|
}
|
|
|
else {
|
|
else {
|
|
|
- fetchConvos();
|
|
|
|
|
- setConvoId(convos.data[0].id);
|
|
|
|
|
- syncConvoUrl(convos.data[0].id);
|
|
|
|
|
|
|
+ openFirstConvo();
|
|
|
}
|
|
}
|
|
|
- }, [selectedConvo?.id2, convos.data, syncConvoUrl]);
|
|
|
|
|
|
|
+ }, [selectedConvo?.id2, openFirstConvo]);
|
|
|
|
|
|
|
|
const getConvo = useCallback(async () => {
|
|
const getConvo = useCallback(async () => {
|
|
|
- let request_url = '/api.php?r=bx_messenger/get_convo/Services¶ms=' + JSON.stringify({ lot: selectedConvo.id2 });
|
|
|
|
|
|
|
+ let request_url = '/api.php?r=bx_messenger/get_convo/Services¶ms=' + JSON.stringify({ lot: selectedConvo?.id2 });
|
|
|
const sResponse = await fetcher(request_url);
|
|
const sResponse = await fetcher(request_url);
|
|
|
if (sResponse.data?.message) {
|
|
if (sResponse.data?.message) {
|
|
|
setShowMsg(sResponse.data?.message)
|
|
setShowMsg(sResponse.data?.message)
|
|
|
}
|
|
}
|
|
|
else {
|
|
else {
|
|
|
- const content = (<View className='items-center justify-center'>
|
|
|
|
|
- <Row className='mb-4 gap-x-4'>
|
|
|
|
|
- <Profile displaySize="base" displayType="unit_wo_info" {...sResponse.data.lot.author_data} />
|
|
|
|
|
- <View>
|
|
|
|
|
- <Text className="text-popover-foreground ">{t('Participants:')} {sResponse.data.lot.parts}</Text>
|
|
|
|
|
- <Text className="text-popover-foreground ">{t('Messages:')} {sResponse.data.lot.messages}</Text>
|
|
|
|
|
- <Text className="text-popover-foreground ">{t('Files:')} {sResponse.data.lot.files}</Text>
|
|
|
|
|
- </View>
|
|
|
|
|
- </Row>
|
|
|
|
|
- </View>)
|
|
|
|
|
- setBottomSheetData({ title: 'Conversation info', content: content, showClose: true, snapPoints: ['25%', '50%'] });
|
|
|
|
|
|
|
+ const lot = sResponse.data.lot;
|
|
|
|
|
+ // Labels without a trailing colon: i18next reads "Participants:" as a namespace and renders nothing.
|
|
|
|
|
+ const stats: [string, ReactNode][] = [
|
|
|
|
|
+ [t('Participants'), lot.parts],
|
|
|
|
|
+ [t('Messages'), lot.messages],
|
|
|
|
|
+ [t('Files'), lot.files],
|
|
|
|
|
+ ];
|
|
|
|
|
+ // Author (avatar + name) on the left, stats on the right.
|
|
|
|
|
+ const content = (<Row className='w-full items-center gap-x-8 pb-4'>
|
|
|
|
|
+ <View className='items-center gap-y-3 shrink-0 max-w-48'>
|
|
|
|
|
+ <Profile displaySize="2xl" displayType="unit_wo_info" {...lot.author_data} />
|
|
|
|
|
+ {lot.author_data?.display_name ? (
|
|
|
|
|
+ <Text className="text-popover-foreground text-xl font-bold text-center" numberOfLines={2}>{lot.author_data.display_name}</Text>
|
|
|
|
|
+ ) : null}
|
|
|
|
|
+ </View>
|
|
|
|
|
+ <View className='flex-1 min-w-0 gap-y-3'>
|
|
|
|
|
+ {stats.map(([label, value]) => (
|
|
|
|
|
+ <Text key={label} className="text-muted-foreground text-base">{label}: {value ?? 0}</Text>
|
|
|
|
|
+ ))}
|
|
|
|
|
+ </View>
|
|
|
|
|
+ </Row>)
|
|
|
|
|
+ setBottomSheetData({ title: t('Conversation info'), content: content, showClose: true, snapPoints: ['50%', '50%'] });
|
|
|
}
|
|
}
|
|
|
- }, [selectedConvo?.id2, convos.data]);
|
|
|
|
|
|
|
+ }, [selectedConvo?.id2]);
|
|
|
|
|
|
|
|
- const onSaveHandler = (data) => {
|
|
|
|
|
|
|
+ const onSaveHandler = (saved: LoosePayload) => {
|
|
|
setBottomSheetData(false);
|
|
setBottomSheetData(false);
|
|
|
- onSave(data);
|
|
|
|
|
|
|
+ onSave(saved);
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
const editConvo = useCallback(async () => {
|
|
const editConvo = useCallback(async () => {
|
|
|
- let request_url = '/api.php?r=bx_messenger/get_parts_list/Services¶ms=' + JSON.stringify({ lot: selectedConvo.id2 });
|
|
|
|
|
|
|
+ let request_url = '/api.php?r=bx_messenger/get_parts_list/Services¶ms=' + JSON.stringify({ lot: selectedConvo?.id2 });
|
|
|
const sResponse = await fetcher(request_url);
|
|
const sResponse = await fetcher(request_url);
|
|
|
- setBottomSheetData({ title: t('messenger_add_users'), content: <CreateConvo onSave={onSaveHandler} initedData={sResponse.data} convoId={selectedConvo.id2} />, showClose: true, snapPoints: ['85%', '85%'] });
|
|
|
|
|
- }, [selectedConvo?.id2, convos.data]);
|
|
|
|
|
|
|
+ setBottomSheetData({ title: t('messenger_add_users'), content: <CreateConvoForm onSave={onSaveHandler} initedData={sResponse.data} convoId={selectedConvo?.id2} />, showClose: true, snapPoints: ['85%', '85%'] });
|
|
|
|
|
+ }, [selectedConvo?.id2]);
|
|
|
|
|
|
|
|
- const handleConvoAction = useCallback((item) => {
|
|
|
|
|
|
|
+ const clearConvo = useCallback(async () => {
|
|
|
|
|
+ const sResponse = await fetcher('/api.php?r=bx_messenger/clear_convo/Services¶ms=' + JSON.stringify({ lot: selectedConvo?.id2 }));
|
|
|
|
|
+ if (sResponse.data?.code != 0) {
|
|
|
|
|
+ setShowMsg(sResponse.data?.message || t('Error'));
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ hasMoreJots.current = false;
|
|
|
|
|
+ jotsPaginationRef.current = null;
|
|
|
|
|
+ setJots(prevJots => ({ ...(prevJots || {}), data: { ...(prevJots ? prevJots.data : {}), jots: [] }, index: 0 }));
|
|
|
|
|
+ setConvosData(prevConvos => (prevConvos || []).map(convo => convo.id === selectedConvo?.id ? { ...convo, message: '' } : convo));
|
|
|
|
|
+ }, [selectedConvo]);
|
|
|
|
|
+
|
|
|
|
|
+ const openSettings = useCallback(async () => {
|
|
|
|
|
+ const lotId = selectedConvo?.id2;
|
|
|
|
|
+ const sResponse = await fetcher('/api.php?r=bx_messenger/get_convo_settings/Services¶ms=' + JSON.stringify({ lot: lotId }));
|
|
|
|
|
+ if (sResponse.data?.message || !Array.isArray(sResponse.data?.options)) {
|
|
|
|
|
+ setShowMsg(sResponse.data?.message || t('Error'));
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ setBottomSheetData({
|
|
|
|
|
+ title: t('Settings'),
|
|
|
|
|
+ content: (
|
|
|
|
|
+ <ConvoSettings
|
|
|
|
|
+ lotId={lotId ?? ''}
|
|
|
|
|
+ settings={sResponse.data}
|
|
|
|
|
+ onSaved={() => setBottomSheetData(false)}
|
|
|
|
|
+ onError={(message: string) => {
|
|
|
|
|
+ setBottomSheetData(false);
|
|
|
|
|
+ setShowMsg(message);
|
|
|
|
|
+ }}
|
|
|
|
|
+ />
|
|
|
|
|
+ ),
|
|
|
|
|
+ showClose: true,
|
|
|
|
|
+ snapPoints: ['50%', '50%'],
|
|
|
|
|
+ });
|
|
|
|
|
+ }, [selectedConvo?.id2]);
|
|
|
|
|
+
|
|
|
|
|
+ // Header search in the open conversation's messages; it closes when another conversation opens.
|
|
|
|
|
+ const [convoSearch, setConvoSearch] = useState<{ convoId: string | null; term: string }>({ convoId: null, term: '' });
|
|
|
|
|
+ const searchOpen = !!selectedConvo && convoSearch.convoId === convoId;
|
|
|
|
|
+ const toggleSearch = useCallback(() => {
|
|
|
|
|
+ setConvoSearch(prev => (prev.convoId === convoIdRef.current ? { convoId: null, term: '' } : { convoId: convoIdRef.current, term: '' }));
|
|
|
|
|
+ }, []);
|
|
|
|
|
+ const handleSearchTerm = useCallback((term: string) => {
|
|
|
|
|
+ setConvoSearch(prev => (prev.term === term ? prev : { ...prev, term }));
|
|
|
|
|
+ }, []);
|
|
|
|
|
+
|
|
|
|
|
+ const openFiles = useCallback(() => {
|
|
|
|
|
+ setBottomSheetData({
|
|
|
|
|
+ title: t('Files'),
|
|
|
|
|
+ content: <ConvoFiles lotId={selectedConvo?.id2 ?? ''} />,
|
|
|
|
|
+ showClose: true,
|
|
|
|
|
+ snapPoints: ['85%', '85%'],
|
|
|
|
|
+ });
|
|
|
|
|
+ }, [selectedConvo?.id2]);
|
|
|
|
|
+
|
|
|
|
|
+ // Delete / clear can be a single tap on the header button, so they ask first.
|
|
|
|
|
+ const [confirmAction, setConfirmAction] = useState<'delete' | 'clear' | false>(false);
|
|
|
|
|
+
|
|
|
|
|
+ // Mute / star: toggles for the viewer, UNA returns the new state.
|
|
|
|
|
+ const toggleConvo = useCallback(async (action: 'mute' | 'star') => {
|
|
|
|
|
+ const sResponse = await fetcher('/api.php?r=bx_messenger/' + action + '_convo/Services¶ms=' + JSON.stringify({ lot: selectedConvo?.id2 }));
|
|
|
|
|
+ if (sResponse.data?.code != 0) {
|
|
|
|
|
+ playCue('error');
|
|
|
|
|
+ setShowMsg(sResponse.data?.message || t('Error'));
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ playCue('toggle', { emphasis: 'subtle' });
|
|
|
|
|
+ if (action === 'star') {
|
|
|
|
|
+ // Star badge in the list follows the new state right away.
|
|
|
|
|
+ const lotId = selectedConvo?.id2;
|
|
|
|
|
+ setConvosData(prevConvos => (prevConvos || []).map(convo => convo.id2 == lotId ? { ...convo, starred: sResponse.data.value } : convo));
|
|
|
|
|
+ // Favorites list shows starred talks only: refresh it after a star change.
|
|
|
|
|
+ if (selectedMenu === 'favorites') fetchConvos();
|
|
|
|
|
+ }
|
|
|
|
|
+ }, [selectedConvo?.id2, selectedMenu, fetchConvos]);
|
|
|
|
|
+
|
|
|
|
|
+ const handleConvoAction = useCallback((item: ConvoAction) => {
|
|
|
|
|
+ if (item.id === 'mute' || item.id === 'star') toggleConvo(item.id);
|
|
|
if (item.id === 'edit') editConvo();
|
|
if (item.id === 'edit') editConvo();
|
|
|
if (item.id === 'leave') leaveConvo();
|
|
if (item.id === 'leave') leaveConvo();
|
|
|
if (item.id === 'info') getConvo();
|
|
if (item.id === 'info') getConvo();
|
|
|
- if (item.id === 'delete') deleteConvo();
|
|
|
|
|
- }, [editConvo, leaveConvo, getConvo, deleteConvo]);
|
|
|
|
|
|
|
+ if (item.id === 'settings') openSettings();
|
|
|
|
|
+ if (item.id === 'files') openFiles();
|
|
|
|
|
+ if (item.id === 'delete' || item.id === 'clear') setConfirmAction(item.id);
|
|
|
|
|
+ }, [editConvo, leaveConvo, getConvo, openSettings, openFiles, toggleConvo]);
|
|
|
|
|
+
|
|
|
|
|
+ const confirmDialog = (
|
|
|
|
|
+ <ConfirmDialog
|
|
|
|
|
+ onVisible={!!confirmAction}
|
|
|
|
|
+ title={t('Are you sure?')}
|
|
|
|
|
+ handleCancel={() => setConfirmAction(false)}
|
|
|
|
|
+ handleOk={() => {
|
|
|
|
|
+ const action = confirmAction;
|
|
|
|
|
+ setConfirmAction(false);
|
|
|
|
|
+ if (action === 'delete') deleteConvo();
|
|
|
|
|
+ if (action === 'clear') clearConvo();
|
|
|
|
|
+ }}
|
|
|
|
|
+ />
|
|
|
|
|
+ );
|
|
|
|
|
|
|
|
- const onFormSubmit = useCallback((formData) => {
|
|
|
|
|
|
|
+ const onFormSubmit = useCallback((formData: FormData) => {
|
|
|
setCommentForm(formData);
|
|
setCommentForm(formData);
|
|
|
}, []);
|
|
}, []);
|
|
|
|
|
|
|
|
- const handleReply = useCallback((item) => {
|
|
|
|
|
|
|
+ const handleReply = useCallback((item: Jot) => {
|
|
|
setReplyItem(item)
|
|
setReplyItem(item)
|
|
|
}, [])
|
|
}, [])
|
|
|
|
|
|
|
|
|
|
+ // Saved section: a message removed from saved leaves the list; an emptied talk leaves the talks list.
|
|
|
|
|
+ const handleSavedChange = useCallback((item: Jot, saved: boolean) => {
|
|
|
|
|
+ if (saved || selectedMenuRef.current !== 'saved') return;
|
|
|
|
|
+
|
|
|
|
|
+ const rest = (jots ? jots.data.jots : []).filter(jot => jot.id !== item.id);
|
|
|
|
|
+ // UNA pages saved messages by offset: one less of them, one less to skip on the next page.
|
|
|
|
|
+ removedJotsRef.current += 1;
|
|
|
|
|
+ setJots(prevJots => (prevJots ? { ...prevJots, data: { ...prevJots.data, jots: prevJots.data.jots.filter(jot => jot.id !== item.id) } } : prevJots));
|
|
|
|
|
+ if (!rest.length) fetchConvos();
|
|
|
|
|
+ }, [jots, fetchConvos]);
|
|
|
|
|
+
|
|
|
const handleCancelReply = useCallback(() => {
|
|
const handleCancelReply = useCallback(() => {
|
|
|
setReplyItem(false)
|
|
setReplyItem(false)
|
|
|
}, [])
|
|
}, [])
|
|
@@ -520,11 +708,11 @@ export default function Messenger({ defaultConvoId, selectedMenu, convos, fetchC
|
|
|
fetchItems(selectedConvo.id, true);
|
|
fetchItems(selectedConvo.id, true);
|
|
|
}, [selectedConvo?.id])
|
|
}, [selectedConvo?.id])
|
|
|
|
|
|
|
|
- const handleFormLayout = useCallback((event) => {
|
|
|
|
|
|
|
+ const handleFormLayout = useCallback((event: LoosePayload) => {
|
|
|
setFormHeight(event.nativeEvent.layout.height)
|
|
setFormHeight(event.nativeEvent.layout.height)
|
|
|
}, []);
|
|
}, []);
|
|
|
|
|
|
|
|
- const handleSplitLayout = useCallback((event) => {
|
|
|
|
|
|
|
+ const handleSplitLayout = useCallback((event: LoosePayload) => {
|
|
|
const { height } = event.nativeEvent.layout;
|
|
const { height } = event.nativeEvent.layout;
|
|
|
if (height > 0)
|
|
if (height > 0)
|
|
|
setSplitHeight(prev => (prev === height ? prev : height));
|
|
setSplitHeight(prev => (prev === height ? prev : height));
|
|
@@ -538,7 +726,7 @@ export default function Messenger({ defaultConvoId, selectedMenu, convos, fetchC
|
|
|
const injectedHeader = useMemo(() => {
|
|
const injectedHeader = useMemo(() => {
|
|
|
if (!isSmallScreen) return null;
|
|
if (!isSmallScreen) return null;
|
|
|
if (panelsVisible.jots && selectedConvo) {
|
|
if (panelsVisible.jots && selectedConvo) {
|
|
|
- return <MobileChatHeader title={selectedConvo.title} onBack={showConvo} onAction={handleConvoAction} />;
|
|
|
|
|
|
|
+ return <MobileChatHeader title={selectedConvo.title} onBack={showConvo} menu={selectedConvo.menu} menuKey={selectedConvo.id} onAction={handleConvoAction} searchOpen={searchOpen} onSearchToggle={toggleSearch} onSearch={handleSearchTerm} />;
|
|
|
}
|
|
}
|
|
|
return (
|
|
return (
|
|
|
<MobileListHeader
|
|
<MobileListHeader
|
|
@@ -550,22 +738,24 @@ export default function Messenger({ defaultConvoId, selectedMenu, convos, fetchC
|
|
|
onMenuChange={onMenuChange}
|
|
onMenuChange={onMenuChange}
|
|
|
/>
|
|
/>
|
|
|
);
|
|
);
|
|
|
- }, [isSmallScreen, panelsVisible.jots, selectedConvo?.id, selectedConvo?.title, showConvo, handleConvoAction, pageData, handleSearch, addButtons, menuItems, menuIndex, onMenuChange]);
|
|
|
|
|
|
|
+ }, [isSmallScreen, panelsVisible.jots, selectedConvo?.id, selectedConvo?.title, showConvo, selectedConvo?.menu, handleConvoAction, searchOpen, toggleSearch, handleSearchTerm, pageData, handleSearch, addButtons, menuItems, menuIndex, onMenuChange]);
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------
|
|
// ---------------------------------------------------------------------
|
|
|
// Render
|
|
// Render
|
|
|
// ---------------------------------------------------------------------
|
|
// ---------------------------------------------------------------------
|
|
|
- const jotsLoaded = !!(panelsVisible.jots && selectedConvo && jots?.data?.jots);
|
|
|
|
|
|
|
+ const jotsLoaded = !!(panelsVisible.jots && selectedConvo && jots && jots.data?.jots);
|
|
|
|
|
+ const jotsList: Jot[] = jots ? jots.data.jots : [];
|
|
|
|
|
+ const unread = jots ? jots.unread : undefined;
|
|
|
const errorOffset = listError ? 60 : 0;
|
|
const errorOffset = listError ? 60 : 0;
|
|
|
const convosListProps = {
|
|
const convosListProps = {
|
|
|
title: t('Messages'),
|
|
title: t('Messages'),
|
|
|
emptyText: t('No conversations found'),
|
|
emptyText: t('No conversations found'),
|
|
|
- emptyAction: ({ resetSearch }) => (
|
|
|
|
|
- addButtons?.length > 0 ? <CreateConvoButton variant="secondary" onSave={onSave} onShow={resetSearch} /> : null
|
|
|
|
|
|
|
+ emptyAction: ({ resetSearch }: { resetSearch: () => void }) => (
|
|
|
|
|
+ Array.isArray(addButtons) && addButtons.length > 0 ? <CreateConvoButton variant="secondary" onSave={onSave} onShow={resetSearch} /> : null
|
|
|
),
|
|
),
|
|
|
// Small screens show the list or the chat, never both, so a "current" row
|
|
// Small screens show the list or the chat, never both, so a "current" row
|
|
|
// would only linger after Back. Desktop keeps it highlighted next to the open chat.
|
|
// would only linger after Back. Desktop keeps it highlighted next to the open chat.
|
|
|
- renderItem: ({ item, index }) => (
|
|
|
|
|
|
|
+ renderItem: ({ item, index }: { item: Convo; index: number }) => (
|
|
|
<ItemConvo selectedIndex={isSmallScreen ? -1 : selectedConvoIndex} item={item} index={index} changeConvo={changeConvo} />
|
|
<ItemConvo selectedIndex={isSmallScreen ? -1 : selectedConvoIndex} item={item} index={index} changeConvo={changeConvo} />
|
|
|
),
|
|
),
|
|
|
};
|
|
};
|
|
@@ -597,10 +787,21 @@ export default function Messenger({ defaultConvoId, selectedMenu, convos, fetchC
|
|
|
</View>}
|
|
</View>}
|
|
|
{panelsVisible.jots && (
|
|
{panelsVisible.jots && (
|
|
|
<View className="relative w-full">
|
|
<View className="relative w-full">
|
|
|
- <Msg onVisible={showMsg} title={showMsg} handleOk={() => { setShowMsg(false) }} />
|
|
|
|
|
- {jotsLoaded && (
|
|
|
|
|
|
|
+ <MsgDialog onVisible={showMsg} title={showMsg} handleOk={() => { setShowMsg(false) }} />
|
|
|
|
|
+ {confirmDialog}
|
|
|
|
|
+ {searchOpen ? (
|
|
|
|
|
+ <ConvoSearchResults
|
|
|
|
|
+ lotId={selectedConvo?.id2 ?? ''}
|
|
|
|
|
+ term={convoSearch.term}
|
|
|
|
|
+ headerInset={isWeb ? 0 : headerHeight}
|
|
|
|
|
+ footerInset={composerInset + bottomBarInset}
|
|
|
|
|
+ />
|
|
|
|
|
+ ) : null}
|
|
|
|
|
+ {jotsLoaded && !searchOpen && (
|
|
|
<JotsPanel
|
|
<JotsPanel
|
|
|
- data={jots.data.jots}
|
|
|
|
|
|
|
+ data={jotsList}
|
|
|
|
|
+ unread={unread}
|
|
|
|
|
+ onSavedChange={handleSavedChange}
|
|
|
refListJots={refListJots}
|
|
refListJots={refListJots}
|
|
|
startReached={handleStartReached}
|
|
startReached={handleStartReached}
|
|
|
handleReply={handleReply}
|
|
handleReply={handleReply}
|
|
@@ -623,9 +824,10 @@ export default function Messenger({ defaultConvoId, selectedMenu, convos, fetchC
|
|
|
// Web: from the window bottom, under the tab bar (footer z-30),
|
|
// Web: from the window bottom, under the tab bar (footer z-30),
|
|
|
// so the wash runs from the bar up through the composer.
|
|
// so the wash runs from the bar up through the composer.
|
|
|
// iOS (ownsBottomBlur) the same, padded up to its resting spot.
|
|
// iOS (ownsBottomBlur) the same, padded up to its resting spot.
|
|
|
- style={isWeb
|
|
|
|
|
|
|
+ // 'fixed' is a web-only position, outside React Native's types.
|
|
|
|
|
+ style={(isWeb
|
|
|
? { position: 'fixed', left: 0, right: 0, bottom: 0, zIndex: 20 }
|
|
? { position: 'fixed', left: 0, right: 0, bottom: 0, zIndex: 20 }
|
|
|
- : { position: 'absolute', left: 0, right: 0, bottom: ownsBottomBlur ? 0 : tabBarOverlayInset, zIndex: 10 }}
|
|
|
|
|
|
|
+ : { position: 'absolute', left: 0, right: 0, bottom: ownsBottomBlur ? 0 : tabBarOverlayInset, zIndex: 10 }) as unknown as ViewStyle}
|
|
|
offset={{ closed: 0, opened: stickyOpened }}
|
|
offset={{ closed: 0, opened: stickyOpened }}
|
|
|
>
|
|
>
|
|
|
<EdgeBlurView
|
|
<EdgeBlurView
|
|
@@ -677,12 +879,23 @@ export default function Messenger({ defaultConvoId, selectedMenu, convos, fetchC
|
|
|
}
|
|
}
|
|
|
center={
|
|
center={
|
|
|
<View className='relative h-full min-h-0 overflow-hidden border-border/60 lg:border-l '>
|
|
<View className='relative h-full min-h-0 overflow-hidden border-border/60 lg:border-l '>
|
|
|
- <Msg onVisible={showMsg} title={showMsg} handleOk={() => { setShowMsg(false) }} />
|
|
|
|
|
|
|
+ <MsgDialog onVisible={showMsg} title={showMsg} handleOk={() => { setShowMsg(false) }} />
|
|
|
|
|
+ {confirmDialog}
|
|
|
{selectedConvo ? (
|
|
{selectedConvo ? (
|
|
|
<>
|
|
<>
|
|
|
- {jotsLoaded ? (
|
|
|
|
|
|
|
+ {searchOpen ? (
|
|
|
|
|
+ <ConvoSearchResults
|
|
|
|
|
+ lotId={selectedConvo.id2}
|
|
|
|
|
+ term={convoSearch.term}
|
|
|
|
|
+ headerInset={PANEL_HEADER_HEIGHT}
|
|
|
|
|
+ footerInset={formHeight + errorOffset}
|
|
|
|
|
+ />
|
|
|
|
|
+ ) : null}
|
|
|
|
|
+ {jotsLoaded && !searchOpen ? (
|
|
|
<JotsPanel
|
|
<JotsPanel
|
|
|
- data={jots.data.jots}
|
|
|
|
|
|
|
+ data={jotsList}
|
|
|
|
|
+ unread={unread}
|
|
|
|
|
+ onSavedChange={handleSavedChange}
|
|
|
listHeight={desktopJotsHeight}
|
|
listHeight={desktopJotsHeight}
|
|
|
refListJots={refListJots}
|
|
refListJots={refListJots}
|
|
|
startReached={handleStartReached}
|
|
startReached={handleStartReached}
|
|
@@ -694,7 +907,7 @@ export default function Messenger({ defaultConvoId, selectedMenu, convos, fetchC
|
|
|
) : null}
|
|
) : null}
|
|
|
<EdgeBlurView edge="top" config={edgeBlurConfig('footer')} className={CHAT_OVERLAY_HEADER} washClassName={CHAT_OVERLAY_HEADER_FADE} pointerEvents="box-none">
|
|
<EdgeBlurView edge="top" config={edgeBlurConfig('footer')} className={CHAT_OVERLAY_HEADER} washClassName={CHAT_OVERLAY_HEADER_FADE} pointerEvents="box-none">
|
|
|
<View pointerEvents="auto">
|
|
<View pointerEvents="auto">
|
|
|
- <PanelHeader title={selectedConvo.title} actions={<ConvoActionsMenu onAction={handleConvoAction} style="glass" />} />
|
|
|
|
|
|
|
+ <PanelHeader title={selectedConvo.title || ''} search={searchOpen ? <ConvoSearchField onSearch={handleSearchTerm} /> : null} actions={<Row className="items-center gap-3"><ConvoSearchButton open={searchOpen} onPress={toggleSearch} style="glass" /><ConvoActionsMenu key={selectedConvo.id} menu={selectedConvo.menu} onAction={handleConvoAction} style="glass" /></Row>} />
|
|
|
</View>
|
|
</View>
|
|
|
</EdgeBlurView>
|
|
</EdgeBlurView>
|
|
|
{listError ? (
|
|
{listError ? (
|