mirror of
https://github.com/duckietm/Nitro-V3.git
synced 2026-06-19 15:06:20 +00:00
Merge branch 'duckietm:main' into item-descriptions
This commit is contained in:
@@ -0,0 +1,26 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { MessengerFriend } from './MessengerFriend';
|
||||
import { MessengerThread } from './MessengerThread';
|
||||
import { MessengerThreadChat } from './MessengerThreadChat';
|
||||
|
||||
const makeThread = (participantId: number): MessengerThread =>
|
||||
{
|
||||
const friend = new MessengerFriend();
|
||||
friend.id = participantId;
|
||||
return new MessengerThread(friend);
|
||||
};
|
||||
|
||||
describe('MessengerThread.setMessagesReadFromUser', () =>
|
||||
{
|
||||
it('marks only the given user\'s messages as READ', () =>
|
||||
{
|
||||
const thread = makeThread(7);
|
||||
const mine = thread.addMessage(100, 'a', 0, null, MessengerThreadChat.CHAT);
|
||||
const theirs = thread.addMessage(7, 'b', 0, null, MessengerThreadChat.CHAT);
|
||||
|
||||
thread.setMessagesReadFromUser(100);
|
||||
|
||||
expect(mine.status).toBe(MessengerThreadChat.READ);
|
||||
expect(theirs.status).toBe(MessengerThreadChat.SENT);
|
||||
});
|
||||
});
|
||||
@@ -99,6 +99,16 @@ export class MessengerThread
|
||||
this._unreadCount = 0;
|
||||
}
|
||||
|
||||
public setMessagesReadFromUser(userId: number): void
|
||||
{
|
||||
for(const group of this._groups)
|
||||
{
|
||||
if(group.userId !== userId) continue;
|
||||
|
||||
for(const chat of group.chats) chat.setStatus(MessengerThreadChat.READ);
|
||||
}
|
||||
}
|
||||
|
||||
public get threadId(): number
|
||||
{
|
||||
return this._threadId;
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { MessengerThreadChat } from './MessengerThreadChat';
|
||||
|
||||
describe('MessengerThreadChat.offlineDelivered', () =>
|
||||
{
|
||||
it('is true for a CHAT message with extraData "offline"', () =>
|
||||
{
|
||||
const chat = new MessengerThreadChat(5, 'hello', 60, 'offline', MessengerThreadChat.CHAT);
|
||||
expect(chat.offlineDelivered).toBe(true);
|
||||
});
|
||||
|
||||
it('is false for a normal CHAT message with no extraData', () =>
|
||||
{
|
||||
const chat = new MessengerThreadChat(5, 'hello', 0, null, MessengerThreadChat.CHAT);
|
||||
expect(chat.offlineDelivered).toBe(false);
|
||||
});
|
||||
|
||||
it('is false when extraData is some other value (e.g. group chat data)', () =>
|
||||
{
|
||||
const chat = new MessengerThreadChat(5, 'hi', 0, 'Bob/figurestr/5', MessengerThreadChat.CHAT);
|
||||
expect(chat.offlineDelivered).toBe(false);
|
||||
});
|
||||
|
||||
it('is false for a non-CHAT type even if extraData is "offline"', () =>
|
||||
{
|
||||
const chat = new MessengerThreadChat(5, 'hi', 0, 'offline', MessengerThreadChat.ROOM_INVITE);
|
||||
expect(chat.offlineDelivered).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('MessengerThreadChat status', () =>
|
||||
{
|
||||
it('defaults to SENT', () =>
|
||||
{
|
||||
const chat = new MessengerThreadChat(5, 'hi', 0, null, MessengerThreadChat.CHAT);
|
||||
expect(chat.status).toBe(MessengerThreadChat.SENT);
|
||||
});
|
||||
|
||||
it('can be set to READ', () =>
|
||||
{
|
||||
const chat = new MessengerThreadChat(5, 'hi', 0, null, MessengerThreadChat.CHAT);
|
||||
chat.setStatus(MessengerThreadChat.READ);
|
||||
expect(chat.status).toBe(MessengerThreadChat.READ);
|
||||
});
|
||||
});
|
||||
@@ -4,10 +4,13 @@ export class MessengerThreadChat
|
||||
public static ROOM_INVITE: number = 1;
|
||||
public static STATUS_NOTIFICATION: number = 2;
|
||||
public static SECURITY_NOTIFICATION: number = 3;
|
||||
public static SENT: number = 0;
|
||||
public static READ: number = 1;
|
||||
private static CHAT_ID: number = 0;
|
||||
|
||||
private _id: number;
|
||||
private _type: number;
|
||||
private _status: number = MessengerThreadChat.SENT;
|
||||
private _senderId: number;
|
||||
private _message: string;
|
||||
private _secondsSinceSent: number;
|
||||
@@ -74,6 +77,21 @@ export class MessengerThreadChat
|
||||
return this._extraData;
|
||||
}
|
||||
|
||||
public get offlineDelivered(): boolean
|
||||
{
|
||||
return (this._type === MessengerThreadChat.CHAT) && (this._extraData === 'offline');
|
||||
}
|
||||
|
||||
public get status(): number
|
||||
{
|
||||
return this._status;
|
||||
}
|
||||
|
||||
public setStatus(status: number): void
|
||||
{
|
||||
this._status = status;
|
||||
}
|
||||
|
||||
public get date(): Date
|
||||
{
|
||||
return this._date;
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { MessengerFriend } from './MessengerFriend';
|
||||
import { countFriendsByCategory, filterFriendsByCategory } from './friendCategory.helpers';
|
||||
|
||||
const makeFriend = (id: number, categoryId: number): MessengerFriend =>
|
||||
{
|
||||
const friend = new MessengerFriend();
|
||||
friend.id = id;
|
||||
friend.categoryId = categoryId;
|
||||
return friend;
|
||||
};
|
||||
|
||||
describe('filterFriendsByCategory', () =>
|
||||
{
|
||||
const friends = [ makeFriend(1, 0), makeFriend(2, 5), makeFriend(3, 5), makeFriend(4, 8) ];
|
||||
|
||||
it('returns all friends when categoryId is 0 (All)', () =>
|
||||
{
|
||||
expect(filterFriendsByCategory(friends, 0)).toHaveLength(4);
|
||||
});
|
||||
|
||||
it('returns only the friends in the given category', () =>
|
||||
{
|
||||
expect(filterFriendsByCategory(friends, 5).map(f => f.id)).toEqual([ 2, 3 ]);
|
||||
});
|
||||
|
||||
it('returns an empty array for a category with no members', () =>
|
||||
{
|
||||
expect(filterFriendsByCategory(friends, 99)).toEqual([]);
|
||||
});
|
||||
|
||||
it('is null-safe', () =>
|
||||
{
|
||||
expect(filterFriendsByCategory(null, 5)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('countFriendsByCategory', () =>
|
||||
{
|
||||
const friends = [ makeFriend(1, 0), makeFriend(2, 5), makeFriend(3, 5) ];
|
||||
|
||||
it('counts members per category id', () =>
|
||||
{
|
||||
const counts = countFriendsByCategory(friends);
|
||||
expect(counts.get(0)).toBe(1);
|
||||
expect(counts.get(5)).toBe(2);
|
||||
});
|
||||
|
||||
it('is null-safe', () =>
|
||||
{
|
||||
expect(countFriendsByCategory(null).size).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
import { MessengerFriend } from './MessengerFriend';
|
||||
|
||||
/**
|
||||
* Filter a friend list to a single category. categoryId 0 means
|
||||
* "All" (no filtering) and returns the list unchanged.
|
||||
*/
|
||||
export const filterFriendsByCategory = (friends: MessengerFriend[], categoryId: number): MessengerFriend[] =>
|
||||
{
|
||||
if(!friends) return [];
|
||||
|
||||
if(!categoryId) return friends;
|
||||
|
||||
return friends.filter(friend => (friend.categoryId === categoryId));
|
||||
};
|
||||
|
||||
/**
|
||||
* Count how many friends belong to each category id. Used to render
|
||||
* member counts on the group chips.
|
||||
*/
|
||||
export const countFriendsByCategory = (friends: MessengerFriend[]): Map<number, number> =>
|
||||
{
|
||||
const counts = new Map<number, number>();
|
||||
|
||||
if(!friends) return counts;
|
||||
|
||||
for(const friend of friends)
|
||||
{
|
||||
counts.set(friend.categoryId, (counts.get(friend.categoryId) ?? 0) + 1);
|
||||
}
|
||||
|
||||
return counts;
|
||||
};
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from './friendCategory.helpers';
|
||||
export * from './GetGroupChatData';
|
||||
export * from './IGroupChatData';
|
||||
export * from './MessengerFriend';
|
||||
|
||||
@@ -5,6 +5,7 @@ export class RoomWidgetUpdateChatInputContentEvent extends RoomWidgetUpdateEvent
|
||||
public static CHAT_INPUT_CONTENT: string = 'RWUCICE_CHAT_INPUT_CONTENT';
|
||||
public static WHISPER: string = 'whisper';
|
||||
public static SHOUT: string = 'shout';
|
||||
public static TEXT: string = 'text';
|
||||
|
||||
private _chatMode: string = '';
|
||||
private _userName: string = '';
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
import { FC, MouseEvent, useEffect, useState } from 'react';
|
||||
import { FriendCategoryData } from '@nitrots/nitro-renderer';
|
||||
import { LocalizeText } from '../../../../api';
|
||||
import { Button, Column, Flex, NitroCardContentView, NitroCardHeaderView, NitroCardView } from '../../../../common';
|
||||
import { useFriendsActions } from '../../../../hooks';
|
||||
|
||||
interface FriendsCategoryManagerViewProps
|
||||
{
|
||||
categories: FriendCategoryData[];
|
||||
onCloseClick: (event: MouseEvent) => void;
|
||||
}
|
||||
|
||||
export const FriendsCategoryManagerView: FC<FriendsCategoryManagerViewProps> = props =>
|
||||
{
|
||||
const { categories = [], onCloseClick = null } = props;
|
||||
const { addCategory, renameCategory, removeCategory } = useFriendsActions();
|
||||
const [ newName, setNewName ] = useState<string>('');
|
||||
const [ editingId, setEditingId ] = useState<number>(0);
|
||||
const [ editingName, setEditingName ] = useState<string>('');
|
||||
|
||||
useEffect(() =>
|
||||
{
|
||||
if(editingId && !categories.some(category => (category.id === editingId)))
|
||||
{
|
||||
setEditingId(0);
|
||||
setEditingName('');
|
||||
}
|
||||
}, [ categories, editingId ]);
|
||||
|
||||
const submitAdd = () =>
|
||||
{
|
||||
const trimmed = newName.trim();
|
||||
if(!trimmed.length) return;
|
||||
addCategory(trimmed);
|
||||
setNewName('');
|
||||
};
|
||||
|
||||
const submitRename = () =>
|
||||
{
|
||||
const trimmed = editingName.trim();
|
||||
if(editingId && trimmed.length) renameCategory(editingId, trimmed);
|
||||
setEditingId(0);
|
||||
setEditingName('');
|
||||
};
|
||||
|
||||
return (
|
||||
<NitroCardView className="nitro-friends-category-manager" theme="primary-slim" uniqueKey="nitro-friends-category-manager" isResizable={ false } style={ { width: 270, minWidth: 270 } }>
|
||||
<NitroCardHeaderView headerText={ LocalizeText('friendlist.friends') } onCloseClick={ onCloseClick } />
|
||||
<NitroCardContentView className="text-black" gap={ 1 }>
|
||||
<Flex gap={ 1 } alignItems="center">
|
||||
<input
|
||||
className="form-control form-control-sm w-full"
|
||||
maxLength={ 25 }
|
||||
type="text"
|
||||
value={ newName }
|
||||
onChange={ event => setNewName(event.target.value) }
|
||||
onKeyDown={ event => (event.key === 'Enter') && submitAdd() } />
|
||||
<Button disabled={ !newName.trim().length || (categories.length >= 20) } onClick={ submitAdd }>
|
||||
{ LocalizeText('catalog.admin.create') }
|
||||
</Button>
|
||||
</Flex>
|
||||
<Column gap={ 1 }>
|
||||
{ categories.map(category => (
|
||||
<Flex key={ category.id } alignItems="center" gap={ 1 }>
|
||||
{ (editingId === category.id) ?
|
||||
<>
|
||||
<input
|
||||
autoFocus
|
||||
className="form-control form-control-sm w-full"
|
||||
maxLength={ 25 }
|
||||
type="text"
|
||||
value={ editingName }
|
||||
onChange={ event => setEditingName(event.target.value) }
|
||||
onKeyDown={ event => (event.key === 'Enter') && submitRename() } />
|
||||
<Button onClick={ submitRename }>
|
||||
{ LocalizeText('catalog.admin.save') }
|
||||
</Button>
|
||||
</>
|
||||
:
|
||||
<>
|
||||
<span className="grow text-sm">{ category.name }</span>
|
||||
<span
|
||||
className="cursor-pointer text-base leading-none select-none"
|
||||
title={ LocalizeText('generic.edit') }
|
||||
onClick={ () => { setEditingId(category.id); setEditingName(category.name); } }>
|
||||
{ '✎' }
|
||||
</span>
|
||||
<span
|
||||
className="cursor-pointer text-base leading-none select-none"
|
||||
title={ LocalizeText('generic.delete') }
|
||||
onClick={ () => removeCategory(category.id) }>
|
||||
{ '✕' }
|
||||
</span>
|
||||
</> }
|
||||
</Flex>
|
||||
)) }
|
||||
{ !categories.length &&
|
||||
<span className="text-muted text-center py-2 text-sm">
|
||||
{ LocalizeText('friendlist.search.nofriendsfound') }
|
||||
</span> }
|
||||
</Column>
|
||||
</NitroCardContentView>
|
||||
</NitroCardView>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,38 @@
|
||||
import { FriendCategoryData } from '@nitrots/nitro-renderer';
|
||||
import { FC } from 'react';
|
||||
import { LocalizeText, MessengerFriend, countFriendsByCategory } from '../../../../api';
|
||||
import { Flex } from '../../../../common';
|
||||
|
||||
interface FriendsListGroupChipsViewProps
|
||||
{
|
||||
categories: FriendCategoryData[];
|
||||
friends: MessengerFriend[];
|
||||
selectedCategoryId: number;
|
||||
setSelectedCategoryId: (id: number) => void;
|
||||
onManageClick: () => void;
|
||||
}
|
||||
|
||||
export const FriendsListGroupChipsView: FC<FriendsListGroupChipsViewProps> = props =>
|
||||
{
|
||||
const { categories = [], friends = [], selectedCategoryId = 0, setSelectedCategoryId = null, onManageClick = null } = props;
|
||||
|
||||
const counts = countFriendsByCategory(friends);
|
||||
|
||||
return (
|
||||
<Flex alignItems="center" className="friends-group-chips px-2 py-1" gap={ 1 }>
|
||||
<Flex alignItems="center" className="friends-group-chips-scroll" gap={ 1 }>
|
||||
<div className={ `friends-group-chip${ (selectedCategoryId === 0) ? ' active' : '' }` } onClick={ () => setSelectedCategoryId(0) }>
|
||||
{ LocalizeText('friendlist.friends') } ({ friends.length })
|
||||
</div>
|
||||
{ categories.map(category => (
|
||||
<div key={ category.id } className={ `friends-group-chip${ (selectedCategoryId === category.id) ? ' active' : '' }` } onClick={ () => setSelectedCategoryId(category.id) }>
|
||||
{ category.name } ({ counts.get(category.id) ?? 0 })
|
||||
</div>
|
||||
)) }
|
||||
</Flex>
|
||||
<div className="friends-group-chip friends-group-chip-manage ms-auto" title={ LocalizeText('friendlist.friends') } onClick={ onManageClick }>
|
||||
{ '⚙' }
|
||||
</div>
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
@@ -1,11 +1,13 @@
|
||||
import { AddLinkEventTracker, ILinkEventTracker, RemoveFriendComposer, RemoveLinkEventTracker, SendRoomInviteComposer } from '@nitrots/nitro-renderer';
|
||||
import { FC, useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { LocalizeText, MessengerFriend, SendMessageComposer } from '../../../../api';
|
||||
import { LocalizeText, MessengerFriend, SendMessageComposer, filterFriendsByCategory } from '../../../../api';
|
||||
import { Button, Flex, NitroCardAccordionSetView, NitroCardAccordionView, NitroCardContentView, NitroCardHeaderView, NitroCardView } from '../../../../common';
|
||||
import { useFriends } from '../../../../hooks';
|
||||
import { FriendsCategoryManagerView } from './FriendsCategoryManagerView';
|
||||
import { FriendsRemoveConfirmationView } from './FriendsListRemoveConfirmationView';
|
||||
import { FriendsRoomInviteView } from './FriendsListRoomInviteView';
|
||||
import { FriendsSearchView } from './FriendsListSearchView';
|
||||
import { FriendsListGroupChipsView } from './FriendsListGroupChipsView';
|
||||
import { FriendsListGroupView } from './friends-list-group/FriendsListGroupView';
|
||||
import { FriendsListRequestView } from './friends-list-request/FriendsListRequestView';
|
||||
|
||||
@@ -15,7 +17,13 @@ export const FriendsListView: FC<{}> = props =>
|
||||
const [ selectedFriendsIds, setSelectedFriendsIds ] = useState<number[]>([]);
|
||||
const [ showRoomInvite, setShowRoomInvite ] = useState<boolean>(false);
|
||||
const [ showRemoveFriendsConfirmation, setShowRemoveFriendsConfirmation ] = useState<boolean>(false);
|
||||
const { onlineFriends = [], offlineFriends = [], requests = [], requestFriend = null } = useFriends();
|
||||
const [ selectedCategoryId, setSelectedCategoryId ] = useState<number>(0);
|
||||
const [ showCategoryManager, setShowCategoryManager ] = useState<boolean>(false);
|
||||
const { friends = [], onlineFriends = [], offlineFriends = [], requests = [], requestFriend = null, settings = null } = useFriends();
|
||||
|
||||
const categories = settings?.categories ?? [];
|
||||
const filteredOnlineFriends = filterFriendsByCategory(onlineFriends, selectedCategoryId);
|
||||
const filteredOfflineFriends = filterFriendsByCategory(offlineFriends, selectedCategoryId);
|
||||
|
||||
const removeFriendsText = useMemo(() =>
|
||||
{
|
||||
@@ -145,32 +153,38 @@ export const FriendsListView: FC<{}> = props =>
|
||||
<NitroCardView className="nitro-friends" theme="primary-slim" uniqueKey="nitro-friends">
|
||||
<NitroCardHeaderView headerText={ LocalizeText('friendlist.friends') } onCloseClick={ event => setIsVisible(false) } />
|
||||
<NitroCardContentView className="text-black p-0" gap={ 1 } overflow="hidden">
|
||||
<FriendsListGroupChipsView
|
||||
categories={ categories }
|
||||
friends={ friends }
|
||||
selectedCategoryId={ selectedCategoryId }
|
||||
setSelectedCategoryId={ setSelectedCategoryId }
|
||||
onManageClick={ () => setShowCategoryManager(true) } />
|
||||
<NitroCardAccordionView fullHeight overflow="hidden">
|
||||
<NitroCardAccordionSetView className="friends-list-section" headerText={ LocalizeText('friendlist.friends') + ` (${ onlineFriends.length })` } isExpanded={ true }>
|
||||
<NitroCardAccordionSetView className="friends-list-section" headerText={ LocalizeText('friendlist.friends') + ` (${ filteredOnlineFriends.length })` } isExpanded={ true }>
|
||||
<Flex className="friends-list-toolbar px-2 py-1" justifyContent="end">
|
||||
<span className="friends-list-toolbar-link" onClick={ event =>
|
||||
{
|
||||
event.stopPropagation(); toggleSelectFriends(onlineFriends.map(friend => friend.id));
|
||||
event.stopPropagation(); toggleSelectFriends(filteredOnlineFriends.map(friend => friend.id));
|
||||
} }>
|
||||
{ onlineFriends.length && onlineFriends.every(friend => (selectedFriendsIds.indexOf(friend.id) >= 0))
|
||||
{ filteredOnlineFriends.length && filteredOnlineFriends.every(friend => (selectedFriendsIds.indexOf(friend.id) >= 0))
|
||||
? LocalizeText('friendlist.unselect_all')
|
||||
: LocalizeText('friendlist.select_all') }
|
||||
</span>
|
||||
</Flex>
|
||||
<FriendsListGroupView list={ onlineFriends } selectedFriendsIds={ selectedFriendsIds } selectFriend={ selectFriend } />
|
||||
<FriendsListGroupView list={ filteredOnlineFriends } selectedFriendsIds={ selectedFriendsIds } selectFriend={ selectFriend } />
|
||||
</NitroCardAccordionSetView>
|
||||
<NitroCardAccordionSetView headerText={ LocalizeText('friendlist.friends.offlinecaption') + ` (${ offlineFriends.length })` }>
|
||||
<NitroCardAccordionSetView headerText={ LocalizeText('friendlist.friends.offlinecaption') + ` (${ filteredOfflineFriends.length })` }>
|
||||
<Flex className="friends-list-toolbar px-2 py-1" justifyContent="end">
|
||||
<span className="friends-list-toolbar-link" onClick={ event =>
|
||||
{
|
||||
event.stopPropagation(); toggleSelectFriends(offlineFriends.map(friend => friend.id));
|
||||
event.stopPropagation(); toggleSelectFriends(filteredOfflineFriends.map(friend => friend.id));
|
||||
} }>
|
||||
{ offlineFriends.length && offlineFriends.every(friend => (selectedFriendsIds.indexOf(friend.id) >= 0))
|
||||
{ filteredOfflineFriends.length && filteredOfflineFriends.every(friend => (selectedFriendsIds.indexOf(friend.id) >= 0))
|
||||
? LocalizeText('friendlist.unselect_all')
|
||||
: LocalizeText('friendlist.select_all') }
|
||||
</span>
|
||||
</Flex>
|
||||
<FriendsListGroupView list={ offlineFriends } selectedFriendsIds={ selectedFriendsIds } selectFriend={ selectFriend } />
|
||||
<FriendsListGroupView list={ filteredOfflineFriends } selectedFriendsIds={ selectedFriendsIds } selectFriend={ selectFriend } />
|
||||
</NitroCardAccordionSetView>
|
||||
<FriendsListRequestView headerText={ LocalizeText('friendlist.tab.friendrequests') + ` (${ requests.length })` } isExpanded={ true } />
|
||||
<FriendsSearchView headerText={ LocalizeText('people.search.title') } />
|
||||
@@ -186,6 +200,8 @@ export const FriendsListView: FC<{}> = props =>
|
||||
<FriendsRoomInviteView selectedFriendsIds={ selectedFriendsIds } sendRoomInvite={ sendRoomInvite } onCloseClick={ () => setShowRoomInvite(false) } /> }
|
||||
{ showRemoveFriendsConfirmation &&
|
||||
<FriendsRemoveConfirmationView removeFriendsText={ removeFriendsText } removeSelectedFriends={ removeSelectedFriends } selectedFriendsIds={ selectedFriendsIds } onCloseClick={ () => setShowRemoveFriendsConfirmation(false) } /> }
|
||||
{ showCategoryManager &&
|
||||
<FriendsCategoryManagerView categories={ categories } onCloseClick={ () => setShowCategoryManager(false) } /> }
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
+18
-1
@@ -9,7 +9,9 @@ export const FriendsListGroupItemView: FC<{ friend: MessengerFriend, selected: b
|
||||
{
|
||||
const { friend = null, selected = false, selectFriend = null } = props;
|
||||
const [ isRelationshipOpen, setIsRelationshipOpen ] = useState<boolean>(false);
|
||||
const { followFriend = null, updateRelationship = null } = useFriends();
|
||||
const { followFriend = null, updateRelationship = null, moveFriendToCategory = null, settings = null } = useFriends();
|
||||
const [ isGroupMenuOpen, setIsGroupMenuOpen ] = useState<boolean>(false);
|
||||
const categories = settings?.categories ?? [];
|
||||
|
||||
const clickFollowFriend = (event: MouseEvent<HTMLDivElement>) =>
|
||||
{
|
||||
@@ -74,6 +76,21 @@ export const FriendsListGroupItemView: FC<{ friend: MessengerFriend, selected: b
|
||||
<div className="nitro-friends-spritesheet icon-follow cursor-pointer" title={ LocalizeText('friendlist.tip.follow') } onClick={ clickFollowFriend } /> }
|
||||
{ friend.online &&
|
||||
<div className="nitro-friends-spritesheet icon-chat cursor-pointer" title={ LocalizeText('friendlist.tip.im') } onClick={ openMessengerChat } /> }
|
||||
{ (friend.id > 0) && (categories.length > 0) &&
|
||||
<div className="friends-list-group-assign position-relative">
|
||||
<div className="friends-list-group-toggle cursor-pointer" title={ LocalizeText('friendlist.friends') } onClick={ event => { event.stopPropagation(); setIsGroupMenuOpen(prev => !prev); } }>{ '📁' }</div>
|
||||
{ isGroupMenuOpen &&
|
||||
<div className="friends-list-group-menu">
|
||||
<div className={ `friends-list-group-menu-item${ (friend.categoryId === 0) ? ' active' : '' }` } onClick={ event => { event.stopPropagation(); moveFriendToCategory(friend.id, 0); setIsGroupMenuOpen(false); } }>
|
||||
{ LocalizeText('friendlist.friends') }
|
||||
</div>
|
||||
{ categories.map(category => (
|
||||
<div key={ category.id } className={ `friends-list-group-menu-item${ (friend.categoryId === category.id) ? ' active' : '' }` } onClick={ event => { event.stopPropagation(); moveFriendToCategory(friend.id, category.id); setIsGroupMenuOpen(false); } }>
|
||||
{ category.name }
|
||||
</div>
|
||||
)) }
|
||||
</div> }
|
||||
</div> }
|
||||
{ (friend.id > 0) &&
|
||||
<div className={ `nitro-friends-spritesheet icon-${ getCurrentRelationshipName() } cursor-pointer` } title={ LocalizeText('infostand.link.relationship') } onClick={ openRelationship } /> }
|
||||
</> }
|
||||
|
||||
@@ -12,11 +12,53 @@ export const FriendsMessengerView: FC<{}> = props =>
|
||||
const [ isVisible, setIsVisible ] = useState(false);
|
||||
const [ lastThreadId, setLastThreadId ] = useState(-1);
|
||||
const [ messageText, setMessageText ] = useState('');
|
||||
const { visibleThreads = [], activeThread = null, getMessageThread = null, sendMessage = null, setActiveThreadId = null, closeThread = null } = useMessenger();
|
||||
const { visibleThreads = [], activeThread = null, getMessageThread = null, sendMessage = null, setActiveThreadId = null, closeThread = null, typingUserIds = [], sendTypingStatus = null } = useMessenger();
|
||||
const { getFriend = null } = useFriends();
|
||||
const { report = null } = useHelp();
|
||||
const { settings, translateOutgoing } = useTranslation();
|
||||
const messagesBox = useRef<HTMLDivElement>(null);
|
||||
const isTypingRef = useRef<boolean>(false);
|
||||
const typingTimeoutRef = useRef<ReturnType<typeof setTimeout>>(null);
|
||||
|
||||
const stopTyping = () =>
|
||||
{
|
||||
if(typingTimeoutRef.current)
|
||||
{
|
||||
clearTimeout(typingTimeoutRef.current);
|
||||
typingTimeoutRef.current = null;
|
||||
}
|
||||
|
||||
if(isTypingRef.current && activeThread && activeThread.participant && (activeThread.participant.id > 0))
|
||||
{
|
||||
sendTypingStatus(activeThread.participant.id, false);
|
||||
}
|
||||
|
||||
isTypingRef.current = false;
|
||||
};
|
||||
|
||||
const handleInputChange = (value: string) =>
|
||||
{
|
||||
setMessageText(value);
|
||||
|
||||
const peerId = (activeThread && activeThread.participant) ? activeThread.participant.id : 0;
|
||||
|
||||
if(peerId <= 0) return;
|
||||
|
||||
if(!value.length)
|
||||
{
|
||||
stopTyping();
|
||||
return;
|
||||
}
|
||||
|
||||
if(!isTypingRef.current)
|
||||
{
|
||||
sendTypingStatus(peerId, true);
|
||||
isTypingRef.current = true;
|
||||
}
|
||||
|
||||
if(typingTimeoutRef.current) clearTimeout(typingTimeoutRef.current);
|
||||
typingTimeoutRef.current = setTimeout(() => stopTyping(), 4000);
|
||||
};
|
||||
|
||||
const followFriend = () => (activeThread && activeThread.participant && SendMessageComposer(new FollowFriendMessageComposer(activeThread.participant.id)));
|
||||
const openProfile = () => (activeThread && activeThread.participant && GetUserProfile(activeThread.participant.id));
|
||||
@@ -25,6 +67,8 @@ export const FriendsMessengerView: FC<{}> = props =>
|
||||
{
|
||||
if(!activeThread || !messageText.length) return;
|
||||
|
||||
stopTyping();
|
||||
|
||||
const trimmedText = messageText.trimStart();
|
||||
const shouldTranslateOutgoing = settings.enabled && !!trimmedText.length && (trimmedText.charAt(0) !== ':');
|
||||
|
||||
@@ -102,6 +146,14 @@ export const FriendsMessengerView: FC<{}> = props =>
|
||||
messagesBox.current.scrollTop = messagesBox.current.scrollHeight;
|
||||
}, [ isVisible, activeThread ]);
|
||||
|
||||
useEffect(() =>
|
||||
{
|
||||
return () =>
|
||||
{
|
||||
stopTyping();
|
||||
};
|
||||
}, [ activeThread ]);
|
||||
|
||||
useEffect(() =>
|
||||
{
|
||||
if(isVisible && !activeThread)
|
||||
@@ -184,8 +236,13 @@ export const FriendsMessengerView: FC<{}> = props =>
|
||||
<FriendsMessengerThreadView thread={ activeThread } />
|
||||
</div>
|
||||
|
||||
{ activeThread.participant && (activeThread.participant.id > 0) && (typingUserIds.indexOf(activeThread.participant.id) >= 0) &&
|
||||
<div className="messenger-typing-indicator">
|
||||
{ LocalizeText('messenger.typing', [ 'FRIEND_NAME' ], [ activeThread.participant.name ]) }
|
||||
</div> }
|
||||
|
||||
<div className="messenger-input-row">
|
||||
<input maxLength={ 255 } placeholder={ LocalizeText('messenger.window.input.default', [ 'FRIEND_NAME' ], [ activeThread.participant.name ]) } type="text" value={ messageText } onChange={ event => setMessageText(event.target.value) } onKeyDown={ onKeyDown } />
|
||||
<input maxLength={ 255 } placeholder={ LocalizeText('messenger.window.input.default', [ 'FRIEND_NAME' ], [ activeThread.participant.name ]) } type="text" value={ messageText } onChange={ event => handleInputChange(event.target.value) } onKeyDown={ onKeyDown } />
|
||||
<button className="messenger-btn send" onClick={ () => void send() }>
|
||||
{ LocalizeText('widgets.chatinput.say') }
|
||||
</button>
|
||||
|
||||
+11
-1
@@ -68,7 +68,13 @@ export const FriendsMessengerThreadGroup: FC<{ thread: MessengerThread, group: M
|
||||
{
|
||||
if(!chat.showTranslation)
|
||||
{
|
||||
return <Base key={ index } className="text-break">{ chat.message }</Base>;
|
||||
return (
|
||||
<Base key={ index } className="text-break">
|
||||
{ chat.message }
|
||||
{ chat.offlineDelivered &&
|
||||
<span className="messenger-offline-tag">{ LocalizeText('messenger.offline.delivered') }</span> }
|
||||
</Base>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -86,6 +92,10 @@ export const FriendsMessengerThreadGroup: FC<{ thread: MessengerThread, group: M
|
||||
}) }
|
||||
</Base>
|
||||
<Base className="messenger-message-time">{ group.chats[0].date.toLocaleTimeString() }</Base>
|
||||
{ isOwnChat && (group.type === MessengerGroupType.PRIVATE_CHAT) && (group.chats[group.chats.length - 1].type === MessengerThreadChat.CHAT) &&
|
||||
<Base className={ 'messenger-message-status ' + ((group.chats[group.chats.length - 1].status === MessengerThreadChat.READ) ? 'read' : '') }>
|
||||
{ (group.chats[group.chats.length - 1].status === MessengerThreadChat.READ) ? '✓✓' : '✓' }
|
||||
</Base> }
|
||||
</Base>
|
||||
{ isOwnChat &&
|
||||
<Base shrink className="message-avatar">
|
||||
|
||||
@@ -16,7 +16,7 @@ export const FurniEditorView: FC<{}> = () =>
|
||||
|
||||
const {
|
||||
items, total, page, loading, error, clearError,
|
||||
selectedItem, setSelectedItem, furniDataEntry,
|
||||
selectedItem, setSelectedItem, furniDataEntry, furniDataDiagnostic,
|
||||
interactions,
|
||||
searchItems, loadDetail, loadBySpriteId, updateItem, deleteItem, loadInteractions,
|
||||
updateFurnidata, revertFurnidata, importText, importResult
|
||||
@@ -151,6 +151,7 @@ export const FurniEditorView: FC<{}> = () =>
|
||||
<FurniEditorEditView
|
||||
item={ selectedItem }
|
||||
furniDataEntry={ furniDataEntry }
|
||||
furniDataDiagnostic={ furniDataDiagnostic }
|
||||
interactions={ interactions }
|
||||
loading={ loading }
|
||||
onUpdate={ updateItem }
|
||||
|
||||
@@ -7,6 +7,7 @@ interface FurniEditorEditViewProps
|
||||
{
|
||||
item: FurniDetail;
|
||||
furniDataEntry: Record<string, unknown> | null;
|
||||
furniDataDiagnostic: Record<string, unknown> | null;
|
||||
interactions: string[];
|
||||
loading: boolean;
|
||||
onUpdate: (id: number, fields: Record<string, unknown>) => void;
|
||||
@@ -121,7 +122,7 @@ const CopyValue: FC<{ value: string | number }> = ({ value }) =>
|
||||
|
||||
export const FurniEditorEditView: FC<FurniEditorEditViewProps> = props =>
|
||||
{
|
||||
const { item, furniDataEntry, interactions, loading, onUpdate, onDelete, onBack, onUpdateFurnidata, onRevertFurnidata, onImportText, importResult } = props;
|
||||
const { item, furniDataEntry, furniDataDiagnostic, interactions, loading, onUpdate, onDelete, onBack, onUpdateFurnidata, onRevertFurnidata, onImportText, importResult } = props;
|
||||
const saveRef = useRef<() => void>(null);
|
||||
|
||||
const [ form, setForm ] = useState({
|
||||
@@ -252,6 +253,14 @@ export const FurniEditorEditView: FC<FurniEditorEditViewProps> = props =>
|
||||
furniName !== String(furniDataEntry?.name ?? '') || furniDescription !== String(furniDataEntry?.description ?? ''),
|
||||
[ furniName, furniDescription, furniDataEntry ]);
|
||||
|
||||
const furnidataMissReason = useMemo(() =>
|
||||
{
|
||||
const reason = String(furniDataDiagnostic?.reason ?? '');
|
||||
return reason || 'not_found';
|
||||
}, [ furniDataDiagnostic ]);
|
||||
|
||||
const furnidataSourcePath = String(furniDataDiagnostic?.sourcePath ?? '');
|
||||
|
||||
// Apply an "Import from Habbo" result into the editable fields (review then Save).
|
||||
useEffect(() =>
|
||||
{
|
||||
@@ -391,7 +400,7 @@ export const FurniEditorEditView: FC<FurniEditorEditViewProps> = props =>
|
||||
) : (
|
||||
<div className="flex items-start gap-2 text-[11px] text-slate-500 bg-slate-50 border border-slate-200 rounded-lg px-2.5 py-2 leading-snug">
|
||||
<span className="text-[#f59e0b] text-sm leading-none mt-px">⚠</span>
|
||||
<span>This furni has no matching <b>furnidata</b> entry (e.g. a pet or custom item), so its display name can't be edited here. Clients fall back to the DB <b>Public Name</b> below.</span>
|
||||
<span>This furni has no matching <b>furnidata</b> entry ({ furnidataMissReason.replace(/_/g, ' ') }), so its display name can't be edited here. Clients fall back to the DB <b>Public Name</b> below.</span>
|
||||
</div>
|
||||
) }
|
||||
</div>
|
||||
@@ -424,6 +433,20 @@ export const FurniEditorEditView: FC<FurniEditorEditViewProps> = props =>
|
||||
</Section>
|
||||
}
|
||||
|
||||
<Section title="Furnidata Debug" defaultOpen={ false }>
|
||||
<div className="grid grid-cols-2 gap-2 mb-2">
|
||||
<div>
|
||||
<label className={ labelClass }>Resolution</label>
|
||||
<CopyValue value={ furnidataMissReason } />
|
||||
</div>
|
||||
<div>
|
||||
<label className={ labelClass }>Source</label>
|
||||
<CopyValue value={ furnidataSourcePath || 'unresolved' } />
|
||||
</div>
|
||||
</div>
|
||||
<pre className="text-[10px] leading-snug text-slate-600 bg-slate-50 border border-slate-200 rounded-lg p-2 overflow-auto max-h-40 whitespace-pre-wrap break-all font-mono">{ JSON.stringify(furniDataDiagnostic ?? {}, null, 2) }</pre>
|
||||
</Section>
|
||||
|
||||
<Section title="Dimensions">
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<div>
|
||||
|
||||
@@ -236,6 +236,10 @@ export const ChatInputView: FC<{}> = props =>
|
||||
{
|
||||
switch(event.chatMode)
|
||||
{
|
||||
case RoomWidgetUpdateChatInputContentEvent.TEXT:
|
||||
setChatValue(event.userName);
|
||||
inputRef.current?.focus();
|
||||
return;
|
||||
case RoomWidgetUpdateChatInputContentEvent.WHISPER: {
|
||||
setChatValue(`${ chatModeIdWhisper } ${ event.userName } `);
|
||||
return;
|
||||
|
||||
@@ -804,6 +804,127 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* Friend group chip filter row */
|
||||
.friends-group-chips {
|
||||
border-bottom: 1px solid #e6e6e6;
|
||||
}
|
||||
|
||||
.friends-group-chips-scroll {
|
||||
overflow-x: auto;
|
||||
flex-wrap: nowrap;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.friends-group-chips-scroll::-webkit-scrollbar {
|
||||
height: 4px;
|
||||
}
|
||||
|
||||
.friends-group-chips-scroll::-webkit-scrollbar-thumb {
|
||||
background: #c0c0b8;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.friends-group-chip {
|
||||
flex: 0 0 auto;
|
||||
padding: 1px 8px;
|
||||
border: 1px solid #d0d0c8;
|
||||
border-radius: 10px;
|
||||
background: #f3f3ef;
|
||||
font-size: 11px;
|
||||
line-height: 16px;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.friends-group-chip:hover {
|
||||
background: #efefef;
|
||||
}
|
||||
|
||||
.friends-group-chip.active {
|
||||
background: #bfe7f6;
|
||||
border-color: #7fb9d6;
|
||||
}
|
||||
|
||||
.friends-group-chip-manage {
|
||||
flex: 0 0 auto;
|
||||
padding: 1px 6px;
|
||||
}
|
||||
|
||||
/* Per-friend assign-to-group dropdown */
|
||||
.friends-list-group-assign {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.friends-list-group-toggle {
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
line-height: 1;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.friends-list-group-menu {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 100%;
|
||||
z-index: 20;
|
||||
min-width: 120px;
|
||||
max-height: 180px;
|
||||
overflow-y: auto;
|
||||
background: #fff;
|
||||
border: 1px solid #c0c0b8;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.friends-list-group-menu-item {
|
||||
padding: 3px 8px;
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.friends-list-group-menu-item:hover {
|
||||
background: #efefef;
|
||||
}
|
||||
|
||||
.friends-list-group-menu-item.active {
|
||||
background: #bfe7f6;
|
||||
}
|
||||
|
||||
/* Category manager: keep the list scrollable */
|
||||
.nitro-friends-category-manager .friends-category-list {
|
||||
max-height: 220px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.messenger-offline-tag {
|
||||
display: block;
|
||||
margin-top: 2px;
|
||||
font-size: 10px;
|
||||
font-style: italic;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.messenger-message-status {
|
||||
margin-top: 1px;
|
||||
font-size: 10px;
|
||||
line-height: 10px;
|
||||
text-align: right;
|
||||
opacity: 0.6;
|
||||
}
|
||||
.messenger-message-status.read {
|
||||
color: #4fc3f7;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.messenger-typing-indicator {
|
||||
padding: 2px 8px;
|
||||
font-size: 11px;
|
||||
font-style: italic;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.nitro-friends .friends-list-avatar {
|
||||
position: relative !important;
|
||||
width: 32px;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { AcceptFriendMessageComposer, DeclineFriendMessageComposer, FollowFriendFailedEvent, FollowFriendMessageComposer, FriendListFragmentEvent, FriendListUpdateComposer, FriendListUpdateEvent, FriendParser, FriendRequestsEvent, GetFriendRequestsComposer, GetSessionDataManager, MessengerInitComposer, MessengerInitEvent, NewFriendRequestEvent, RequestFriendComposer, SetRelationshipStatusComposer } from '@nitrots/nitro-renderer';
|
||||
import { AcceptFriendMessageComposer, AddFriendCategoryComposer, DeclineFriendMessageComposer, FollowFriendFailedEvent, FollowFriendMessageComposer, FriendListFragmentEvent, FriendListUpdateComposer, FriendListUpdateEvent, FriendParser, FriendRequestsEvent, GetFriendRequestsComposer, GetSessionDataManager, MessengerInitComposer, MessengerInitEvent, MoveFriendToCategoryComposer, NewFriendRequestEvent, RemoveFriendCategoryComposer, RenameFriendCategoryComposer, RequestFriendComposer, SetRelationshipStatusComposer } from '@nitrots/nitro-renderer';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useBetween } from 'use-between';
|
||||
import { CloneObject, LocalizeText, MessengerFriend, MessengerRequest, MessengerSettings, NotificationAlertType, SendMessageComposer } from '../../api';
|
||||
@@ -43,6 +43,38 @@ const useFriendsStore = () =>
|
||||
|
||||
const updateRelationship = (friend: MessengerFriend, type: number) => ((type !== friend.relationshipStatus) && SendMessageComposer(new SetRelationshipStatusComposer(friend.id, type)));
|
||||
|
||||
const addCategory = (name: string) =>
|
||||
{
|
||||
const trimmed = (name ?? '').trim();
|
||||
|
||||
if(!trimmed.length || (trimmed.length > 25)) return;
|
||||
|
||||
SendMessageComposer(new AddFriendCategoryComposer(trimmed));
|
||||
};
|
||||
|
||||
const renameCategory = (categoryId: number, name: string) =>
|
||||
{
|
||||
const trimmed = (name ?? '').trim();
|
||||
|
||||
if(!categoryId || !trimmed.length || (trimmed.length > 25)) return;
|
||||
|
||||
SendMessageComposer(new RenameFriendCategoryComposer(categoryId, trimmed));
|
||||
};
|
||||
|
||||
const removeCategory = (categoryId: number) =>
|
||||
{
|
||||
if(!categoryId) return;
|
||||
|
||||
SendMessageComposer(new RemoveFriendCategoryComposer(categoryId));
|
||||
};
|
||||
|
||||
const moveFriendToCategory = (friendId: number, categoryId: number) =>
|
||||
{
|
||||
if(!friendId) return;
|
||||
|
||||
SendMessageComposer(new MoveFriendToCategoryComposer(friendId, categoryId));
|
||||
};
|
||||
|
||||
const getFriend = (userId: number) =>
|
||||
{
|
||||
for(const friend of friends)
|
||||
@@ -259,7 +291,7 @@ const useFriendsStore = () =>
|
||||
};
|
||||
}, []);
|
||||
|
||||
return { friends, requests, sentRequests, dismissedRequestIds, setDismissedRequestIds, settings, onlineFriends, offlineFriends, getFriend, canRequestFriend, requestFriend, requestResponse, followFriend, updateRelationship };
|
||||
return { friends, requests, sentRequests, dismissedRequestIds, setDismissedRequestIds, settings, onlineFriends, offlineFriends, getFriend, canRequestFriend, requestFriend, requestResponse, followFriend, updateRelationship, addCategory, renameCategory, removeCategory, moveFriendToCategory };
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -312,14 +344,22 @@ export const useFriendsActions = () =>
|
||||
requestFriend,
|
||||
requestResponse,
|
||||
followFriend,
|
||||
updateRelationship
|
||||
updateRelationship,
|
||||
addCategory,
|
||||
renameCategory,
|
||||
removeCategory,
|
||||
moveFriendToCategory
|
||||
} = useBetween(useFriendsStore);
|
||||
|
||||
return {
|
||||
requestFriend,
|
||||
requestResponse,
|
||||
followFriend,
|
||||
updateRelationship
|
||||
updateRelationship,
|
||||
addCategory,
|
||||
renameCategory,
|
||||
removeCategory,
|
||||
moveFriendToCategory
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { GetSessionDataManager, NewConsoleMessageEvent, RoomInviteErrorEvent, RoomInviteEvent, SendMessageComposer as SendMessageComposerPacket } from '@nitrots/nitro-renderer';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { ConsoleReadReceiptEvent, ConsoleTypingComposer, FriendIsTypingEvent, GetSessionDataManager, MarkConsoleReadComposer, NewConsoleMessageEvent, RoomInviteErrorEvent, RoomInviteEvent, SendMessageComposer as SendMessageComposerPacket } from '@nitrots/nitro-renderer';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useBetween } from 'use-between';
|
||||
import { CloneObject, LocalizeText, MessengerIconState, MessengerThread, MessengerThreadChat, NotificationAlertType, PlaySound, SendMessageComposer, SoundNames } from '../../api';
|
||||
import { useMessageEvent } from '../events';
|
||||
@@ -17,6 +17,12 @@ const useMessengerState = () =>
|
||||
const { simpleAlert = null } = useNotification();
|
||||
const { settings, translateIncoming } = useTranslation();
|
||||
|
||||
const [typingUserIds, setTypingUserIds] = useState<number[]>([]);
|
||||
const typingTimersRef = useRef<Map<number, ReturnType<typeof setTimeout>>>(new Map());
|
||||
|
||||
const messageThreadsRef = useRef(messageThreads);
|
||||
messageThreadsRef.current = messageThreads;
|
||||
|
||||
const visibleThreads = useMemo(() => messageThreads.filter(thread => (hiddenThreadIds.indexOf(thread.threadId) === -1)), [messageThreads, hiddenThreadIds]);
|
||||
const activeThread = useMemo(() => ((activeThreadId > 0) && visibleThreads.find(thread => (thread.threadId === activeThreadId) || null)), [activeThreadId, visibleThreads]);
|
||||
|
||||
@@ -148,6 +154,13 @@ const useMessengerState = () =>
|
||||
});
|
||||
};
|
||||
|
||||
const sendTypingStatus = (peerId: number, isTyping: boolean) =>
|
||||
{
|
||||
if (!peerId || (peerId <= 0)) return;
|
||||
|
||||
SendMessageComposer(new ConsoleTypingComposer(peerId, isTyping));
|
||||
};
|
||||
|
||||
useMessageEvent<NewConsoleMessageEvent>(NewConsoleMessageEvent, event =>
|
||||
{
|
||||
const parser = event.getParser();
|
||||
@@ -156,6 +169,7 @@ const useMessengerState = () =>
|
||||
if (!thread) return;
|
||||
|
||||
sendMessage(thread, parser.senderId, parser.messageText, parser.secondsSinceSent, parser.extraData);
|
||||
if ((thread.threadId === activeThreadId) && (parser.senderId > 0)) SendMessageComposer(new MarkConsoleReadComposer(parser.senderId));
|
||||
});
|
||||
|
||||
useMessageEvent<RoomInviteEvent>(RoomInviteEvent, event =>
|
||||
@@ -175,10 +189,65 @@ const useMessengerState = () =>
|
||||
simpleAlert(`Received room invite error: ${ parser.errorCode },recipients: ${ parser.failedRecipients.join(',') }`, NotificationAlertType.DEFAULT, null, null, LocalizeText('friendlist.alert.title'));
|
||||
});
|
||||
|
||||
useMessageEvent<FriendIsTypingEvent>(FriendIsTypingEvent, event =>
|
||||
{
|
||||
const parser = event.getParser();
|
||||
const senderId = parser.senderId;
|
||||
|
||||
if (senderId <= 0) return;
|
||||
|
||||
const timers = typingTimersRef.current;
|
||||
const existing = timers.get(senderId);
|
||||
|
||||
if (existing)
|
||||
{
|
||||
clearTimeout(existing);
|
||||
timers.delete(senderId);
|
||||
}
|
||||
|
||||
if (parser.isTyping)
|
||||
{
|
||||
setTypingUserIds(prev => (prev.indexOf(senderId) >= 0) ? prev : [...prev, senderId]);
|
||||
|
||||
timers.set(senderId, setTimeout(() =>
|
||||
{
|
||||
typingTimersRef.current.delete(senderId);
|
||||
setTypingUserIds(prev => prev.filter(id => (id !== senderId)));
|
||||
}, 6000));
|
||||
}
|
||||
else
|
||||
{
|
||||
setTypingUserIds(prev => prev.filter(id => (id !== senderId)));
|
||||
}
|
||||
});
|
||||
|
||||
useMessageEvent<ConsoleReadReceiptEvent>(ConsoleReadReceiptEvent, event =>
|
||||
{
|
||||
const parser = event.getParser();
|
||||
const ownUserId = GetSessionDataManager().userId;
|
||||
|
||||
setMessageThreads(prevValue =>
|
||||
{
|
||||
const index = prevValue.findIndex(thread => (thread.participant && (thread.participant.id === parser.readerId)));
|
||||
|
||||
if (index === -1) return prevValue;
|
||||
|
||||
const newValue = [...prevValue];
|
||||
|
||||
newValue[index] = CloneObject(newValue[index]);
|
||||
newValue[index].setMessagesReadFromUser(ownUserId);
|
||||
|
||||
return newValue;
|
||||
});
|
||||
});
|
||||
|
||||
useEffect(() =>
|
||||
{
|
||||
if (activeThreadId <= 0) return;
|
||||
|
||||
const activeThreadValue = messageThreadsRef.current.find(thread => (thread.threadId === activeThreadId));
|
||||
const participantId = activeThreadValue?.participant?.id ?? 0;
|
||||
|
||||
setMessageThreads(prevValue =>
|
||||
{
|
||||
const newValue = [...prevValue];
|
||||
@@ -187,12 +256,13 @@ const useMessengerState = () =>
|
||||
if (index >= 0)
|
||||
{
|
||||
newValue[index] = CloneObject(newValue[index]);
|
||||
|
||||
newValue[index].setRead();
|
||||
}
|
||||
|
||||
return newValue;
|
||||
});
|
||||
|
||||
if (participantId > 0) SendMessageComposer(new MarkConsoleReadComposer(participantId));
|
||||
}, [activeThreadId]);
|
||||
|
||||
useEffect(() =>
|
||||
@@ -219,7 +289,7 @@ const useMessengerState = () =>
|
||||
});
|
||||
}, [visibleThreads]);
|
||||
|
||||
return { messageThreads, activeThread, iconState, visibleThreads, getMessageThread, setActiveThreadId, closeThread, sendMessage };
|
||||
return { messageThreads, activeThread, iconState, visibleThreads, getMessageThread, setActiveThreadId, closeThread, sendMessage, typingUserIds, sendTypingStatus };
|
||||
};
|
||||
|
||||
export const useMessenger = () => useBetween(useMessengerState);
|
||||
|
||||
@@ -60,6 +60,7 @@ export const useFurniEditor = () =>
|
||||
const [ catalogItems, setCatalogItems ] = useState<CatalogRef[]>([]);
|
||||
const [ interactions, setInteractions ] = useState<string[]>([]);
|
||||
const [ furniDataEntry, setFurniDataEntry ] = useState<Record<string, unknown> | null>(null);
|
||||
const [ furniDataDiagnostic, setFurniDataDiagnostic ] = useState<Record<string, unknown> | null>(null);
|
||||
const pendingActionRef = useRef<{ action: string; itemId: number } | null>(null);
|
||||
const [ importResult, setImportResult ] = useState<{ found: boolean; name: string; description: string; classname: string; nonce: number } | null>(null);
|
||||
const importNonceRef = useRef(0);
|
||||
@@ -152,6 +153,20 @@ export const useFurniEditor = () =>
|
||||
{}
|
||||
|
||||
setFurniDataEntry(furniData);
|
||||
|
||||
let diagnostic: Record<string, unknown> | null = null;
|
||||
|
||||
try
|
||||
{
|
||||
if(parser.furniDataDiagnosticJson && parser.furniDataDiagnosticJson !== '{}' && parser.furniDataDiagnosticJson !== '')
|
||||
{
|
||||
diagnostic = JSON.parse(parser.furniDataDiagnosticJson);
|
||||
}
|
||||
}
|
||||
catch(e)
|
||||
{}
|
||||
|
||||
setFurniDataDiagnostic(diagnostic);
|
||||
});
|
||||
|
||||
// Handle interaction types list
|
||||
@@ -203,6 +218,7 @@ export const useFurniEditor = () =>
|
||||
setSelectedItem(null);
|
||||
setCatalogItems([]);
|
||||
setFurniDataEntry(null);
|
||||
setFurniDataDiagnostic(null);
|
||||
|
||||
if(simpleAlert)
|
||||
{
|
||||
@@ -295,7 +311,7 @@ export const useFurniEditor = () =>
|
||||
|
||||
return {
|
||||
items, total, page, loading, error, clearError,
|
||||
selectedItem, setSelectedItem, catalogItems, furniDataEntry,
|
||||
selectedItem, setSelectedItem, catalogItems, furniDataEntry, furniDataDiagnostic,
|
||||
interactions,
|
||||
searchItems, loadDetail, loadBySpriteId, updateItem, deleteItem, loadInteractions,
|
||||
updateFurnidata, revertFurnidata, importText, importResult
|
||||
|
||||
Reference in New Issue
Block a user