diff --git a/arma/client/addons/phone/XEH_postInitClient.sqf b/arma/client/addons/phone/XEH_postInitClient.sqf index 86414d3..f3292be 100644 --- a/arma/client/addons/phone/XEH_postInitClient.sqf +++ b/arma/client/addons/phone/XEH_postInitClient.sqf @@ -150,6 +150,17 @@ if (isNil QGVAR(PhoneClass)) then { [] call FUNC(initClass); }; [QGVAR(updateMessageRead), [_messageId]] call CFUNC(localEvent); }] call CFUNC(addEventHandler); +[QGVAR(responseDeleteMessage), { + params [["_success", false, [false]], ["_messageId", "", [""]]]; + + if (_success) then { + diag_log format ["[FORGE:Client:Phone] Message %1 deleted", _messageId]; + [QGVAR(updateMessageDeleted), [_messageId]] call CFUNC(localEvent); + } else { + [QEGVAR(notifications,recieveNotification), ["danger", "Message Delete Failed", "Failed to delete message", 4000]] call CFUNC(localEvent); + }; +}] call CFUNC(addEventHandler); + // Email Response Events [QGVAR(responseEmailSent), { params [["_emailObj", createHashMap, [createHashMap]]]; @@ -215,6 +226,17 @@ if (isNil QGVAR(PhoneClass)) then { [] call FUNC(initClass); }; [QGVAR(updateEmailRead), [_emailId]] call CFUNC(localEvent); }] call CFUNC(addEventHandler); +[QGVAR(responseDeleteEmail), { + params [["_success", false, [false]], ["_emailId", "", [""]]]; + + if (_success) then { + diag_log format ["[FORGE:Client:Phone] Email %1 deleted", _emailId]; + [QGVAR(updateEmailDeleted), [_emailId]] call CFUNC(localEvent); + } else { + [QEGVAR(notifications,recieveNotification), ["danger", "Email Delete Failed", "Failed to delete email", 4000]] call CFUNC(localEvent); + }; +}] call CFUNC(addEventHandler); + // Cleanup Response Events [QGVAR(responseRemovePhone), { params [["_success", false, [false]]]; @@ -269,6 +291,14 @@ if (isNil QGVAR(PhoneClass)) then { [] call FUNC(initClass); }; if (!isNull _control) then { _control ctrlWebBrowserAction ["ExecJS", format ["updateMessageThread(%1, %2)", (toJSON _messages), (toJSON _otherUid)]]; }; }] call CFUNC(addEventHandler); +[QGVAR(updateMessageDeleted), { + params [["_messageId", "", [""]]]; + + private _control = (uiNamespace getVariable ["RscPhone", displayNull]) displayCtrl 1001; + + if (!isNull _control) then { _control ctrlWebBrowserAction ["ExecJS", format ["updateMessageDeleted(%1)", (toJSON _messageId)]]; }; +}] call CFUNC(addEventHandler); + [QGVAR(updateEmailSent), { params [["_emailObj", createHashMap, [createHashMap]]]; @@ -300,3 +330,11 @@ if (isNil QGVAR(PhoneClass)) then { [] call FUNC(initClass); }; if (!isNull _control) then { _control ctrlWebBrowserAction ["ExecJS", format ["updateEmailRead(%1)", (toJSON _emailId)]]; }; }] call CFUNC(addEventHandler); + +[QGVAR(updateEmailDeleted), { + params [["_emailId", "", [""]]]; + + private _control = (uiNamespace getVariable ["RscPhone", displayNull]) displayCtrl 1001; + + if (!isNull _control) then { _control ctrlWebBrowserAction ["ExecJS", format ["updateEmailDeleted(%1)", (toJSON _emailId)]]; }; +}] call CFUNC(addEventHandler); diff --git a/arma/client/addons/phone/functions/fnc_handleUIEvents.sqf b/arma/client/addons/phone/functions/fnc_handleUIEvents.sqf index 72329ef..3b66a67 100644 --- a/arma/client/addons/phone/functions/fnc_handleUIEvents.sqf +++ b/arma/client/addons/phone/functions/fnc_handleUIEvents.sqf @@ -118,12 +118,23 @@ switch (_event) do { diag_log "[FORGE:Client:Phone] No message ID provided for mark read"; }; }; + case "phone::delete::message": { + private _messageId = _data get "messageId"; + + if (_messageId isNotEqualTo "") then { + ["forge_server_phone_requestDeleteMessage", [getPlayerUID player, _messageId, player]] call CFUNC(serverEvent); + } else { + diag_log "[FORGE:Client:Phone] No message ID provided for delete"; + }; + }; case "phone::send::email": { private _toUid = _data get "toUid"; private _subject = _data get "subject"; private _body = _data get "body"; + if (_subject isEqualTo "") then { _subject = "No subject"; }; - if (_toUid isNotEqualTo "" && _subject isNotEqualTo "" && _body isNotEqualTo "") then { + if (_toUid isNotEqualTo "" && _body isNotEqualTo "") then { + diag_log format ["[FORGE:Client:Phone] Sending email to %1 subject length %2 body length %3", _toUid, count _subject, count _body]; ["forge_server_phone_requestSendEmail", [getPlayerUID player, _toUid, _subject, _body, player]] call CFUNC(serverEvent); } else { diag_log "[FORGE:Client:Phone] Missing required email parameters"; @@ -141,6 +152,15 @@ switch (_event) do { diag_log "[FORGE:Client:Phone] No email ID provided for mark read"; }; }; + case "phone::delete::email": { + private _emailId = _data get "emailId"; + + if (_emailId isNotEqualTo "") then { + ["forge_server_phone_requestDeleteEmail", [getPlayerUID player, _emailId, player]] call CFUNC(serverEvent); + } else { + diag_log "[FORGE:Client:Phone] No email ID provided for delete"; + }; + }; case "phone::get::notes": { private _notes = GVAR(PhoneClass) call ["getAllNotes", []]; diff --git a/arma/client/addons/phone/ui/_site/dist/app.bundle.css b/arma/client/addons/phone/ui/_site/dist/app.bundle.css index 4f74960..e5ac873 100644 --- a/arma/client/addons/phone/ui/_site/dist/app.bundle.css +++ b/arma/client/addons/phone/ui/_site/dist/app.bundle.css @@ -1170,6 +1170,7 @@ body::-webkit-scrollbar { .message-content { flex: 1; + min-width: 0; .message-header { display: flex; @@ -1217,9 +1218,58 @@ body::-webkit-scrollbar { } } } + + .message-thread-delete-button { + border: 1px solid rgba(255, 59, 48, 0.55); + border-radius: 10px; + background: rgba(255, 59, 48, 0.14); + color: #ff6b61; + cursor: pointer; + flex-shrink: 0; + font-size: 12px; + font-weight: 700; + margin-left: 10px; + padding: 7px 9px; + } + + .message-thread-delete-button:hover { + background: rgba(255, 59, 48, 0.22); + } } } +.message-nav-delete-button { + border: 0; + border-radius: 10px; + background: rgba(255, 59, 48, 0.18); + color: #ff6b61; + cursor: pointer; + font: inherit; + font-size: 12px; + font-weight: 700; + padding: 7px 10px; +} + +.message-nav-delete-button:hover { + background: rgba(255, 59, 48, 0.28); +} + +.messages-empty-state { + align-items: center; + color: var(--text-secondary); + display: flex; + flex-direction: column; + gap: 6px; + justify-content: center; + min-height: 190px; + text-align: center; +} + +.messages-empty-state strong { + color: var(--text-primary); + font-size: 16px; +} + /* Conversation View */ .conversation-view { height: 100%; @@ -1460,6 +1510,7 @@ body::-webkit-scrollbar { } } + /* ---- ../styles/components/mail.css ---- */ /* Mail App */ .mail-content, @@ -1558,8 +1609,7 @@ body::-webkit-scrollbar { resize: none; } -.mail-send-button, -.nav-action-button { +.mail-send-button { border: 0; border-radius: 12px; background: var(--accent-color); @@ -1572,12 +1622,6 @@ body::-webkit-scrollbar { padding: 12px 14px; } -.nav-action-button { - min-width: 32px; - min-height: 32px; - font-size: 20px; -} - .mail-detail { padding: 16px; overflow-y: auto; @@ -1604,6 +1648,23 @@ body::-webkit-scrollbar { margin: 0; } +.mail-delete-button { + margin-top: 18px; + width: 100%; + border: 1px solid rgba(255, 59, 48, 0.55); + border-radius: 12px; + background: rgba(255, 59, 48, 0.14); + color: #ff6b61; + cursor: pointer; + font: inherit; + font-weight: 700; + padding: 11px 14px; +} + +.mail-delete-button:hover { + background: rgba(255, 59, 48, 0.22); +} + /* ---- ../styles/components/notes.css ---- */ /* Notes App Styles */ diff --git a/arma/client/addons/phone/ui/_site/dist/app.bundle.js b/arma/client/addons/phone/ui/_site/dist/app.bundle.js index 7dc516e..d2d2b3a 100644 --- a/arma/client/addons/phone/ui/_site/dist/app.bundle.js +++ b/arma/client/addons/phone/ui/_site/dist/app.bundle.js @@ -197,8 +197,14 @@ class Component { Object.assign(element.style, value); } else if (key === 'ref' && typeof value === 'function') { value(element); - } else { + } else if (typeof value === 'boolean') { + if (value) { + element.setAttribute(key, key); + } + } else if (value !== null && value !== undefined) { element.setAttribute(key, value); + } else { + return; } }); @@ -300,6 +306,7 @@ const initialAppState = { // UI state selectedContact: null, selectedConversation: null, + showMessageContactPicker: false, newMessage: '', currentUid: null, @@ -760,11 +767,15 @@ class Modal extends Component { * @returns {HTMLElement} The rendered actions element * @private */ - renderActions(onClose, onConfirm, confirmText = 'Call', cancelText = 'Cancel') { + renderActions(onClose, onConfirm, confirmText = 'Call', cancelText = 'Cancel', extraActions = [], hideCancel = false, hideConfirm = false) { + if (hideCancel && hideConfirm && !extraActions.length) { + return null; + } + return this.createElement( 'div', { className: 'modal-actions' }, - this.createElement( + hideCancel ? null : this.createElement( 'button', { className: 'button secondary', @@ -774,7 +785,18 @@ class Modal extends Component { }, cancelText ), - this.createElement( + ...extraActions.map((action) => this.createElement( + 'button', + { + className: action.className || 'button secondary', + onClick: () => action.onClick?.(), + type: 'button', + disabled: action.disabled === true, + 'aria-label': action.ariaLabel || action.text, + }, + action.text + )), + hideConfirm ? null : this.createElement( 'button', { className: 'button', @@ -792,7 +814,7 @@ class Modal extends Component { * @returns {HTMLElement} The rendered modal element */ render() { - const { show, title, children = [], onClose, onConfirm, confirmText, cancelText } = this.props; + const { show, title, children = [], onClose, onConfirm, confirmText, cancelText, extraActions = [], hideCancel = false, hideConfirm = false } = this.props; if (!show) { return this.createElement('div', { @@ -841,7 +863,7 @@ class Modal extends Component { }, ...childElements.filter((child) => child != null) ), - this.renderActions(onClose, onConfirm, confirmText, cancelText) + this.renderActions(onClose, onConfirm, confirmText, cancelText, extraActions, hideCancel, hideConfirm) ) ); } @@ -883,10 +905,18 @@ class NavigationBar extends Component { if (currentState.selectedConversation) { globalState.setState({ selectedConversation: null, + selectedConversationRaw: null, }); return; // Exit early, don't execute the rest } + if (currentState.showMessageContactPicker) { + globalState.setState({ + showMessageContactPicker: false, + }); + return; + } + if (currentState.selectedEmail || currentState.showEmailComposer) { globalState.setState({ selectedEmail: null, @@ -909,7 +939,9 @@ class NavigationBar extends Component { currentApp: 'home', previousApp: null, selectedConversation: null, + selectedConversationRaw: null, selectedContact: null, + showMessageContactPicker: false, showModal: false, }); } @@ -1063,7 +1095,9 @@ class HomeIndicator extends Component { globalState.setState({ currentApp: 'home', selectedConversation: null, + selectedConversationRaw: null, selectedContact: null, + showMessageContactPicker: false, showModal: false, }); } @@ -1506,6 +1540,8 @@ class HomeScreen extends Component { */ class Dialpad extends Component { + static fieldCommanderPhoneNumber = '0160000000'; + static assetPath(...parts) { return PhoneMedia.base64Path('images', ...parts); } @@ -1674,7 +1710,11 @@ class Dialpad extends Component { * @description Initiates a phone call and starts the call timer */ handleCall() { - if (this.state.phoneNumber && !this.state.isCallActive) { + if ( + this.state.phoneNumber && + !this.state.isCallActive && + this.cleanPhoneNumber(this.state.phoneNumber) !== Dialpad.fieldCommanderPhoneNumber + ) { this.setState({ isCallActive: true, callDuration: 0, @@ -1804,7 +1844,7 @@ class Dialpad extends Component { 'aria-label': 'Make call', }; - if (isPhoneNumberEmpty) { + if (isPhoneNumberEmpty || this.cleanPhoneNumber(phoneNumber) === Dialpad.fieldCommanderPhoneNumber) { callButtonProps.disabled = true; } @@ -1950,7 +1990,7 @@ class MessagesList extends Component { constructor(props) { super(props); this.state = { - filteredMessages: props.messages || [], + filteredMessages: this.buildRows(props.messages || [], props.contacts || [], ''), searchTerm: '' }; } @@ -1960,24 +2000,94 @@ class MessagesList extends Component { * @param {Object} nextProps - Next props */ componentWillReceiveProps(nextProps) { - if (nextProps.messages !== this.props.messages) { + if ( + nextProps.messages !== this.props.messages || + nextProps.contacts !== this.props.contacts || + nextProps.includeContacts !== this.props.includeContacts || + nextProps.includeContactsOnSearch !== this.props.includeContactsOnSearch + ) { // Re-apply current search filter to new messages this.handleSearch(this.state.searchTerm); } } + buildRows(messages = [], contacts = [], searchTerm = '') { + const searchTermLower = searchTerm.toLowerCase(); + const includeContacts = this.props.includeContacts === true || (this.props.includeContactsOnSearch === true && searchTermLower.length > 0); + const byContactId = new Map(); + const contactByUid = new Map(); + + contacts + .filter((contact) => contact && contact.uid) + .forEach((contact) => contactByUid.set(contact.uid, contact)); + + messages.forEach((message) => { + if (!message) return; + const contactId = message.contactId || message.id; + const contact = contactByUid.get(contactId) || {}; + + byContactId.set(contactId, { + ...contact, + ...message, + id: contactId, + contactId, + contactName: message.contactName || contact.fullName || contact.name || contactId, + phone: contact.phone || message.phone || '', + email: contact.email || message.email || '', + canCall: contact.canCall !== false, + canMessage: contact.canMessage !== false, + hasConversation: Array.isArray(message.conversation) && message.conversation.length > 0 + }); + }); + + if (includeContacts) { + contacts + .filter((contact) => contact && contact.uid && contact.canMessage !== false) + .forEach((contact) => { + if (byContactId.has(contact.uid)) return; + + byContactId.set(contact.uid, { + id: contact.uid, + contactId: contact.uid, + contactName: contact.fullName || contact.name || contact.uid, + fullName: contact.fullName || contact.name || contact.uid, + name: contact.name || contact.fullName || contact.uid, + phone: contact.phone || '', + email: contact.email || '', + avatar: contact.avatar, + canCall: contact.canCall !== false, + canMessage: contact.canMessage !== false, + lastMessage: 'Start conversation', + timestamp: null, + unread: 0, + conversation: [], + hasConversation: false + }); + }); + } + + return Array.from(byContactId.values()).filter((message) => { + if (!searchTermLower) return true; + + return [ + message.contactName, + message.lastMessage, + message.contactId, + message.id, + message.phone, + message.email + ].some((value) => (value || '').toString().toLowerCase().includes(searchTermLower)); + }); + } + /** * Filter messages based on search term * @param {string} searchTerm - The search term to filter messages * @private */ handleSearch(searchTerm) { - const { messages = [] } = this.props; - const searchTermLower = searchTerm.toLowerCase(); - - const filtered = messages.filter(message => - message.contactName.toLowerCase().includes(searchTermLower) - ); + const { messages = [], contacts = [] } = this.props; + const filtered = this.buildRows(messages, contacts, searchTerm); this.setState({ filteredMessages: filtered, @@ -1991,14 +2101,26 @@ class MessagesList extends Component { * @returns {Array} Array of MessageItem components */ renderMessageItems() { - const { onMessageClick } = this.props; + const { onMessageClick, onMessageDelete } = this.props; const { filteredMessages } = this.state; + if (!filteredMessages.length) { + return [ + this.createElement( + 'div', + { className: 'messages-empty-state' }, + this.createElement('strong', {}, this.props.emptyTitle || 'No conversations'), + this.createElement('span', {}, this.props.emptySubtitle || 'Tap + to start a new conversation.') + ) + ]; + } + return filteredMessages.map( (message) => new MessageItem({ message, onClick: onMessageClick, + onDelete: onMessageDelete, key: message.id, }) ); @@ -2022,7 +2144,7 @@ class MessagesList extends Component { } }, new SearchBar({ - placeholder: 'Search by contact name...', + placeholder: this.props.searchPlaceholder || 'Search by contact name...', onSearch: this.handleSearch.bind(this), value: searchTerm }), @@ -2044,6 +2166,7 @@ class MessagesList extends Component { } } + // ---- ../js/apps/messages/components/MessageItem.js ---- /** @format */ @@ -2067,6 +2190,7 @@ class MessageItem extends Component { constructor(props) { super(props); this.handleClick = this.handleClick.bind(this); + this.handleDeleteClick = this.handleDeleteClick.bind(this); } /** @@ -2080,6 +2204,19 @@ class MessageItem extends Component { } } + /** + * Handles delete clicks without opening the conversation. + * @param {Event} event - Click event + * @private + */ + handleDeleteClick(event) { + event.stopPropagation(); + const { onDelete, message } = this.props; + if (onDelete) { + onDelete(message); + } + } + /** * Formats the timestamp into a relative time string * @param {Date} timestamp - The timestamp to format @@ -2087,8 +2224,12 @@ class MessageItem extends Component { * @private */ formatTime(timestamp) { + if (!timestamp) return ''; + const now = new Date(); const messageTime = new Date(timestamp); + if (Number.isNaN(messageTime.getTime())) return ''; + const diffInHours = (now - messageTime) / (1000 * 60 * 60); if (diffInHours < 1) { @@ -2138,7 +2279,7 @@ class MessageItem extends Component { 'span', { className: 'message-time', - 'aria-label': `Sent ${this.formatTime(message.timestamp)}`, + 'aria-label': message.timestamp ? `Sent ${this.formatTime(message.timestamp)}` : '', }, this.formatTime(message.timestamp) ) @@ -2152,6 +2293,8 @@ class MessageItem extends Component { * @private */ renderMessagePreview(message) { + const preview = message.hasConversation ? message.lastMessage : 'Start conversation'; + return this.createElement( 'div', { className: 'message-preview' }, @@ -2161,7 +2304,7 @@ class MessageItem extends Component { role: 'text', 'aria-label': 'Last message', }, - message.lastMessage + preview ), message.unread > 0 && this.createElement( @@ -2183,6 +2326,7 @@ class MessageItem extends Component { render() { const { message } = this.props; const initials = this.getContactInitials(message.contactName); + const canDelete = Array.isArray(message.conversation) && message.conversation.length > 0; return this.createElement( 'div', @@ -2206,7 +2350,22 @@ class MessageItem extends Component { }, initials ), - this.createElement('div', { className: 'message-content' }, this.renderMessageHeader(message), this.renderMessagePreview(message)) + this.createElement( + 'div', + { className: 'message-content' }, + this.renderMessageHeader(message), + this.renderMessagePreview(message) + ), + canDelete ? this.createElement( + 'button', + { + type: 'button', + className: 'message-thread-delete-button', + 'aria-label': `Delete conversation with ${message.contactName}`, + onClick: this.handleDeleteClick + }, + 'Delete' + ) : null ); } } @@ -2361,6 +2520,10 @@ class ConversationView extends Component { const { newMessage } = this.state; const { conversation } = this.props; + if (conversation && conversation.canMessage === false) { + return; + } + if (newMessage.trim()) { // Create new message object const newMessageObj = { @@ -2444,6 +2607,9 @@ class ConversationView extends Component { * @private */ renderMessageForm() { + const { conversation } = this.props; + const canMessage = !conversation || conversation.canMessage !== false; + return this.createElement( 'div', { @@ -2453,9 +2619,11 @@ class ConversationView extends Component { }, this.createElement('textarea', { className: 'message-input', - placeholder: 'Type a message...', + placeholder: canMessage ? 'Type a message...' : 'Replies disabled for this contact', value: this.state.newMessage, + disabled: !canMessage, onInput: (e) => { + if (!canMessage) return; this.handleInputChange(e); // Auto-grow logic if (e.target) { @@ -2465,7 +2633,7 @@ class ConversationView extends Component { }, onKeyDown: (e) => { // Send message on Enter key (but not Shift+Enter) - if (e.key === 'Enter' && !e.shiftKey) { + if (canMessage && e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); this.handleSendMessage(); } @@ -2487,7 +2655,8 @@ class ConversationView extends Component { type: 'button', className: 'send-button', onClick: this.handleSendMessage, - 'aria-label': 'Send message' + disabled: !canMessage, + 'aria-label': canMessage ? 'Send message' : 'Replies disabled' }, this.createElement('img', { src: 'data:image/svg+xml;utf8,', @@ -2542,67 +2711,126 @@ class ConversationView extends Component { // ---- ../js/apps/messages/index.js ---- /** - * @fileoverview Main entry point for the Messages application - * - * This module initializes the Messages app UI, including: - * - Rendering the navigation bar with the app title or contact name - * - Displaying either the messages list or a conversation view - * - Handling navigation between the list and conversation - * - * The navigation bar shows "Messages" on the list, and the contact's name with a back button in a conversation. + * @fileoverview Main entry point for the Messages application. */ -// Initialize the messages app function initializeMessagesApp(container) { - // Get current messages and selected conversation from global state - const { messages, selectedConversation } = globalState.getState(); + const { messages = [], contacts = [], selectedConversation, showMessageContactPicker } = globalState.getState(); const appContainer = document.createElement('div'); + const openConversation = (conversation) => { + if (!conversation) return; + + const contactId = conversation.contactId || conversation.uid || conversation.id; + const { rawMessages = [], currentUid = window.__playerUid } = globalState.getState(); + const selectedRawMessages = rawMessages.filter((message) => + message && + ( + (message.from === currentUid && message.to === contactId) || + (message.from === contactId && message.to === currentUid) + ) + ); + + globalState.setState({ + selectedConversation: { + ...conversation, + id: contactId, + contactId, + contactName: conversation.contactName || conversation.fullName || conversation.name || contactId, + conversation: conversation.conversation || [] + }, + selectedConversationRaw: { + otherUid: contactId, + messages: selectedRawMessages + }, + showMessageContactPicker: false + }); + }; + + const deleteConversationMessages = (conversation) => { + const messageIds = ((conversation && conversation.conversation) || []) + .map((message) => message && message.id) + .filter(Boolean); + + if (!messageIds.length) return; + + if (typeof A3API !== 'undefined' && A3API.SendAlert) { + messageIds.forEach((messageId) => { + A3API.SendAlert(JSON.stringify({ + event: 'phone::delete::message', + data: { messageId } + })); + }); + } + }; + appContainer.className = 'app-container'; appContainer.setAttribute('role', 'main'); appContainer.setAttribute('aria-label', 'Messages'); - /** - * Navigation bar - * - Shows "Messages" on the list - * - Shows contact name and back button in a conversation - */ const navBar = new NavigationBar({ - title: selectedConversation ? selectedConversation.contactName : 'Messages', - showBackButton: !!selectedConversation + title: selectedConversation ? selectedConversation.contactName : (showMessageContactPicker ? 'New Conversation' : 'Messages'), + showBackButton: !!selectedConversation || showMessageContactPicker, + rightButton: selectedConversation && selectedConversation.conversation && selectedConversation.conversation.length ? { + element: 'button', + props: { + type: 'button', + className: 'message-nav-delete-button', + onClick: () => { + deleteConversationMessages(selectedConversation); + globalState.setState({ selectedConversation: null, selectedConversationRaw: null }); + } + }, + content: 'Delete' + } : (!selectedConversation && !showMessageContactPicker) ? { + element: 'button', + props: { + type: 'button', + className: 'nav-button add-button', + onClick: () => globalState.setState({ showMessageContactPicker: true }), + 'aria-label': 'Start conversation', + style: { + fontSize: '24px', + padding: '0 15px', + background: 'none', + border: 'none', + color: 'var(--accent-color)', + cursor: 'pointer' + } + }, + content: '+' + } : null }); navBar.mount(appContainer); - // Content container for either the list or conversation const contentContainer = document.createElement('div'); contentContainer.className = 'content'; appContainer.appendChild(contentContainer); - /** - * Render either the conversation view or the messages list - * - If a conversation is selected, show ConversationView - * - Otherwise, show MessagesList - */ if (selectedConversation) { const conversationView = new ConversationView({ conversation: selectedConversation }); conversationView.mount(contentContainer); } else { const messagesList = new MessagesList({ messages, - onMessageClick: (message) => { - globalState.setState({ selectedConversation: message }); - } + contacts, + includeContacts: showMessageContactPicker, + includeContactsOnSearch: true, + searchPlaceholder: 'Search contacts or conversations...', + emptyTitle: showMessageContactPicker ? 'No contacts found' : 'No conversations', + emptySubtitle: showMessageContactPicker ? 'Try another search.' : 'Search for a contact to start texting.', + onMessageClick: openConversation, + onMessageDelete: deleteConversationMessages }); messagesList.mount(contentContainer); } - // Mount the app container container.appendChild(appContainer); } -// Make initialization function globally available window.initializeMessagesApp = initializeMessagesApp; + // ---- ../js/apps/contacts/components/ContactList.js ---- /** @format */ @@ -2764,14 +2992,17 @@ class ContactItem extends Component { */ render() { const { contact } = this.props; + const displayName = contact.fullName || contact.name; + const subtitleParts = [contact.phone]; + if (contact.system) subtitleParts.push('system contact'); return this.createElement( 'li', { - className: 'contact-item', + className: `contact-item${contact.system ? ' system-contact' : ''}`, onClick: this.handleClick, role: 'button', - 'aria-label': `Contact ${contact.name}`, + 'aria-label': `Contact ${displayName}`, }, // Avatar section this.createElement( @@ -2783,11 +3014,17 @@ class ContactItem extends Component { contact.avatar ), // Contact information section - this.createElement('div', { className: 'contact-info' }, this.createElement('h3', {}, contact.name), this.createElement('p', { 'aria-label': 'Phone number' }, contact.phone)) + this.createElement( + 'div', + { className: 'contact-info' }, + this.createElement('h3', {}, displayName), + this.createElement('p', { 'aria-label': 'Phone number' }, subtitleParts.filter(Boolean).join(' - ')) + ) ); } } + // ---- ../js/apps/contacts/components/AddContactForm.js ---- /** @format */ @@ -3163,27 +3400,86 @@ class MailList extends Component { class MailComposer extends Component { constructor(props = {}) { super(props); + const contacts = this.emailableContacts(props.contacts || []); + const defaultRecipient = contacts.length === 1 ? (contacts[0].uid || contacts[0].id || '') : ''; this.state = { - toUid: '', + toUid: defaultRecipient, subject: '', body: '' }; + this.toRef = null; + this.subjectRef = null; + this.bodyRef = null; + this.lastSendAt = 0; + this.handleSend = this.handleSend.bind(this); + this.syncSubject = this.syncSubject.bind(this); + this.syncBody = this.syncBody.bind(this); } - handleSend() { - const toUid = (this.state.toUid || '').trim(); - const subject = (this.state.subject || '').trim(); - const body = (this.state.body || '').trim(); + emailableContacts(contacts = []) { + return contacts.filter((contact) => contact && contact.canEmail !== false && (contact.uid || contact.id)); + } - if (!toUid || !subject || !body) return; + readField(id, ref, fallback = '') { + const scopedElement = this.element ? this.element.querySelector(`#${id}`) : null; + const documentElement = typeof document !== 'undefined' ? document.getElementById(id) : null; + const element = scopedElement || documentElement || ref; + if (!element) return fallback; + + if (typeof element.value === 'string' && element.value.length > 0) { + return element.value; + } + + if (typeof element.textContent === 'string' && element.textContent.length > 0) { + return element.textContent; + } + + return fallback; + } + + syncSubject(event) { + this.state.subject = event?.target?.value || ''; + } + + syncBody(event) { + this.state.body = event?.target?.value || ''; + } + + handleSend(event) { + event?.preventDefault?.(); + event?.stopPropagation?.(); + + const now = Date.now(); + if (now - this.lastSendAt < 500) return; + + const toUid = this.readField('phone-mail-recipient', this.toRef, this.state.toUid).trim(); + const subject = this.readField('phone-mail-subject', this.subjectRef, this.state.subject).trim() || 'No subject'; + const body = this.readField('phone-mail-body', this.bodyRef, this.state.body).trim(); + + if (!toUid || !body) { + console.warn('MailComposer: missing required email fields', { + hasRecipient: !!toUid, + hasSubject: subject !== 'No subject', + hasBody: !!body, + toUid, + subjectLength: subject.length, + bodyLength: body.length + }); + return; + } + + this.lastSendAt = now; if (typeof A3API !== 'undefined' && A3API.SendAlert) { + console.log('MailComposer: sending email', { toUid, subjectLength: subject.length, bodyLength: body.length }); A3API.SendAlert(JSON.stringify({ event: 'phone::send::email', data: { toUid, subject, body } })); + } else { + console.warn('MailComposer: A3API.SendAlert unavailable'); } globalState.setState({ @@ -3193,14 +3489,14 @@ class MailComposer extends Component { } renderContactOptions() { - const contacts = this.props.contacts || []; + const contacts = this.emailableContacts(this.props.contacts || []); return [ this.createElement('option', { value: '' }, 'Select recipient'), ...contacts.map((contact) => this.createElement( 'option', { value: contact.uid || contact.id }, - `${contact.name || 'Unknown'}${contact.email ? ` (${contact.email})` : ''}` + `${contact.fullName || contact.name || 'Unknown'}${contact.email ? ` (${contact.email})` : ''}` )) ]; } @@ -3214,8 +3510,17 @@ class MailComposer extends Component { this.createElement( 'select', { + id: 'phone-mail-recipient', + name: 'phone-mail-recipient', value: this.state.toUid, + onInput: (event) => { this.state.toUid = event.target.value; }, onChange: (event) => { this.state.toUid = event.target.value; }, + ref: (element) => { + this.toRef = element; + if (element && this.state.toUid && !element.value) { + element.value = this.state.toUid; + } + }, 'aria-label': 'Email recipient' }, ...this.renderContactOptions() @@ -3224,17 +3529,27 @@ class MailComposer extends Component { this.createElement('label', {}, 'Subject', this.createElement('input', { + id: 'phone-mail-subject', + name: 'phone-mail-subject', type: 'text', value: this.state.subject, - onInput: (event) => { this.state.subject = event.target.value; }, + onInput: this.syncSubject, + onChange: this.syncSubject, + onKeyUp: this.syncSubject, + ref: (element) => { this.subjectRef = element; }, placeholder: 'Subject' }) ), this.createElement('label', {}, 'Message', this.createElement('textarea', { + id: 'phone-mail-body', + name: 'phone-mail-body', value: this.state.body, - onInput: (event) => { this.state.body = event.target.value; }, + onInput: this.syncBody, + onChange: this.syncBody, + onKeyUp: this.syncBody, + ref: (element) => { this.bodyRef = element; }, placeholder: 'Write email body...', rows: 8 }) @@ -3244,7 +3559,8 @@ class MailComposer extends Component { { type: 'button', className: 'mail-send-button', - onClick: this.handleSend + onClick: this.handleSend, + onMouseDown: this.handleSend }, 'Send' ) @@ -3286,6 +3602,17 @@ class MailDetail extends Component { } } + handleDeleteEmail(emailId) { + if (!emailId) return; + + if (typeof A3API !== 'undefined' && A3API.SendAlert) { + A3API.SendAlert(JSON.stringify({ + event: 'phone::delete::email', + data: { emailId } + })); + } + } + render() { const { email } = this.props; @@ -3302,7 +3629,16 @@ class MailDetail extends Component { this.createElement('span', {}, `To: ${this.resolveContactName(email.to) || 'Unknown'}`), this.createElement('span', {}, this.formatEmailTime(email.timestamp)) ), - this.createElement('p', { className: 'mail-body' }, email.body || '') + this.createElement('p', { className: 'mail-body' }, email.body || ''), + this.createElement( + 'button', + { + type: 'button', + className: 'mail-delete-button', + onClick: () => this.handleDeleteEmail(email.id) + }, + 'Delete Email' + ) ); } } @@ -3329,9 +3665,17 @@ function initializeMailApp(container) { element: 'button', props: { type: 'button', - className: 'nav-action-button', + className: 'nav-button add-button', onClick: () => globalState.setState({ showEmailComposer: true, selectedEmail: null }), - 'aria-label': 'Compose email' + 'aria-label': 'Compose email', + style: { + fontSize: '24px', + padding: '0 15px', + background: 'none', + border: 'none', + color: 'var(--accent-color)', + cursor: 'pointer' + } }, content: '+' } : null @@ -6371,6 +6715,48 @@ class App extends Component { */ render() { const { currentApp, selectedContact, showModal, showDeleteModal, noteToDelete, eventToDelete } = this.state; + const openMessageThread = (contact) => { + if (!contact || contact.canMessage === false) return; + + const contactId = contact.contactId || contact.uid || contact.id; + if (!contactId) return; + + const { messages = [], rawMessages = [], currentUid = window.__playerUid } = globalState.getState(); + const existingConversation = messages.find((message) => (message.contactId || message.id) === contactId); + const selectedRawMessages = rawMessages.filter((message) => + message && + ( + (message.from === currentUid && message.to === contactId) || + (message.from === contactId && message.to === currentUid) + ) + ); + const conversation = existingConversation || { + ...contact, + id: contactId, + contactId, + contactName: contact.fullName || contact.name || contactId, + conversation: [], + hasConversation: false + }; + + globalState.setState({ + currentApp: 'messages', + selectedContact: null, + showModal: false, + showMessageContactPicker: false, + selectedConversation: { + ...conversation, + id: contactId, + contactId, + contactName: conversation.contactName || contact.fullName || contact.name || contactId, + conversation: conversation.conversation || [] + }, + selectedConversationRaw: { + otherUid: contactId, + messages: selectedRawMessages + } + }); + }; return this.createElement( 'div', @@ -6408,9 +6794,24 @@ class App extends Component { // Call modal showModal && selectedContact && new Modal({ show: showModal, - title: `Call ${selectedContact.name}?`, + title: selectedContact.canCall === false ? (selectedContact.fullName || selectedContact.name) : `Call ${selectedContact.fullName || selectedContact.name}?`, + confirmText: selectedContact.canCall === false ? 'Close' : 'Call', + cancelText: selectedContact.canCall === false ? 'Back' : 'Cancel', + hideCancel: true, + hideConfirm: selectedContact.canCall === false, + extraActions: selectedContact.canMessage === false || !(selectedContact.contactId || selectedContact.uid || selectedContact.id) ? [] : [{ + text: 'Text', + ariaLabel: `Text ${selectedContact.fullName || selectedContact.name}`, + className: 'button secondary', + onClick: () => openMessageThread(selectedContact) + }], onClose: () => globalState.setState({ showModal: false, selectedContact: null }), onConfirm: () => { + if (selectedContact.canCall === false) { + globalState.setState({ showModal: false, selectedContact: null }); + return; + } + globalState.setState({ phoneNumber: selectedContact.phone, showModal: false, @@ -6418,7 +6819,15 @@ class App extends Component { currentApp: 'phone' }); }, - children: [this.createElement('p', { role: 'alert' }, `Do you want to call ${selectedContact.name} at ${selectedContact.phone}?`)] + children: [ + this.createElement( + 'p', + { role: 'alert' }, + selectedContact.canCall === false + ? `${selectedContact.fullName || selectedContact.name} is a command broadcast contact. Incoming messages and email are available, but direct calls are disabled.` + : `Do you want to call ${selectedContact.fullName || selectedContact.name} at ${selectedContact.phone}?` + ) + ] }), // Delete note confirmation modal @@ -6701,10 +7110,15 @@ function normalizeContacts(contacts) { id: uid || contact.phone || name, uid, name, + fullName: contact.fullName || name, phone: contact.phone || '', email: contact.email || '', avatar: contact.avatar || getInitials(name), - online: Boolean(contact.online) + online: Boolean(contact.online), + system: Boolean(contact.system), + canCall: contact.canCall !== false, + canMessage: contact.canMessage !== false, + canEmail: contact.canEmail !== false }; }); } @@ -6851,6 +7265,30 @@ function updateMessageRead(messageId) { } } +/** + * Remove a message from the local phone state after server delete succeeds + * @param {string} messageId + */ +function updateMessageDeleted(messageId) { + try { + const { rawMessages = [], selectedConversationRaw = null } = globalState.getState(); + const nextRawMessages = rawMessages.filter(message => message && message.id !== messageId); + const statePatch = { rawMessages: nextRawMessages }; + + if (selectedConversationRaw && Array.isArray(selectedConversationRaw.messages)) { + statePatch.selectedConversationRaw = { + ...selectedConversationRaw, + messages: selectedConversationRaw.messages.filter(message => message && message.id !== messageId) + }; + } + + globalState.setState(statePatch); + rebuildMessageSummariesFromRaw(); + } catch (e) { + console.error('Error in updateMessageDeleted:', e); + } +} + // Transform raw message payloads into UI-friendly summary and thread structures function rebuildMessageSummariesFromRaw() { try { @@ -6907,6 +7345,7 @@ function rebuildMessageSummariesFromRaw() { id: otherUid, contactId: otherUid, contactName: contact.name || otherUid, + canMessage: contact.canMessage !== false, lastMessage: (last && (last.message || last.text)) || '', timestamp: toJsDate(last && last.timestamp), unread: arr.filter(m => m.read === false && m.to === currentUid).length || 0, @@ -6927,6 +7366,7 @@ function rebuildMessageSummariesFromRaw() { id: selectedConversationRaw.otherUid, contactId: selectedConversationRaw.otherUid, contactName: contact.name, + canMessage: contact.canMessage !== false, lastMessage: thread.length ? (thread[thread.length - 1].message || thread[thread.length - 1].text) : '', timestamp: thread.length ? toJsDate(thread[thread.length - 1].timestamp) : new Date(), unread: thread.filter(m => m.read === false && m.to === currentUid).length || 0, @@ -7049,6 +7489,22 @@ function updateEmailRead(emailId) { } } +/** + * Remove an email from the local phone state after server delete succeeds + * @param {string} emailId + */ +function updateEmailDeleted(emailId) { + try { + const { emails = [], selectedEmail = null } = globalState.getState(); + globalState.setState({ + emails: emails.filter(email => email && email.id !== emailId), + selectedEmail: selectedEmail && selectedEmail.id === emailId ? null : selectedEmail + }); + } catch (e) { + console.error('Error in updateEmailDeleted:', e); + } +} + // Debounce variables for notes requests let lastNotesRequest = 0; const NOTES_REQUEST_COOLDOWN = 1000; // 1 second cooldown @@ -7444,12 +7900,14 @@ window.updateMessageThread = updateMessageThread; window.updateMessageSent = updateMessageSent; window.updateMessageReceived = updateMessageReceived; window.updateMessageRead = updateMessageRead; +window.updateMessageDeleted = updateMessageDeleted; // Emails window.requestEmails = requestEmails; window.updateEmails = updateEmails; window.updateEmailSent = updateEmailSent; window.updateEmailReceived = updateEmailReceived; window.updateEmailRead = updateEmailRead; +window.updateEmailDeleted = updateEmailDeleted; window.requestNotes = requestNotes; window.loadNotes = loadNotes; window.saveNote = saveNote; diff --git a/arma/client/addons/phone/ui/_site/js/app.js b/arma/client/addons/phone/ui/_site/js/app.js index 438d6b4..8f9a8d0 100644 --- a/arma/client/addons/phone/ui/_site/js/app.js +++ b/arma/client/addons/phone/ui/_site/js/app.js @@ -143,6 +143,48 @@ class App extends Component { */ render() { const { currentApp, selectedContact, showModal, showDeleteModal, noteToDelete, eventToDelete } = this.state; + const openMessageThread = (contact) => { + if (!contact || contact.canMessage === false) return; + + const contactId = contact.contactId || contact.uid || contact.id; + if (!contactId) return; + + const { messages = [], rawMessages = [], currentUid = window.__playerUid } = globalState.getState(); + const existingConversation = messages.find((message) => (message.contactId || message.id) === contactId); + const selectedRawMessages = rawMessages.filter((message) => + message && + ( + (message.from === currentUid && message.to === contactId) || + (message.from === contactId && message.to === currentUid) + ) + ); + const conversation = existingConversation || { + ...contact, + id: contactId, + contactId, + contactName: contact.fullName || contact.name || contactId, + conversation: [], + hasConversation: false + }; + + globalState.setState({ + currentApp: 'messages', + selectedContact: null, + showModal: false, + showMessageContactPicker: false, + selectedConversation: { + ...conversation, + id: contactId, + contactId, + contactName: conversation.contactName || contact.fullName || contact.name || contactId, + conversation: conversation.conversation || [] + }, + selectedConversationRaw: { + otherUid: contactId, + messages: selectedRawMessages + } + }); + }; return this.createElement( 'div', @@ -180,9 +222,24 @@ class App extends Component { // Call modal showModal && selectedContact && new Modal({ show: showModal, - title: `Call ${selectedContact.name}?`, + title: selectedContact.canCall === false ? (selectedContact.fullName || selectedContact.name) : `Call ${selectedContact.fullName || selectedContact.name}?`, + confirmText: selectedContact.canCall === false ? 'Close' : 'Call', + cancelText: selectedContact.canCall === false ? 'Back' : 'Cancel', + hideCancel: true, + hideConfirm: selectedContact.canCall === false, + extraActions: selectedContact.canMessage === false || !(selectedContact.contactId || selectedContact.uid || selectedContact.id) ? [] : [{ + text: 'Text', + ariaLabel: `Text ${selectedContact.fullName || selectedContact.name}`, + className: 'button secondary', + onClick: () => openMessageThread(selectedContact) + }], onClose: () => globalState.setState({ showModal: false, selectedContact: null }), onConfirm: () => { + if (selectedContact.canCall === false) { + globalState.setState({ showModal: false, selectedContact: null }); + return; + } + globalState.setState({ phoneNumber: selectedContact.phone, showModal: false, @@ -190,7 +247,15 @@ class App extends Component { currentApp: 'phone' }); }, - children: [this.createElement('p', { role: 'alert' }, `Do you want to call ${selectedContact.name} at ${selectedContact.phone}?`)] + children: [ + this.createElement( + 'p', + { role: 'alert' }, + selectedContact.canCall === false + ? `${selectedContact.fullName || selectedContact.name} is a command broadcast contact. Incoming messages and email are available, but direct calls are disabled.` + : `Do you want to call ${selectedContact.fullName || selectedContact.name} at ${selectedContact.phone}?` + ) + ] }), // Delete note confirmation modal diff --git a/arma/client/addons/phone/ui/_site/js/apps/contacts/components/ContactItem.js b/arma/client/addons/phone/ui/_site/js/apps/contacts/components/ContactItem.js index 30a9a0f..6e47572 100644 --- a/arma/client/addons/phone/ui/_site/js/apps/contacts/components/ContactItem.js +++ b/arma/client/addons/phone/ui/_site/js/apps/contacts/components/ContactItem.js @@ -42,14 +42,17 @@ class ContactItem extends Component { */ render() { const { contact } = this.props; + const displayName = contact.fullName || contact.name; + const subtitleParts = [contact.phone]; + if (contact.system) subtitleParts.push('system contact'); return this.createElement( 'li', { - className: 'contact-item', + className: `contact-item${contact.system ? ' system-contact' : ''}`, onClick: this.handleClick, role: 'button', - 'aria-label': `Contact ${contact.name}`, + 'aria-label': `Contact ${displayName}`, }, // Avatar section this.createElement( @@ -61,7 +64,12 @@ class ContactItem extends Component { contact.avatar ), // Contact information section - this.createElement('div', { className: 'contact-info' }, this.createElement('h3', {}, contact.name), this.createElement('p', { 'aria-label': 'Phone number' }, contact.phone)) + this.createElement( + 'div', + { className: 'contact-info' }, + this.createElement('h3', {}, displayName), + this.createElement('p', { 'aria-label': 'Phone number' }, subtitleParts.filter(Boolean).join(' - ')) + ) ); } -} \ No newline at end of file +} diff --git a/arma/client/addons/phone/ui/_site/js/apps/mail/components/MailComposer.js b/arma/client/addons/phone/ui/_site/js/apps/mail/components/MailComposer.js index d28517e..0eafca7 100644 --- a/arma/client/addons/phone/ui/_site/js/apps/mail/components/MailComposer.js +++ b/arma/client/addons/phone/ui/_site/js/apps/mail/components/MailComposer.js @@ -3,27 +3,86 @@ class MailComposer extends Component { constructor(props = {}) { super(props); + const contacts = this.emailableContacts(props.contacts || []); + const defaultRecipient = contacts.length === 1 ? (contacts[0].uid || contacts[0].id || '') : ''; this.state = { - toUid: '', + toUid: defaultRecipient, subject: '', body: '' }; + this.toRef = null; + this.subjectRef = null; + this.bodyRef = null; + this.lastSendAt = 0; + this.handleSend = this.handleSend.bind(this); + this.syncSubject = this.syncSubject.bind(this); + this.syncBody = this.syncBody.bind(this); } - handleSend() { - const toUid = (this.state.toUid || '').trim(); - const subject = (this.state.subject || '').trim(); - const body = (this.state.body || '').trim(); + emailableContacts(contacts = []) { + return contacts.filter((contact) => contact && contact.canEmail !== false && (contact.uid || contact.id)); + } - if (!toUid || !subject || !body) return; + readField(id, ref, fallback = '') { + const scopedElement = this.element ? this.element.querySelector(`#${id}`) : null; + const documentElement = typeof document !== 'undefined' ? document.getElementById(id) : null; + const element = scopedElement || documentElement || ref; + if (!element) return fallback; + + if (typeof element.value === 'string' && element.value.length > 0) { + return element.value; + } + + if (typeof element.textContent === 'string' && element.textContent.length > 0) { + return element.textContent; + } + + return fallback; + } + + syncSubject(event) { + this.state.subject = event?.target?.value || ''; + } + + syncBody(event) { + this.state.body = event?.target?.value || ''; + } + + handleSend(event) { + event?.preventDefault?.(); + event?.stopPropagation?.(); + + const now = Date.now(); + if (now - this.lastSendAt < 500) return; + + const toUid = this.readField('phone-mail-recipient', this.toRef, this.state.toUid).trim(); + const subject = this.readField('phone-mail-subject', this.subjectRef, this.state.subject).trim() || 'No subject'; + const body = this.readField('phone-mail-body', this.bodyRef, this.state.body).trim(); + + if (!toUid || !body) { + console.warn('MailComposer: missing required email fields', { + hasRecipient: !!toUid, + hasSubject: subject !== 'No subject', + hasBody: !!body, + toUid, + subjectLength: subject.length, + bodyLength: body.length + }); + return; + } + + this.lastSendAt = now; if (typeof A3API !== 'undefined' && A3API.SendAlert) { + console.log('MailComposer: sending email', { toUid, subjectLength: subject.length, bodyLength: body.length }); A3API.SendAlert(JSON.stringify({ event: 'phone::send::email', data: { toUid, subject, body } })); + } else { + console.warn('MailComposer: A3API.SendAlert unavailable'); } globalState.setState({ @@ -33,14 +92,14 @@ class MailComposer extends Component { } renderContactOptions() { - const contacts = this.props.contacts || []; + const contacts = this.emailableContacts(this.props.contacts || []); return [ this.createElement('option', { value: '' }, 'Select recipient'), ...contacts.map((contact) => this.createElement( 'option', { value: contact.uid || contact.id }, - `${contact.name || 'Unknown'}${contact.email ? ` (${contact.email})` : ''}` + `${contact.fullName || contact.name || 'Unknown'}${contact.email ? ` (${contact.email})` : ''}` )) ]; } @@ -54,8 +113,17 @@ class MailComposer extends Component { this.createElement( 'select', { + id: 'phone-mail-recipient', + name: 'phone-mail-recipient', value: this.state.toUid, + onInput: (event) => { this.state.toUid = event.target.value; }, onChange: (event) => { this.state.toUid = event.target.value; }, + ref: (element) => { + this.toRef = element; + if (element && this.state.toUid && !element.value) { + element.value = this.state.toUid; + } + }, 'aria-label': 'Email recipient' }, ...this.renderContactOptions() @@ -64,17 +132,27 @@ class MailComposer extends Component { this.createElement('label', {}, 'Subject', this.createElement('input', { + id: 'phone-mail-subject', + name: 'phone-mail-subject', type: 'text', value: this.state.subject, - onInput: (event) => { this.state.subject = event.target.value; }, + onInput: this.syncSubject, + onChange: this.syncSubject, + onKeyUp: this.syncSubject, + ref: (element) => { this.subjectRef = element; }, placeholder: 'Subject' }) ), this.createElement('label', {}, 'Message', this.createElement('textarea', { + id: 'phone-mail-body', + name: 'phone-mail-body', value: this.state.body, - onInput: (event) => { this.state.body = event.target.value; }, + onInput: this.syncBody, + onChange: this.syncBody, + onKeyUp: this.syncBody, + ref: (element) => { this.bodyRef = element; }, placeholder: 'Write email body...', rows: 8 }) @@ -84,7 +162,8 @@ class MailComposer extends Component { { type: 'button', className: 'mail-send-button', - onClick: this.handleSend + onClick: this.handleSend, + onMouseDown: this.handleSend }, 'Send' ) diff --git a/arma/client/addons/phone/ui/_site/js/apps/mail/components/MailDetail.js b/arma/client/addons/phone/ui/_site/js/apps/mail/components/MailDetail.js index 9827165..ccaed67 100644 --- a/arma/client/addons/phone/ui/_site/js/apps/mail/components/MailDetail.js +++ b/arma/client/addons/phone/ui/_site/js/apps/mail/components/MailDetail.js @@ -30,6 +30,17 @@ class MailDetail extends Component { } } + handleDeleteEmail(emailId) { + if (!emailId) return; + + if (typeof A3API !== 'undefined' && A3API.SendAlert) { + A3API.SendAlert(JSON.stringify({ + event: 'phone::delete::email', + data: { emailId } + })); + } + } + render() { const { email } = this.props; @@ -46,7 +57,16 @@ class MailDetail extends Component { this.createElement('span', {}, `To: ${this.resolveContactName(email.to) || 'Unknown'}`), this.createElement('span', {}, this.formatEmailTime(email.timestamp)) ), - this.createElement('p', { className: 'mail-body' }, email.body || '') + this.createElement('p', { className: 'mail-body' }, email.body || ''), + this.createElement( + 'button', + { + type: 'button', + className: 'mail-delete-button', + onClick: () => this.handleDeleteEmail(email.id) + }, + 'Delete Email' + ) ); } } diff --git a/arma/client/addons/phone/ui/_site/js/apps/mail/index.js b/arma/client/addons/phone/ui/_site/js/apps/mail/index.js index 133ef0e..b4d92da 100644 --- a/arma/client/addons/phone/ui/_site/js/apps/mail/index.js +++ b/arma/client/addons/phone/ui/_site/js/apps/mail/index.js @@ -18,9 +18,17 @@ function initializeMailApp(container) { element: 'button', props: { type: 'button', - className: 'nav-action-button', + className: 'nav-button add-button', onClick: () => globalState.setState({ showEmailComposer: true, selectedEmail: null }), - 'aria-label': 'Compose email' + 'aria-label': 'Compose email', + style: { + fontSize: '24px', + padding: '0 15px', + background: 'none', + border: 'none', + color: 'var(--accent-color)', + cursor: 'pointer' + } }, content: '+' } : null diff --git a/arma/client/addons/phone/ui/_site/js/apps/messages/components/ConversationView.js b/arma/client/addons/phone/ui/_site/js/apps/messages/components/ConversationView.js index e0ecd6c..86db68a 100644 --- a/arma/client/addons/phone/ui/_site/js/apps/messages/components/ConversationView.js +++ b/arma/client/addons/phone/ui/_site/js/apps/messages/components/ConversationView.js @@ -146,6 +146,10 @@ class ConversationView extends Component { const { newMessage } = this.state; const { conversation } = this.props; + if (conversation && conversation.canMessage === false) { + return; + } + if (newMessage.trim()) { // Create new message object const newMessageObj = { @@ -229,6 +233,9 @@ class ConversationView extends Component { * @private */ renderMessageForm() { + const { conversation } = this.props; + const canMessage = !conversation || conversation.canMessage !== false; + return this.createElement( 'div', { @@ -238,9 +245,11 @@ class ConversationView extends Component { }, this.createElement('textarea', { className: 'message-input', - placeholder: 'Type a message...', + placeholder: canMessage ? 'Type a message...' : 'Replies disabled for this contact', value: this.state.newMessage, + disabled: !canMessage, onInput: (e) => { + if (!canMessage) return; this.handleInputChange(e); // Auto-grow logic if (e.target) { @@ -250,7 +259,7 @@ class ConversationView extends Component { }, onKeyDown: (e) => { // Send message on Enter key (but not Shift+Enter) - if (e.key === 'Enter' && !e.shiftKey) { + if (canMessage && e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); this.handleSendMessage(); } @@ -272,7 +281,8 @@ class ConversationView extends Component { type: 'button', className: 'send-button', onClick: this.handleSendMessage, - 'aria-label': 'Send message' + disabled: !canMessage, + 'aria-label': canMessage ? 'Send message' : 'Replies disabled' }, this.createElement('img', { src: 'data:image/svg+xml;utf8,', diff --git a/arma/client/addons/phone/ui/_site/js/apps/messages/components/MessageItem.js b/arma/client/addons/phone/ui/_site/js/apps/messages/components/MessageItem.js index 91fea41..5bdd0c8 100644 --- a/arma/client/addons/phone/ui/_site/js/apps/messages/components/MessageItem.js +++ b/arma/client/addons/phone/ui/_site/js/apps/messages/components/MessageItem.js @@ -20,6 +20,7 @@ class MessageItem extends Component { constructor(props) { super(props); this.handleClick = this.handleClick.bind(this); + this.handleDeleteClick = this.handleDeleteClick.bind(this); } /** @@ -33,6 +34,19 @@ class MessageItem extends Component { } } + /** + * Handles delete clicks without opening the conversation. + * @param {Event} event - Click event + * @private + */ + handleDeleteClick(event) { + event.stopPropagation(); + const { onDelete, message } = this.props; + if (onDelete) { + onDelete(message); + } + } + /** * Formats the timestamp into a relative time string * @param {Date} timestamp - The timestamp to format @@ -40,8 +54,12 @@ class MessageItem extends Component { * @private */ formatTime(timestamp) { + if (!timestamp) return ''; + const now = new Date(); const messageTime = new Date(timestamp); + if (Number.isNaN(messageTime.getTime())) return ''; + const diffInHours = (now - messageTime) / (1000 * 60 * 60); if (diffInHours < 1) { @@ -91,7 +109,7 @@ class MessageItem extends Component { 'span', { className: 'message-time', - 'aria-label': `Sent ${this.formatTime(message.timestamp)}`, + 'aria-label': message.timestamp ? `Sent ${this.formatTime(message.timestamp)}` : '', }, this.formatTime(message.timestamp) ) @@ -105,6 +123,8 @@ class MessageItem extends Component { * @private */ renderMessagePreview(message) { + const preview = message.hasConversation ? message.lastMessage : 'Start conversation'; + return this.createElement( 'div', { className: 'message-preview' }, @@ -114,7 +134,7 @@ class MessageItem extends Component { role: 'text', 'aria-label': 'Last message', }, - message.lastMessage + preview ), message.unread > 0 && this.createElement( @@ -136,6 +156,7 @@ class MessageItem extends Component { render() { const { message } = this.props; const initials = this.getContactInitials(message.contactName); + const canDelete = Array.isArray(message.conversation) && message.conversation.length > 0; return this.createElement( 'div', @@ -159,7 +180,22 @@ class MessageItem extends Component { }, initials ), - this.createElement('div', { className: 'message-content' }, this.renderMessageHeader(message), this.renderMessagePreview(message)) + this.createElement( + 'div', + { className: 'message-content' }, + this.renderMessageHeader(message), + this.renderMessagePreview(message) + ), + canDelete ? this.createElement( + 'button', + { + type: 'button', + className: 'message-thread-delete-button', + 'aria-label': `Delete conversation with ${message.contactName}`, + onClick: this.handleDeleteClick + }, + 'Delete' + ) : null ); } } diff --git a/arma/client/addons/phone/ui/_site/js/apps/messages/components/MessagesList.js b/arma/client/addons/phone/ui/_site/js/apps/messages/components/MessagesList.js index 2adc940..0173045 100644 --- a/arma/client/addons/phone/ui/_site/js/apps/messages/components/MessagesList.js +++ b/arma/client/addons/phone/ui/_site/js/apps/messages/components/MessagesList.js @@ -16,7 +16,7 @@ class MessagesList extends Component { constructor(props) { super(props); this.state = { - filteredMessages: props.messages || [], + filteredMessages: this.buildRows(props.messages || [], props.contacts || [], ''), searchTerm: '' }; } @@ -26,24 +26,94 @@ class MessagesList extends Component { * @param {Object} nextProps - Next props */ componentWillReceiveProps(nextProps) { - if (nextProps.messages !== this.props.messages) { + if ( + nextProps.messages !== this.props.messages || + nextProps.contacts !== this.props.contacts || + nextProps.includeContacts !== this.props.includeContacts || + nextProps.includeContactsOnSearch !== this.props.includeContactsOnSearch + ) { // Re-apply current search filter to new messages this.handleSearch(this.state.searchTerm); } } + buildRows(messages = [], contacts = [], searchTerm = '') { + const searchTermLower = searchTerm.toLowerCase(); + const includeContacts = this.props.includeContacts === true || (this.props.includeContactsOnSearch === true && searchTermLower.length > 0); + const byContactId = new Map(); + const contactByUid = new Map(); + + contacts + .filter((contact) => contact && contact.uid) + .forEach((contact) => contactByUid.set(contact.uid, contact)); + + messages.forEach((message) => { + if (!message) return; + const contactId = message.contactId || message.id; + const contact = contactByUid.get(contactId) || {}; + + byContactId.set(contactId, { + ...contact, + ...message, + id: contactId, + contactId, + contactName: message.contactName || contact.fullName || contact.name || contactId, + phone: contact.phone || message.phone || '', + email: contact.email || message.email || '', + canCall: contact.canCall !== false, + canMessage: contact.canMessage !== false, + hasConversation: Array.isArray(message.conversation) && message.conversation.length > 0 + }); + }); + + if (includeContacts) { + contacts + .filter((contact) => contact && contact.uid && contact.canMessage !== false) + .forEach((contact) => { + if (byContactId.has(contact.uid)) return; + + byContactId.set(contact.uid, { + id: contact.uid, + contactId: contact.uid, + contactName: contact.fullName || contact.name || contact.uid, + fullName: contact.fullName || contact.name || contact.uid, + name: contact.name || contact.fullName || contact.uid, + phone: contact.phone || '', + email: contact.email || '', + avatar: contact.avatar, + canCall: contact.canCall !== false, + canMessage: contact.canMessage !== false, + lastMessage: 'Start conversation', + timestamp: null, + unread: 0, + conversation: [], + hasConversation: false + }); + }); + } + + return Array.from(byContactId.values()).filter((message) => { + if (!searchTermLower) return true; + + return [ + message.contactName, + message.lastMessage, + message.contactId, + message.id, + message.phone, + message.email + ].some((value) => (value || '').toString().toLowerCase().includes(searchTermLower)); + }); + } + /** * Filter messages based on search term * @param {string} searchTerm - The search term to filter messages * @private */ handleSearch(searchTerm) { - const { messages = [] } = this.props; - const searchTermLower = searchTerm.toLowerCase(); - - const filtered = messages.filter(message => - message.contactName.toLowerCase().includes(searchTermLower) - ); + const { messages = [], contacts = [] } = this.props; + const filtered = this.buildRows(messages, contacts, searchTerm); this.setState({ filteredMessages: filtered, @@ -57,14 +127,26 @@ class MessagesList extends Component { * @returns {Array} Array of MessageItem components */ renderMessageItems() { - const { onMessageClick } = this.props; + const { onMessageClick, onMessageDelete } = this.props; const { filteredMessages } = this.state; + if (!filteredMessages.length) { + return [ + this.createElement( + 'div', + { className: 'messages-empty-state' }, + this.createElement('strong', {}, this.props.emptyTitle || 'No conversations'), + this.createElement('span', {}, this.props.emptySubtitle || 'Tap + to start a new conversation.') + ) + ]; + } + return filteredMessages.map( (message) => new MessageItem({ message, onClick: onMessageClick, + onDelete: onMessageDelete, key: message.id, }) ); @@ -88,7 +170,7 @@ class MessagesList extends Component { } }, new SearchBar({ - placeholder: 'Search by contact name...', + placeholder: this.props.searchPlaceholder || 'Search by contact name...', onSearch: this.handleSearch.bind(this), value: searchTerm }), @@ -108,4 +190,4 @@ class MessagesList extends Component { ) ); } -} \ No newline at end of file +} diff --git a/arma/client/addons/phone/ui/_site/js/apps/messages/index.js b/arma/client/addons/phone/ui/_site/js/apps/messages/index.js index f98adee..1523cd2 100644 --- a/arma/client/addons/phone/ui/_site/js/apps/messages/index.js +++ b/arma/client/addons/phone/ui/_site/js/apps/messages/index.js @@ -1,61 +1,119 @@ /** - * @fileoverview Main entry point for the Messages application - * - * This module initializes the Messages app UI, including: - * - Rendering the navigation bar with the app title or contact name - * - Displaying either the messages list or a conversation view - * - Handling navigation between the list and conversation - * - * The navigation bar shows "Messages" on the list, and the contact's name with a back button in a conversation. + * @fileoverview Main entry point for the Messages application. */ -// Initialize the messages app function initializeMessagesApp(container) { - // Get current messages and selected conversation from global state - const { messages, selectedConversation } = globalState.getState(); + const { messages = [], contacts = [], selectedConversation, showMessageContactPicker } = globalState.getState(); const appContainer = document.createElement('div'); + const openConversation = (conversation) => { + if (!conversation) return; + + const contactId = conversation.contactId || conversation.uid || conversation.id; + const { rawMessages = [], currentUid = window.__playerUid } = globalState.getState(); + const selectedRawMessages = rawMessages.filter((message) => + message && + ( + (message.from === currentUid && message.to === contactId) || + (message.from === contactId && message.to === currentUid) + ) + ); + + globalState.setState({ + selectedConversation: { + ...conversation, + id: contactId, + contactId, + contactName: conversation.contactName || conversation.fullName || conversation.name || contactId, + conversation: conversation.conversation || [] + }, + selectedConversationRaw: { + otherUid: contactId, + messages: selectedRawMessages + }, + showMessageContactPicker: false + }); + }; + + const deleteConversationMessages = (conversation) => { + const messageIds = ((conversation && conversation.conversation) || []) + .map((message) => message && message.id) + .filter(Boolean); + + if (!messageIds.length) return; + + if (typeof A3API !== 'undefined' && A3API.SendAlert) { + messageIds.forEach((messageId) => { + A3API.SendAlert(JSON.stringify({ + event: 'phone::delete::message', + data: { messageId } + })); + }); + } + }; + appContainer.className = 'app-container'; appContainer.setAttribute('role', 'main'); appContainer.setAttribute('aria-label', 'Messages'); - /** - * Navigation bar - * - Shows "Messages" on the list - * - Shows contact name and back button in a conversation - */ const navBar = new NavigationBar({ - title: selectedConversation ? selectedConversation.contactName : 'Messages', - showBackButton: !!selectedConversation + title: selectedConversation ? selectedConversation.contactName : (showMessageContactPicker ? 'New Conversation' : 'Messages'), + showBackButton: !!selectedConversation || showMessageContactPicker, + rightButton: selectedConversation && selectedConversation.conversation && selectedConversation.conversation.length ? { + element: 'button', + props: { + type: 'button', + className: 'message-nav-delete-button', + onClick: () => { + deleteConversationMessages(selectedConversation); + globalState.setState({ selectedConversation: null, selectedConversationRaw: null }); + } + }, + content: 'Delete' + } : (!selectedConversation && !showMessageContactPicker) ? { + element: 'button', + props: { + type: 'button', + className: 'nav-button add-button', + onClick: () => globalState.setState({ showMessageContactPicker: true }), + 'aria-label': 'Start conversation', + style: { + fontSize: '24px', + padding: '0 15px', + background: 'none', + border: 'none', + color: 'var(--accent-color)', + cursor: 'pointer' + } + }, + content: '+' + } : null }); navBar.mount(appContainer); - // Content container for either the list or conversation const contentContainer = document.createElement('div'); contentContainer.className = 'content'; appContainer.appendChild(contentContainer); - /** - * Render either the conversation view or the messages list - * - If a conversation is selected, show ConversationView - * - Otherwise, show MessagesList - */ if (selectedConversation) { const conversationView = new ConversationView({ conversation: selectedConversation }); conversationView.mount(contentContainer); } else { const messagesList = new MessagesList({ messages, - onMessageClick: (message) => { - globalState.setState({ selectedConversation: message }); - } + contacts, + includeContacts: showMessageContactPicker, + includeContactsOnSearch: true, + searchPlaceholder: 'Search contacts or conversations...', + emptyTitle: showMessageContactPicker ? 'No contacts found' : 'No conversations', + emptySubtitle: showMessageContactPicker ? 'Try another search.' : 'Search for a contact to start texting.', + onMessageClick: openConversation, + onMessageDelete: deleteConversationMessages }); messagesList.mount(contentContainer); } - // Mount the app container container.appendChild(appContainer); } -// Make initialization function globally available -window.initializeMessagesApp = initializeMessagesApp; \ No newline at end of file +window.initializeMessagesApp = initializeMessagesApp; diff --git a/arma/client/addons/phone/ui/_site/js/apps/phone/components/Dialpad.js b/arma/client/addons/phone/ui/_site/js/apps/phone/components/Dialpad.js index 6477519..6116d0f 100644 --- a/arma/client/addons/phone/ui/_site/js/apps/phone/components/Dialpad.js +++ b/arma/client/addons/phone/ui/_site/js/apps/phone/components/Dialpad.js @@ -6,6 +6,8 @@ */ class Dialpad extends Component { + static fieldCommanderPhoneNumber = '0160000000'; + static assetPath(...parts) { return PhoneMedia.base64Path('images', ...parts); } @@ -174,7 +176,11 @@ class Dialpad extends Component { * @description Initiates a phone call and starts the call timer */ handleCall() { - if (this.state.phoneNumber && !this.state.isCallActive) { + if ( + this.state.phoneNumber && + !this.state.isCallActive && + this.cleanPhoneNumber(this.state.phoneNumber) !== Dialpad.fieldCommanderPhoneNumber + ) { this.setState({ isCallActive: true, callDuration: 0, @@ -304,7 +310,7 @@ class Dialpad extends Component { 'aria-label': 'Make call', }; - if (isPhoneNumberEmpty) { + if (isPhoneNumberEmpty || this.cleanPhoneNumber(phoneNumber) === Dialpad.fieldCommanderPhoneNumber) { callButtonProps.disabled = true; } diff --git a/arma/client/addons/phone/ui/_site/js/components/HomeIndicator.js b/arma/client/addons/phone/ui/_site/js/components/HomeIndicator.js index 26d9b43..fee13b8 100644 --- a/arma/client/addons/phone/ui/_site/js/components/HomeIndicator.js +++ b/arma/client/addons/phone/ui/_site/js/components/HomeIndicator.js @@ -31,7 +31,9 @@ class HomeIndicator extends Component { globalState.setState({ currentApp: 'home', selectedConversation: null, + selectedConversationRaw: null, selectedContact: null, + showMessageContactPicker: false, showModal: false, }); } diff --git a/arma/client/addons/phone/ui/_site/js/components/Modal.js b/arma/client/addons/phone/ui/_site/js/components/Modal.js index 71b15a9..5b26b6c 100644 --- a/arma/client/addons/phone/ui/_site/js/components/Modal.js +++ b/arma/client/addons/phone/ui/_site/js/components/Modal.js @@ -66,11 +66,15 @@ class Modal extends Component { * @returns {HTMLElement} The rendered actions element * @private */ - renderActions(onClose, onConfirm, confirmText = 'Call', cancelText = 'Cancel') { + renderActions(onClose, onConfirm, confirmText = 'Call', cancelText = 'Cancel', extraActions = [], hideCancel = false, hideConfirm = false) { + if (hideCancel && hideConfirm && !extraActions.length) { + return null; + } + return this.createElement( 'div', { className: 'modal-actions' }, - this.createElement( + hideCancel ? null : this.createElement( 'button', { className: 'button secondary', @@ -80,7 +84,18 @@ class Modal extends Component { }, cancelText ), - this.createElement( + ...extraActions.map((action) => this.createElement( + 'button', + { + className: action.className || 'button secondary', + onClick: () => action.onClick?.(), + type: 'button', + disabled: action.disabled === true, + 'aria-label': action.ariaLabel || action.text, + }, + action.text + )), + hideConfirm ? null : this.createElement( 'button', { className: 'button', @@ -98,7 +113,7 @@ class Modal extends Component { * @returns {HTMLElement} The rendered modal element */ render() { - const { show, title, children = [], onClose, onConfirm, confirmText, cancelText } = this.props; + const { show, title, children = [], onClose, onConfirm, confirmText, cancelText, extraActions = [], hideCancel = false, hideConfirm = false } = this.props; if (!show) { return this.createElement('div', { @@ -147,7 +162,7 @@ class Modal extends Component { }, ...childElements.filter((child) => child != null) ), - this.renderActions(onClose, onConfirm, confirmText, cancelText) + this.renderActions(onClose, onConfirm, confirmText, cancelText, extraActions, hideCancel, hideConfirm) ) ); } diff --git a/arma/client/addons/phone/ui/_site/js/components/NavigationBar.js b/arma/client/addons/phone/ui/_site/js/components/NavigationBar.js index eadd959..725f44e 100644 --- a/arma/client/addons/phone/ui/_site/js/components/NavigationBar.js +++ b/arma/client/addons/phone/ui/_site/js/components/NavigationBar.js @@ -32,10 +32,18 @@ class NavigationBar extends Component { if (currentState.selectedConversation) { globalState.setState({ selectedConversation: null, + selectedConversationRaw: null, }); return; // Exit early, don't execute the rest } + if (currentState.showMessageContactPicker) { + globalState.setState({ + showMessageContactPicker: false, + }); + return; + } + if (currentState.selectedEmail || currentState.showEmailComposer) { globalState.setState({ selectedEmail: null, @@ -58,7 +66,9 @@ class NavigationBar extends Component { currentApp: 'home', previousApp: null, selectedConversation: null, + selectedConversationRaw: null, selectedContact: null, + showMessageContactPicker: false, showModal: false, }); } diff --git a/arma/client/addons/phone/ui/_site/js/core/Component.js b/arma/client/addons/phone/ui/_site/js/core/Component.js index 3d8dcc9..03142a0 100644 --- a/arma/client/addons/phone/ui/_site/js/core/Component.js +++ b/arma/client/addons/phone/ui/_site/js/core/Component.js @@ -195,8 +195,14 @@ class Component { Object.assign(element.style, value); } else if (key === 'ref' && typeof value === 'function') { value(element); - } else { + } else if (typeof value === 'boolean') { + if (value) { + element.setAttribute(key, key); + } + } else if (value !== null && value !== undefined) { element.setAttribute(key, value); + } else { + return; } }); diff --git a/arma/client/addons/phone/ui/_site/js/core/StateManager.js b/arma/client/addons/phone/ui/_site/js/core/StateManager.js index d03b3d3..0d2d616 100644 --- a/arma/client/addons/phone/ui/_site/js/core/StateManager.js +++ b/arma/client/addons/phone/ui/_site/js/core/StateManager.js @@ -29,6 +29,7 @@ const initialAppState = { // UI state selectedContact: null, selectedConversation: null, + showMessageContactPicker: false, newMessage: '', currentUid: null, diff --git a/arma/client/addons/phone/ui/_site/js/global.js b/arma/client/addons/phone/ui/_site/js/global.js index 8346fcb..b80f2a3 100644 --- a/arma/client/addons/phone/ui/_site/js/global.js +++ b/arma/client/addons/phone/ui/_site/js/global.js @@ -110,10 +110,15 @@ function normalizeContacts(contacts) { id: uid || contact.phone || name, uid, name, + fullName: contact.fullName || name, phone: contact.phone || '', email: contact.email || '', avatar: contact.avatar || getInitials(name), - online: Boolean(contact.online) + online: Boolean(contact.online), + system: Boolean(contact.system), + canCall: contact.canCall !== false, + canMessage: contact.canMessage !== false, + canEmail: contact.canEmail !== false }; }); } @@ -260,6 +265,30 @@ function updateMessageRead(messageId) { } } +/** + * Remove a message from the local phone state after server delete succeeds + * @param {string} messageId + */ +function updateMessageDeleted(messageId) { + try { + const { rawMessages = [], selectedConversationRaw = null } = globalState.getState(); + const nextRawMessages = rawMessages.filter(message => message && message.id !== messageId); + const statePatch = { rawMessages: nextRawMessages }; + + if (selectedConversationRaw && Array.isArray(selectedConversationRaw.messages)) { + statePatch.selectedConversationRaw = { + ...selectedConversationRaw, + messages: selectedConversationRaw.messages.filter(message => message && message.id !== messageId) + }; + } + + globalState.setState(statePatch); + rebuildMessageSummariesFromRaw(); + } catch (e) { + console.error('Error in updateMessageDeleted:', e); + } +} + // Transform raw message payloads into UI-friendly summary and thread structures function rebuildMessageSummariesFromRaw() { try { @@ -316,6 +345,7 @@ function rebuildMessageSummariesFromRaw() { id: otherUid, contactId: otherUid, contactName: contact.name || otherUid, + canMessage: contact.canMessage !== false, lastMessage: (last && (last.message || last.text)) || '', timestamp: toJsDate(last && last.timestamp), unread: arr.filter(m => m.read === false && m.to === currentUid).length || 0, @@ -336,6 +366,7 @@ function rebuildMessageSummariesFromRaw() { id: selectedConversationRaw.otherUid, contactId: selectedConversationRaw.otherUid, contactName: contact.name, + canMessage: contact.canMessage !== false, lastMessage: thread.length ? (thread[thread.length - 1].message || thread[thread.length - 1].text) : '', timestamp: thread.length ? toJsDate(thread[thread.length - 1].timestamp) : new Date(), unread: thread.filter(m => m.read === false && m.to === currentUid).length || 0, @@ -458,6 +489,22 @@ function updateEmailRead(emailId) { } } +/** + * Remove an email from the local phone state after server delete succeeds + * @param {string} emailId + */ +function updateEmailDeleted(emailId) { + try { + const { emails = [], selectedEmail = null } = globalState.getState(); + globalState.setState({ + emails: emails.filter(email => email && email.id !== emailId), + selectedEmail: selectedEmail && selectedEmail.id === emailId ? null : selectedEmail + }); + } catch (e) { + console.error('Error in updateEmailDeleted:', e); + } +} + // Debounce variables for notes requests let lastNotesRequest = 0; const NOTES_REQUEST_COOLDOWN = 1000; // 1 second cooldown @@ -853,12 +900,14 @@ window.updateMessageThread = updateMessageThread; window.updateMessageSent = updateMessageSent; window.updateMessageReceived = updateMessageReceived; window.updateMessageRead = updateMessageRead; +window.updateMessageDeleted = updateMessageDeleted; // Emails window.requestEmails = requestEmails; window.updateEmails = updateEmails; window.updateEmailSent = updateEmailSent; window.updateEmailReceived = updateEmailReceived; window.updateEmailRead = updateEmailRead; +window.updateEmailDeleted = updateEmailDeleted; window.requestNotes = requestNotes; window.loadNotes = loadNotes; window.saveNote = saveNote; diff --git a/arma/client/addons/phone/ui/_site/styles/components/mail.css b/arma/client/addons/phone/ui/_site/styles/components/mail.css index f5ca45e..cb21cfa 100644 --- a/arma/client/addons/phone/ui/_site/styles/components/mail.css +++ b/arma/client/addons/phone/ui/_site/styles/components/mail.css @@ -95,8 +95,7 @@ resize: none; } -.mail-send-button, -.nav-action-button { +.mail-send-button { border: 0; border-radius: 12px; background: var(--accent-color); @@ -109,12 +108,6 @@ padding: 12px 14px; } -.nav-action-button { - min-width: 32px; - min-height: 32px; - font-size: 20px; -} - .mail-detail { padding: 16px; overflow-y: auto; @@ -140,3 +133,20 @@ line-height: 1.45; margin: 0; } + +.mail-delete-button { + margin-top: 18px; + width: 100%; + border: 1px solid rgba(255, 59, 48, 0.55); + border-radius: 12px; + background: rgba(255, 59, 48, 0.14); + color: #ff6b61; + cursor: pointer; + font: inherit; + font-weight: 700; + padding: 11px 14px; +} + +.mail-delete-button:hover { + background: rgba(255, 59, 48, 0.22); +} diff --git a/arma/client/addons/phone/ui/_site/styles/components/messages.css b/arma/client/addons/phone/ui/_site/styles/components/messages.css index 769e38d..6af659c 100644 --- a/arma/client/addons/phone/ui/_site/styles/components/messages.css +++ b/arma/client/addons/phone/ui/_site/styles/components/messages.css @@ -32,6 +32,7 @@ .message-content { flex: 1; + min-width: 0; .message-header { display: flex; @@ -79,9 +80,58 @@ } } } + + .message-thread-delete-button { + border: 1px solid rgba(255, 59, 48, 0.55); + border-radius: 10px; + background: rgba(255, 59, 48, 0.14); + color: #ff6b61; + cursor: pointer; + flex-shrink: 0; + font-size: 12px; + font-weight: 700; + margin-left: 10px; + padding: 7px 9px; + } + + .message-thread-delete-button:hover { + background: rgba(255, 59, 48, 0.22); + } } } +.message-nav-delete-button { + border: 0; + border-radius: 10px; + background: rgba(255, 59, 48, 0.18); + color: #ff6b61; + cursor: pointer; + font: inherit; + font-size: 12px; + font-weight: 700; + padding: 7px 10px; +} + +.message-nav-delete-button:hover { + background: rgba(255, 59, 48, 0.28); +} + +.messages-empty-state { + align-items: center; + color: var(--text-secondary); + display: flex; + flex-direction: column; + gap: 6px; + justify-content: center; + min-height: 190px; + text-align: center; +} + +.messages-empty-state strong { + color: var(--text-primary); + font-size: 16px; +} + /* Conversation View */ .conversation-view { height: 100%; @@ -320,4 +370,4 @@ } } } -} \ No newline at end of file +} diff --git a/arma/server/addons/garage/functions/fnc_initGarageStore.sqf b/arma/server/addons/garage/functions/fnc_initGarageStore.sqf index 6e35c3c..da7b0bb 100644 --- a/arma/server/addons/garage/functions/fnc_initGarageStore.sqf +++ b/arma/server/addons/garage/functions/fnc_initGarageStore.sqf @@ -60,9 +60,6 @@ GVAR(GarageBaseStore) = compileFinal createHashMapFromArray [ if (isNull _player) exitWith { createHashMap }; private _garage = _self call ["loadHotGarage", [_uid, true]]; - if (_garage isEqualTo createHashMap) then { - ["ERROR", format ["Failed to initialize garage for %1! Using fallback garage.", _uid]] call EFUNC(common,log); - }; [CRPC(garage,responseInitGarage), [_garage], _player] call CFUNC(targetEvent); _garage diff --git a/arma/server/addons/locker/functions/fnc_initLockerStore.sqf b/arma/server/addons/locker/functions/fnc_initLockerStore.sqf index a98e8e7..b76bde4 100644 --- a/arma/server/addons/locker/functions/fnc_initLockerStore.sqf +++ b/arma/server/addons/locker/functions/fnc_initLockerStore.sqf @@ -60,9 +60,6 @@ GVAR(LockerBaseStore) = compileFinal createHashMapFromArray [ if (isNull _player) exitWith { createHashMap }; private _locker = _self call ["loadHotLocker", [_uid, true]]; - if (_locker isEqualTo createHashMap) then { - ["ERROR", format ["Failed to initialize locker for %1! Using fallback locker.", _uid]] call EFUNC(common,log); - }; [CRPC(locker,responseInitLocker), [_locker], _player] call CFUNC(targetEvent); _locker diff --git a/arma/server/addons/phone/XEH_preInit.sqf b/arma/server/addons/phone/XEH_preInit.sqf index 62bdfdb..e25b713 100644 --- a/arma/server/addons/phone/XEH_preInit.sqf +++ b/arma/server/addons/phone/XEH_preInit.sqf @@ -96,7 +96,7 @@ if (isNil QGVAR(PhoneStore)) then { [] call FUNC(initPhoneStore); }; }; private _recipient = [_toUid] call EFUNC(common,getPlayer); - if (_success && { !isNull _recipient }) then { + if (_success && { _toUid isNotEqualTo _fromUid } && { !isNull _recipient }) then { ["forge_client_phone_responseMessageReceived", [_messageObj], _recipient] call CFUNC(targetEvent); }; }] call CFUNC(addEventHandler); @@ -135,11 +135,24 @@ if (isNil QGVAR(PhoneStore)) then { [] call FUNC(initPhoneStore); }; if (!isNull _player) then { ["forge_client_phone_responseMarkMessageRead", [_result, _messageId], _player] call CFUNC(targetEvent); }; }] call CFUNC(addEventHandler); +[QGVAR(requestDeleteMessage), { + params [["_uid", "", [""]], ["_messageId", "", [""]], ["_player", objNull, [objNull]]]; + + if (_uid isEqualTo "" || _messageId isEqualTo "") exitWith { + diag_log "[FORGE:Server:Phone] Invalid parameters for requestDeleteMessage"; + }; + + private _result = GVAR(PhoneStore) call ["deleteMessage", [_uid, _messageId]]; + + if (!isNull _player) then { ["forge_client_phone_responseDeleteMessage", [_result, _messageId], _player] call CFUNC(targetEvent); }; +}] call CFUNC(addEventHandler); + // Email Events [QGVAR(requestSendEmail), { params [["_fromUid", "", [""]], ["_toUid", "", [""]], ["_subject", "", [""]], ["_body", "", [""]], ["_player", objNull, [objNull]]]; + if (_subject isEqualTo "") then { _subject = "No subject"; }; - if (_fromUid isEqualTo "" || _toUid isEqualTo "" || _subject isEqualTo "" || _body isEqualTo "") exitWith { + if (_fromUid isEqualTo "" || _toUid isEqualTo "" || _body isEqualTo "") exitWith { diag_log "[FORGE:Server:Phone] Invalid parameters for requestSendEmail"; }; @@ -154,7 +167,7 @@ if (isNil QGVAR(PhoneStore)) then { [] call FUNC(initPhoneStore); }; }; private _recipient = [_toUid] call EFUNC(common,getPlayer); - if (_success && { !isNull _recipient }) then { + if (_success && { _toUid isNotEqualTo _fromUid } && { !isNull _recipient }) then { ["forge_client_phone_responseEmailReceived", [_emailObj], _recipient] call CFUNC(targetEvent); }; }] call CFUNC(addEventHandler); @@ -181,6 +194,18 @@ if (isNil QGVAR(PhoneStore)) then { [] call FUNC(initPhoneStore); }; if (!isNull _player) then { ["forge_client_phone_responseMarkEmailRead", [_result, _emailId], _player] call CFUNC(targetEvent); }; }] call CFUNC(addEventHandler); +[QGVAR(requestDeleteEmail), { + params [["_uid", "", [""]], ["_emailId", "", [""]], ["_player", objNull, [objNull]]]; + + if (_uid isEqualTo "" || _emailId isEqualTo "") exitWith { + diag_log "[FORGE:Server:Phone] Invalid parameters for requestDeleteEmail"; + }; + + private _result = GVAR(PhoneStore) call ["deleteEmail", [_uid, _emailId]]; + + if (!isNull _player) then { ["forge_client_phone_responseDeleteEmail", [_result, _emailId], _player] call CFUNC(targetEvent); }; +}] call CFUNC(addEventHandler); + // Cleanup Event [QGVAR(requestRemovePhone), { params [["_uid", "", [""]], ["_player", objNull, [objNull]]]; diff --git a/arma/server/addons/phone/functions/fnc_initContactStore.sqf b/arma/server/addons/phone/functions/fnc_initContactStore.sqf index c450a0a..3aa0588 100644 --- a/arma/server/addons/phone/functions/fnc_initContactStore.sqf +++ b/arma/server/addons/phone/functions/fnc_initContactStore.sqf @@ -48,6 +48,9 @@ GVAR(ContactStore) = createHashMapObject [[ false }; + private _fieldCommanderUid = "field_commander"; + _self call ["callPhoneBool", ["phone:contacts:add", [_uid, _uid]]]; + _self call ["callPhoneBool", ["phone:contacts:add", [_uid, _fieldCommanderUid]]]; _self call ["refreshContacts", [_uid]]; true }], @@ -59,11 +62,6 @@ GVAR(ContactStore) = createHashMapObject [[ false }; - if (_uid isEqualTo _contactUid) exitWith { - diag_log "[FORGE:Server:Phone:Contact] Cannot add self as contact"; - false - }; - private _added = _self call ["callPhoneBool", ["phone:contacts:add", [_uid, _contactUid]]]; if (_added) then { _self call ["refreshContacts", [_uid]]; }; _added @@ -104,7 +102,6 @@ GVAR(ContactStore) = createHashMapObject [[ private _matchedUid = ""; { private _candidateUid = _x; - if (_candidateUid isEqualTo _requesterUid) then { continue; }; private _actorValue = EGVAR(actor,ActorStore) call ["getFieldOrDefault", [_candidateUid, _field, ""]]; if (_actorValue isEqualType "" && { toLowerANSI _actorValue isEqualTo _normalizedValue }) exitWith { @@ -151,8 +148,31 @@ GVAR(ContactStore) = createHashMapObject [[ }; private _contactObjects = []; + private _fieldCommanderUid = "field_commander"; + private _fieldCommanderContact = createHashMapFromArray [ + ["uid", _fieldCommanderUid], + ["name", "Field Cmdr"], + ["fullName", "Field Commander"], + ["phone", "0160000000"], + ["email", "field_cmdr@spearnet.mil"], + ["online", false], + ["system", true], + ["canCall", false], + ["canMessage", false], + ["canEmail", false] + ]; + private _contactUids = _self call ["getContacts", [_uid]]; + if !(_fieldCommanderUid in _contactUids) then { + _contactUids pushBack _fieldCommanderUid; + }; + { private _contactUid = _x; + if (_contactUid isEqualTo _fieldCommanderUid) then { + _contactObjects pushBack _fieldCommanderContact; + continue; + }; + private _contactData = EGVAR(actor,ActorStore) call ["load", [_contactUid]]; if (_contactData isNotEqualTo createHashMap) then { @@ -165,12 +185,17 @@ GVAR(ContactStore) = createHashMapObject [[ _contactObjects pushBack createHashMapFromArray [ ["uid", _contactUid], ["name", _name], + ["fullName", _name], ["phone", _contactData getOrDefault ["phone_number", ""]], ["email", _contactData getOrDefault ["email", ""]], - ["online", _isOnline] + ["online", _isOnline], + ["system", false], + ["canCall", true], + ["canMessage", true], + ["canEmail", true] ]; }; - } forEach (_self call ["getContacts", [_uid]]); + } forEach _contactUids; private _player = [_uid] call EFUNC(common,getPlayer); if (!isNull _player) then { diff --git a/arma/server/addons/phone/functions/fnc_initEmailStore.sqf b/arma/server/addons/phone/functions/fnc_initEmailStore.sqf index e7c1775..324cf1c 100644 --- a/arma/server/addons/phone/functions/fnc_initEmailStore.sqf +++ b/arma/server/addons/phone/functions/fnc_initEmailStore.sqf @@ -65,8 +65,9 @@ GVAR(EmailStore) = createHashMapObject [[ }], ["sendEmail", { params [["_fromUid", "", [""]], ["_toUid", "", [""]], ["_subject", "", [""]], ["_body", "", [""]]]; + if (_subject isEqualTo "") then { _subject = "No subject"; }; - if (_fromUid isEqualTo "" || { _toUid isEqualTo "" } || { _subject isEqualTo "" } || { _body isEqualTo "" }) exitWith { + if (_fromUid isEqualTo "" || { _toUid isEqualTo "" } || { _body isEqualTo "" }) exitWith { diag_log "[FORGE:Server:Phone:Email] Invalid parameters provided to sendEmail"; false }; @@ -84,7 +85,9 @@ GVAR(EmailStore) = createHashMapObject [[ _self call ["callPhoneBool", ["phone:emails:mark_read", [_uid, _emailId]]] }], ["deleteEmail", { - false + params [["_uid", "", [""]], ["_emailId", "", [""]]]; + if (_uid isEqualTo "" || { _emailId isEqualTo "" }) exitWith { false }; + _self call ["callPhoneBool", ["phone:emails:delete", [_uid, _emailId]]] }], ["remove", { params [["_uid", "", [""]]]; diff --git a/arma/server/addons/phone/functions/fnc_initMessageStore.sqf b/arma/server/addons/phone/functions/fnc_initMessageStore.sqf index e7b4fbc..a0f0e9c 100644 --- a/arma/server/addons/phone/functions/fnc_initMessageStore.sqf +++ b/arma/server/addons/phone/functions/fnc_initMessageStore.sqf @@ -83,6 +83,11 @@ GVAR(MessageStore) = createHashMapObject [[ if (_uid isEqualTo "" || { _messageId isEqualTo "" }) exitWith { false }; _self call ["callPhoneBool", ["phone:messages:mark_read", [_uid, _messageId]]] }], + ["deleteMessage", { + params [["_uid", "", [""]], ["_messageId", "", [""]]]; + if (_uid isEqualTo "" || { _messageId isEqualTo "" }) exitWith { false }; + _self call ["callPhoneBool", ["phone:messages:delete", [_uid, _messageId]]] + }], ["getMessages", { params [["_uid", "", [""]]]; if (_uid isEqualTo "") exitWith { [] }; diff --git a/arma/server/addons/phone/functions/fnc_initPhoneStore.sqf b/arma/server/addons/phone/functions/fnc_initPhoneStore.sqf index 35fccd5..f680d54 100644 --- a/arma/server/addons/phone/functions/fnc_initPhoneStore.sqf +++ b/arma/server/addons/phone/functions/fnc_initPhoneStore.sqf @@ -114,6 +114,10 @@ GVAR(PhoneStore) = createHashMapObject [[ params [["_uid", "", [""]], ["_messageId", "", [""]]]; GVAR(MessageStore) call ["markMessageRead", [_uid, _messageId]] }], + ["deleteMessage", { + params [["_uid", "", [""]], ["_messageId", "", [""]]]; + GVAR(MessageStore) call ["deleteMessage", [_uid, _messageId]] + }], ["syncMessageIndices", { params [["_uid", "", [""]]]; GVAR(MessageStore) call ["syncMessageIndices", [_uid]] @@ -130,6 +134,10 @@ GVAR(PhoneStore) = createHashMapObject [[ params [["_uid", "", [""]], ["_emailId", "", [""]]]; GVAR(EmailStore) call ["markEmailRead", [_uid, _emailId]] }], + ["deleteEmail", { + params [["_uid", "", [""]], ["_emailId", "", [""]]]; + GVAR(EmailStore) call ["deleteEmail", [_uid, _emailId]] + }], ["remove", { params [["_uid", "", [""]]]; diff --git a/arma/server/extension/Cargo.toml b/arma/server/extension/Cargo.toml index 2c7e903..e4410d1 100644 --- a/arma/server/extension/Cargo.toml +++ b/arma/server/extension/Cargo.toml @@ -10,7 +10,7 @@ crate-type = ["cdylib"] [dependencies] arma-rs = { workspace = true } base64 = "0.22.1" -bb8-redis = "0.25.0-rc.1" +bb8-redis = "0.26.0" chrono = { workspace = true } forge-icom = { path = "../../../bin/icom" } forge-models = { path = "../../../lib/models", features = ["actor"] } @@ -20,6 +20,7 @@ forge-shared = { path = "../../../lib/shared" } redis = { workspace = true } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } +surrealdb = { version = "2", default-features = false, features = ["protocol-http", "rustls"] } tokio = { workspace = true } -toml = "0.9.8" +toml = "1.1.2" uuid = { workspace = true } diff --git a/arma/server/extension/config.example.toml b/arma/server/extension/config.example.toml index f556f92..f2df52b 100644 --- a/arma/server/extension/config.example.toml +++ b/arma/server/extension/config.example.toml @@ -2,6 +2,12 @@ # Copy this file to config.toml and modify as needed # Place this file in the same directory as your crate_server_x64.dll +[storage] +# Redis remains the default while modules are migrated incrementally. +# Current SurrealDB-backed durable repositories: +# actor, bank, garage, locker, owned garage, owned locker, org, phone. +backend = "redis" # "redis" or "surreal" + [redis] # Redis server connection settings host = "127.0.0.1" @@ -20,6 +26,18 @@ connect_timeout_ms = 2000 # Pool connect timeout in milliseconds pool_get_timeout_ms = 2000 # Pool checkout timeout in milliseconds command_timeout_ms = 2000 # Redis command timeout in milliseconds +[surreal] +# SurrealDB HTTP endpoint. Use "127.0.0.1:8000" for a local SurrealDB server. +endpoint = "127.0.0.1:8000" +namespace = "forge" +database = "main" + +# Optional authentication +username = "root" +password = "root" + +connect_timeout_ms = 5000 + # Example configurations for different environments: # Development (local Redis) diff --git a/arma/server/extension/src/actor.rs b/arma/server/extension/src/actor.rs index 7bbdf68..7565c59 100644 --- a/arma/server/extension/src/actor.rs +++ b/arma/server/extension/src/actor.rs @@ -4,38 +4,31 @@ //! Handles SQF command mapping and parameter validation. use arma_rs::{CallContext, Group}; -use forge_repositories::{InMemoryActorHotRepository, RedisActorRepository}; +use forge_repositories::InMemoryActorHotRepository; use forge_services::{ActorHotStateService, ActorService}; use std::sync::LazyLock; -use crate::adapters::ExtensionRedisClient; use crate::enqueue_persistence_task; use crate::helpers::resolve_uid; use crate::log::log; +use crate::storage::ActorStorageRepository; /// Global actor service instance. /// /// Lazily initialized singleton combining Redis adapter, repository, and service layers. -static ACTOR_SERVICE: LazyLock>> = - LazyLock::new(|| { - let redis_client = ExtensionRedisClient::new(); - let repository = RedisActorRepository::new(redis_client); - ActorService::new(repository) - }); +static ACTOR_SERVICE: LazyLock> = + LazyLock::new(|| ActorService::new(ActorStorageRepository::configured())); static HOT_ACTOR_SERVICE: LazyLock< - ActorHotStateService, InMemoryActorHotRepository>, + ActorHotStateService, > = LazyLock::new(|| { - let redis_client = ExtensionRedisClient::new(); - let repository = RedisActorRepository::new(redis_client); + let repository = ActorStorageRepository::configured(); let hot_repository = InMemoryActorHotRepository::new(); ActorHotStateService::new(repository, hot_repository) }); #[allow(dead_code)] -pub(crate) fn hot_service() -> &'static ActorHotStateService< - RedisActorRepository, - InMemoryActorHotRepository, -> { +pub(crate) fn hot_service() +-> &'static ActorHotStateService { &HOT_ACTOR_SERVICE } diff --git a/arma/server/extension/src/bank.rs b/arma/server/extension/src/bank.rs index 64f6492..0a6f426 100644 --- a/arma/server/extension/src/bank.rs +++ b/arma/server/extension/src/bank.rs @@ -8,37 +8,30 @@ use forge_models::{ BankCheckoutContext, BankMutationResult, BankOperationContext, BankPinContext, BankTransferContext, BankTransferResult, }; -use forge_repositories::{InMemoryBankHotRepository, RedisBankRepository}; +use forge_repositories::InMemoryBankHotRepository; use forge_services::{BankHotStateService, BankService}; use std::sync::LazyLock; -use crate::adapters::ExtensionRedisClient; use crate::enqueue_persistence_task; use crate::helpers::resolve_uid; use crate::log::log; +use crate::storage::BankStorageRepository; /// Global bank service instance. /// /// Lazily initialized singleton combining Redis adapter, repository, and service layers. -static BANK_SERVICE: LazyLock>> = - LazyLock::new(|| { - let redis_client = ExtensionRedisClient::new(); - let repository = RedisBankRepository::new(redis_client); - BankService::new(repository) - }); +static BANK_SERVICE: LazyLock> = + LazyLock::new(|| BankService::new(BankStorageRepository::configured())); static HOT_BANK_SERVICE: LazyLock< - BankHotStateService, InMemoryBankHotRepository>, + BankHotStateService, > = LazyLock::new(|| { - let redis_client = ExtensionRedisClient::new(); - let repository = RedisBankRepository::new(redis_client); + let repository = BankStorageRepository::configured(); let hot_repository = InMemoryBankHotRepository::new(); BankHotStateService::new(repository, hot_repository) }); -pub(crate) fn hot_service() -> &'static BankHotStateService< - RedisBankRepository, - InMemoryBankHotRepository, -> { +pub(crate) fn hot_service() +-> &'static BankHotStateService { &HOT_BANK_SERVICE } diff --git a/arma/server/extension/src/garage.rs b/arma/server/extension/src/garage.rs index a5e8a67..bdcea2d 100644 --- a/arma/server/extension/src/garage.rs +++ b/arma/server/extension/src/garage.rs @@ -4,37 +4,30 @@ use arma_rs::{CallContext, Group}; use forge_models::Vehicle; -use forge_repositories::{InMemoryGarageHotRepository, RedisGarageRepository}; +use forge_repositories::InMemoryGarageHotRepository; use forge_services::{GarageHotStateService, GarageService}; use std::collections::HashMap; use std::sync::LazyLock; -use crate::adapters::ExtensionRedisClient; use crate::enqueue_persistence_task; use crate::helpers::resolve_uid; use crate::log::log; +use crate::storage::GarageStorageRepository; /// Global garage service instance. -static GARAGE_SERVICE: LazyLock>> = - LazyLock::new(|| { - let redis_client = ExtensionRedisClient::new(); - let repository = RedisGarageRepository::new(redis_client); - GarageService::new(repository) - }); +static GARAGE_SERVICE: LazyLock> = + LazyLock::new(|| GarageService::new(GarageStorageRepository::configured())); static HOT_GARAGE_SERVICE: LazyLock< - GarageHotStateService, InMemoryGarageHotRepository>, + GarageHotStateService, > = LazyLock::new(|| { - let redis_client = ExtensionRedisClient::new(); - let repository = RedisGarageRepository::new(redis_client); + let repository = GarageStorageRepository::configured(); let hot_repository = InMemoryGarageHotRepository::new(); GarageHotStateService::new(repository, hot_repository) }); #[allow(dead_code)] -pub(crate) fn hot_service() -> &'static GarageHotStateService< - RedisGarageRepository, - InMemoryGarageHotRepository, -> { +pub(crate) fn hot_service() +-> &'static GarageHotStateService { &HOT_GARAGE_SERVICE } diff --git a/arma/server/extension/src/lib.rs b/arma/server/extension/src/lib.rs index 0a35297..c818631 100644 --- a/arma/server/extension/src/lib.rs +++ b/arma/server/extension/src/lib.rs @@ -23,7 +23,9 @@ mod log; pub mod org; pub mod phone; pub mod redis; +pub mod storage; pub mod store; +pub mod surreal; pub mod task; pub mod terrain; pub mod transport; @@ -77,10 +79,12 @@ where /// creates the Redis connection pool on the global runtime. fn init() -> Extension { let config = redis::config::load(); + let storage_backend = config.storage.backend; let ext = Extension::build() .command("version", get_version) .command("status", get_status) .group("redis", redis::group()) + .group("surreal", surreal::group()) .group("actor", actor::group()) .group("bank", bank::group()) .group("cad", cad::group()) @@ -104,6 +108,14 @@ fn init() -> Extension { // Spawn initialization tasks for Redis and ICOM // These run asynchronously and don't block extension startup // Redis initialization will set the global CONTEXT + if storage_backend == redis::config::StorageBackend::Surreal { + let surreal_config = config.surreal.clone(); + surreal::prepare(); + RUNTIME.spawn(async move { + surreal::initialize(surreal_config).await; + }); + } + RUNTIME.spawn(async move { redis::initialize(config.redis).await; }); diff --git a/arma/server/extension/src/locker.rs b/arma/server/extension/src/locker.rs index 91d8e10..39e15d6 100644 --- a/arma/server/extension/src/locker.rs +++ b/arma/server/extension/src/locker.rs @@ -1,34 +1,27 @@ use arma_rs::{CallContext, Group}; use forge_models::locker::Item; -use forge_repositories::{InMemoryLockerHotRepository, RedisLockerRepository}; +use forge_repositories::InMemoryLockerHotRepository; use forge_services::{LockerHotStateService, LockerService}; use std::collections::HashMap; use std::sync::LazyLock; -use crate::adapters::ExtensionRedisClient; use crate::enqueue_persistence_task; use crate::helpers::resolve_uid; use crate::log::log; +use crate::storage::LockerStorageRepository; -static LOCKER_SERVICE: LazyLock>> = - LazyLock::new(|| { - let redis_client = ExtensionRedisClient::new(); - let repository = RedisLockerRepository::new(redis_client); - LockerService::new(repository) - }); +static LOCKER_SERVICE: LazyLock> = + LazyLock::new(|| LockerService::new(LockerStorageRepository::configured())); static HOT_LOCKER_SERVICE: LazyLock< - LockerHotStateService, InMemoryLockerHotRepository>, + LockerHotStateService, > = LazyLock::new(|| { - let redis_client = ExtensionRedisClient::new(); - let repository = RedisLockerRepository::new(redis_client); + let repository = LockerStorageRepository::configured(); let hot_repository = InMemoryLockerHotRepository::new(); LockerHotStateService::new(repository, hot_repository) }); -pub(crate) fn hot_service() -> &'static LockerHotStateService< - RedisLockerRepository, - InMemoryLockerHotRepository, -> { +pub(crate) fn hot_service() +-> &'static LockerHotStateService { &HOT_LOCKER_SERVICE } diff --git a/arma/server/extension/src/org.rs b/arma/server/extension/src/org.rs index 9b370fb..a328260 100644 --- a/arma/server/extension/src/org.rs +++ b/arma/server/extension/src/org.rs @@ -11,34 +11,29 @@ use forge_models::{ OrgInviteDecisionContext, OrgInviteDecisionResult, OrgInviteRecord, OrgInviteResult, OrgLeaveContext, OrgLeaveResult, OrgRegisterContext, }; -use forge_repositories::{InMemoryOrgHotRepository, RedisOrgRepository}; +use forge_repositories::InMemoryOrgHotRepository; use forge_services::{OrgHotStateService, OrgService}; use std::sync::LazyLock; -use crate::adapters::ExtensionRedisClient; use crate::enqueue_persistence_task; use crate::log::log; +use crate::storage::OrgStorageRepository; /// Global organization service instance. /// /// Lazily initialized singleton combining Redis adapter, repository, and service layers. -static ORG_SERVICE: LazyLock>> = - LazyLock::new(|| { - let redis_client = ExtensionRedisClient::new(); - let repository = RedisOrgRepository::new(redis_client); - OrgService::new(repository) - }); +static ORG_SERVICE: LazyLock> = + LazyLock::new(|| OrgService::new(OrgStorageRepository::configured())); static HOT_ORG_SERVICE: LazyLock< - OrgHotStateService, InMemoryOrgHotRepository>, + OrgHotStateService, > = LazyLock::new(|| { - let redis_client = ExtensionRedisClient::new(); - let repository = RedisOrgRepository::new(redis_client); + let repository = OrgStorageRepository::configured(); let hot_repository = InMemoryOrgHotRepository::new(); OrgHotStateService::new(repository, hot_repository) }); pub(crate) fn hot_service() --> &'static OrgHotStateService, InMemoryOrgHotRepository> { +-> &'static OrgHotStateService { &HOT_ORG_SERVICE } diff --git a/arma/server/extension/src/phone.rs b/arma/server/extension/src/phone.rs index a299904..ed539ae 100644 --- a/arma/server/extension/src/phone.rs +++ b/arma/server/extension/src/phone.rs @@ -3,17 +3,14 @@ //! The extension owns phone runtime state for contacts, messages, and emails. //! SQF remains the event bridge and may enrich contact identity from actor state. -use crate::adapters::ExtensionRedisClient; +use crate::storage::PhoneStorageRepository; use arma_rs::Group; -use forge_repositories::RedisPhoneRepository; use forge_services::PhoneStateService; use serde::Serialize; use std::sync::LazyLock; -static PHONE_SERVICE: LazyLock>> = - LazyLock::new(|| { - PhoneStateService::new(RedisPhoneRepository::new(ExtensionRedisClient::new())) - }); +static PHONE_SERVICE: LazyLock> = + LazyLock::new(|| PhoneStateService::new(PhoneStorageRepository::configured())); pub fn group() -> Group { Group::new() @@ -31,14 +28,16 @@ pub fn group() -> Group { .command("list", list_messages) .command("thread", message_thread) .command("send", send_message) - .command("mark_read", mark_message_read), + .command("mark_read", mark_message_read) + .command("delete", delete_message), ) .group( "emails", Group::new() .command("list", list_emails) .command("send", send_email) - .command("mark_read", mark_email_read), + .command("mark_read", mark_email_read) + .command("delete", delete_email), ) .command("remove", remove_phone) } @@ -80,6 +79,10 @@ pub(crate) fn mark_message_read(uid: String, message_id: String) -> String { serialize_bool(PHONE_SERVICE.mark_message_read(uid, message_id)) } +pub(crate) fn delete_message(uid: String, message_id: String) -> String { + serialize_bool(PHONE_SERVICE.delete_message(uid, message_id)) +} + pub(crate) fn send_email( from_uid: String, to_uid: String, @@ -98,6 +101,10 @@ pub(crate) fn mark_email_read(uid: String, email_id: String) -> String { serialize_bool(PHONE_SERVICE.mark_email_read(uid, email_id)) } +pub(crate) fn delete_email(uid: String, email_id: String) -> String { + serialize_bool(PHONE_SERVICE.delete_email(uid, email_id)) +} + pub(crate) fn remove_phone(uid: String) -> String { match PHONE_SERVICE.remove(uid) { Ok(()) => "OK".to_string(), diff --git a/arma/server/extension/src/redis/config.rs b/arma/server/extension/src/redis/config.rs index 0b3c9df..0da1ea4 100644 --- a/arma/server/extension/src/redis/config.rs +++ b/arma/server/extension/src/redis/config.rs @@ -12,16 +12,52 @@ static CONFIG_CACHE: OnceLock = OnceLock::new(); /// Main configuration structure for the entire application. #[derive(Debug, Clone, Deserialize)] pub struct Config { + /// Durable storage backend selector. + #[serde(default)] + pub storage: StorageConfig, /// Redis configuration with automatic defaults if not specified #[serde(default)] pub redis: RedisConfig, + /// SurrealDB configuration with automatic defaults if not specified + #[serde(default)] + pub surreal: SurrealConfig, } impl Default for Config { /// Creates a default configuration with sensible values for development. fn default() -> Self { Self { + storage: StorageConfig::default(), redis: RedisConfig::default(), + surreal: SurrealConfig::default(), + } + } +} + +#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum StorageBackend { + Redis, + Surreal, +} + +impl Default for StorageBackend { + fn default() -> Self { + Self::Redis + } +} + +/// Durable storage backend selection. +#[derive(Debug, Clone, Deserialize)] +pub struct StorageConfig { + #[serde(default)] + pub backend: StorageBackend, +} + +impl Default for StorageConfig { + fn default() -> Self { + Self { + backend: StorageBackend::Redis, } } } @@ -72,6 +108,36 @@ impl Default for RedisConfig { } } +/// SurrealDB connection configuration. +#[derive(Debug, Clone, Deserialize)] +pub struct SurrealConfig { + /// SurrealDB HTTP endpoint, for example `127.0.0.1:8000`. + pub endpoint: String, + /// SurrealDB namespace. + pub namespace: String, + /// SurrealDB database. + pub database: String, + /// Optional root username for authentication. + pub username: Option, + /// Optional root password for authentication. + pub password: Option, + /// Maximum time to wait for initial connection in milliseconds. + pub connect_timeout_ms: Option, +} + +impl Default for SurrealConfig { + fn default() -> Self { + Self { + endpoint: "127.0.0.1:8000".to_string(), + namespace: "forge".to_string(), + database: "main".to_string(), + username: Some("root".to_string()), + password: Some("root".to_string()), + connect_timeout_ms: Some(5000), + } + } +} + impl RedisConfig { /// Generates a Redis connection string from the configuration. pub fn connection_string(&self) -> String { @@ -121,7 +187,18 @@ pub fn load() -> Config { log("main", "INFO", &format!("Config file found! Loading...")); match toml::from_str::(&contents) { Ok(config) => config, - Err(_) => Config::default(), + Err(error) => { + log( + "main", + "ERROR", + &format!( + "Failed to parse config file '{}': {}. Using defaults.", + config_path.display(), + error + ), + ); + Config::default() + } } } Err(_) => { diff --git a/arma/server/extension/src/storage.rs b/arma/server/extension/src/storage.rs new file mode 100644 index 0000000..4ff3e24 --- /dev/null +++ b/arma/server/extension/src/storage.rs @@ -0,0 +1,1338 @@ +//! Durable repository selection for the extension. + +use forge_models::{ + Actor, Bank, CreditLineSummary, Garage, Locker, MemberSummary, Org, OrgAssetEntry, + OrgFleetEntry, PhoneEmail, PhoneMessage, VGarage, VLocker, +}; +use forge_repositories::{ + ActorRepository, BankRepository, GarageRepository, LockerRepository, OrgRepository, + PhoneRepository, RedisActorRepository, RedisBankRepository, RedisGarageRepository, + RedisLockerRepository, RedisOrgRepository, RedisPhoneRepository, RedisVGarageRepository, + RedisVLockerRepository, VGarageRepository, VLockerRepository, +}; +use serde::de::DeserializeOwned; +use serde::{Deserialize, Serialize}; +use std::collections::{HashMap, HashSet}; + +use crate::RUNTIME; +use crate::adapters::ExtensionRedisClient; +use crate::redis::config::{StorageBackend, load}; +use crate::surreal; + +fn surreal_select(table: &'static str, id: &str, label: &str) -> Result, String> +where + T: DeserializeOwned, +{ + let id = id.to_string(); + RUNTIME.block_on(async move { + surreal::client() + .await? + .select((table, id.as_str())) + .await + .map_err(|error| format!("SurrealDB {} select failed: {}", label, error)) + }) +} + +fn surreal_select_all(table: &'static str, label: &str) -> Result, String> +where + T: DeserializeOwned, +{ + RUNTIME.block_on(async move { + surreal::client() + .await? + .select(table) + .await + .map_err(|error| format!("SurrealDB {} select all failed: {}", label, error)) + }) +} + +fn surreal_upsert(table: &'static str, id: &str, label: &str, record: &T) -> Result<(), String> +where + T: Serialize + DeserializeOwned, +{ + let id = id.to_string(); + let record = serde_json::to_value(record) + .map_err(|error| format!("SurrealDB {} serialize failed: {}", label, error))?; + RUNTIME.block_on(async move { + let _: Option = surreal::client() + .await? + .upsert((table, id.as_str())) + .content(record) + .await + .map_err(|error| format!("SurrealDB {} upsert failed: {}", label, error))?; + Ok(()) + }) +} + +fn surreal_delete(table: &'static str, id: &str, label: &str) -> Result<(), String> +where + T: DeserializeOwned, +{ + let id = id.to_string(); + RUNTIME.block_on(async move { + let _: Option = surreal::client() + .await? + .delete((table, id.as_str())) + .await + .map_err(|error| format!("SurrealDB {} delete failed: {}", label, error))?; + Ok(()) + }) +} + +pub enum ActorStorageRepository { + Redis(RedisActorRepository), + Surreal(SurrealActorRepository), +} + +impl ActorStorageRepository { + pub fn configured() -> Self { + match load().storage.backend { + StorageBackend::Surreal => Self::Surreal(SurrealActorRepository), + StorageBackend::Redis => { + Self::Redis(RedisActorRepository::new(ExtensionRedisClient::new())) + } + } + } +} + +impl ActorRepository for ActorStorageRepository { + fn create(&self, actor: &Actor) -> Result<(), String> { + match self { + Self::Redis(repository) => repository.create(actor), + Self::Surreal(repository) => repository.create(actor), + } + } + + fn get_by_id(&self, id: &str) -> Result, String> { + match self { + Self::Redis(repository) => repository.get_by_id(id), + Self::Surreal(repository) => repository.get_by_id(id), + } + } + + fn update(&self, actor: &Actor) -> Result<(), String> { + match self { + Self::Redis(repository) => repository.update(actor), + Self::Surreal(repository) => repository.update(actor), + } + } + + fn delete(&self, id: &str) -> Result<(), String> { + match self { + Self::Redis(repository) => repository.delete(id), + Self::Surreal(repository) => repository.delete(id), + } + } + + fn exists(&self, id: &str) -> Result { + match self { + Self::Redis(repository) => repository.exists(id), + Self::Surreal(repository) => repository.exists(id), + } + } +} + +pub enum BankStorageRepository { + Redis(RedisBankRepository), + Surreal(SurrealBankRepository), +} + +impl BankStorageRepository { + pub fn configured() -> Self { + match load().storage.backend { + StorageBackend::Surreal => Self::Surreal(SurrealBankRepository), + StorageBackend::Redis => { + Self::Redis(RedisBankRepository::new(ExtensionRedisClient::new())) + } + } + } +} + +impl BankRepository for BankStorageRepository { + fn create(&self, bank: &Bank) -> Result<(), String> { + match self { + Self::Redis(repository) => repository.create(bank), + Self::Surreal(repository) => repository.create(bank), + } + } + + fn get_by_id(&self, id: &str) -> Result, String> { + match self { + Self::Redis(repository) => repository.get_by_id(id), + Self::Surreal(repository) => repository.get_by_id(id), + } + } + + fn update(&self, bank: &Bank) -> Result<(), String> { + match self { + Self::Redis(repository) => repository.update(bank), + Self::Surreal(repository) => repository.update(bank), + } + } + + fn delete(&self, id: &str) -> Result<(), String> { + match self { + Self::Redis(repository) => repository.delete(id), + Self::Surreal(repository) => repository.delete(id), + } + } + + fn exists(&self, id: &str) -> Result { + match self { + Self::Redis(repository) => repository.exists(id), + Self::Surreal(repository) => repository.exists(id), + } + } +} + +pub struct SurrealActorRepository; + +impl ActorRepository for SurrealActorRepository { + fn create(&self, actor: &Actor) -> Result<(), String> { + self.update(actor) + } + + fn get_by_id(&self, id: &str) -> Result, String> { + surreal_select("actor", id, "actor") + } + + fn update(&self, actor: &Actor) -> Result<(), String> { + surreal_upsert("actor", actor.uid.as_str(), "actor", actor) + } + + fn delete(&self, id: &str) -> Result<(), String> { + surreal_delete::("actor", id, "actor") + } + + fn exists(&self, id: &str) -> Result { + self.get_by_id(id).map(|actor| actor.is_some()) + } +} + +pub struct SurrealBankRepository; + +impl BankRepository for SurrealBankRepository { + fn create(&self, bank: &Bank) -> Result<(), String> { + self.update(bank) + } + + fn get_by_id(&self, id: &str) -> Result, String> { + surreal_select("bank", id, "bank") + } + + fn update(&self, bank: &Bank) -> Result<(), String> { + surreal_upsert("bank", bank.uid.as_str(), "bank", bank) + } + + fn delete(&self, id: &str) -> Result<(), String> { + surreal_delete::("bank", id, "bank") + } + + fn exists(&self, id: &str) -> Result { + self.get_by_id(id).map(|bank| bank.is_some()) + } +} + +pub enum PhoneStorageRepository { + Redis(RedisPhoneRepository), + Surreal(SurrealPhoneRepository), +} + +impl PhoneStorageRepository { + pub fn configured() -> Self { + match load().storage.backend { + StorageBackend::Surreal => Self::Surreal(SurrealPhoneRepository), + StorageBackend::Redis => { + Self::Redis(RedisPhoneRepository::new(ExtensionRedisClient::new())) + } + } + } +} + +impl PhoneRepository for PhoneStorageRepository { + fn init(&self, uid: &str) -> Result<(), String> { + match self { + Self::Redis(repository) => repository.init(uid), + Self::Surreal(repository) => repository.init(uid), + } + } + + fn add_contact(&self, uid: &str, contact_uid: &str) -> Result { + match self { + Self::Redis(repository) => repository.add_contact(uid, contact_uid), + Self::Surreal(repository) => repository.add_contact(uid, contact_uid), + } + } + + fn remove_contact(&self, uid: &str, contact_uid: &str) -> Result { + match self { + Self::Redis(repository) => repository.remove_contact(uid, contact_uid), + Self::Surreal(repository) => repository.remove_contact(uid, contact_uid), + } + } + + fn list_contacts(&self, uid: &str) -> Result, String> { + match self { + Self::Redis(repository) => repository.list_contacts(uid), + Self::Surreal(repository) => repository.list_contacts(uid), + } + } + + fn remove_phone(&self, uid: &str) -> Result<(), String> { + match self { + Self::Redis(repository) => repository.remove_phone(uid), + Self::Surreal(repository) => repository.remove_phone(uid), + } + } + + fn append_message(&self, uid: &str, message: PhoneMessage) -> Result<(), String> { + match self { + Self::Redis(repository) => repository.append_message(uid, message), + Self::Surreal(repository) => repository.append_message(uid, message), + } + } + + fn list_messages(&self, uid: &str) -> Result, String> { + match self { + Self::Redis(repository) => repository.list_messages(uid), + Self::Surreal(repository) => repository.list_messages(uid), + } + } + + fn mark_message_read(&self, uid: &str, message_id: &str) -> Result { + match self { + Self::Redis(repository) => repository.mark_message_read(uid, message_id), + Self::Surreal(repository) => repository.mark_message_read(uid, message_id), + } + } + + fn delete_message(&self, uid: &str, message_id: &str) -> Result { + match self { + Self::Redis(repository) => repository.delete_message(uid, message_id), + Self::Surreal(repository) => repository.delete_message(uid, message_id), + } + } + + fn append_email(&self, uid: &str, email: PhoneEmail) -> Result<(), String> { + match self { + Self::Redis(repository) => repository.append_email(uid, email), + Self::Surreal(repository) => repository.append_email(uid, email), + } + } + + fn list_emails(&self, uid: &str) -> Result, String> { + match self { + Self::Redis(repository) => repository.list_emails(uid), + Self::Surreal(repository) => repository.list_emails(uid), + } + } + + fn mark_email_read(&self, uid: &str, email_id: &str) -> Result { + match self { + Self::Redis(repository) => repository.mark_email_read(uid, email_id), + Self::Surreal(repository) => repository.mark_email_read(uid, email_id), + } + } + + fn delete_email(&self, uid: &str, email_id: &str) -> Result { + match self { + Self::Redis(repository) => repository.delete_email(uid, email_id), + Self::Surreal(repository) => repository.delete_email(uid, email_id), + } + } + + fn next_sequence(&self) -> Result { + match self { + Self::Redis(repository) => repository.next_sequence(), + Self::Surreal(repository) => repository.next_sequence(), + } + } +} + +#[derive(Debug, Default, Serialize, Deserialize)] +struct PhoneUserRecord { + #[serde(default)] + contacts: Vec, + #[serde(default)] + message_ids: Vec, + #[serde(default)] + email_ids: Vec, + #[serde(default)] + message_read: HashMap, + #[serde(default)] + email_read: HashMap, +} + +#[derive(Debug, Default, Serialize, Deserialize)] +struct PhoneMessageRecord { + #[serde(default)] + message_id: String, + #[serde(default)] + from: String, + #[serde(default)] + to: String, + #[serde(default)] + message: String, + #[serde(default)] + timestamp: f64, +} + +impl PhoneMessageRecord { + fn into_message(self, fallback_id: &str, read: bool) -> PhoneMessage { + let id = if self.message_id.trim().is_empty() { + fallback_id.to_string() + } else { + self.message_id + }; + + PhoneMessage { + id, + from: self.from, + to: self.to, + message: self.message, + timestamp: self.timestamp, + read, + } + } +} + +impl From<&PhoneMessage> for PhoneMessageRecord { + fn from(message: &PhoneMessage) -> Self { + Self { + message_id: message.id.clone(), + from: message.from.clone(), + to: message.to.clone(), + message: message.message.clone(), + timestamp: message.timestamp, + } + } +} + +#[derive(Debug, Default, Serialize, Deserialize)] +struct PhoneEmailRecord { + #[serde(default)] + email_id: String, + #[serde(default)] + from: String, + #[serde(default)] + to: String, + #[serde(default)] + subject: String, + #[serde(default)] + body: String, + #[serde(default)] + timestamp: f64, +} + +impl PhoneEmailRecord { + fn into_email(self, fallback_id: &str, read: bool) -> PhoneEmail { + let id = if self.email_id.trim().is_empty() { + fallback_id.to_string() + } else { + self.email_id + }; + + PhoneEmail { + id, + from: self.from, + to: self.to, + subject: self.subject, + body: self.body, + timestamp: self.timestamp, + read, + } + } +} + +impl From<&PhoneEmail> for PhoneEmailRecord { + fn from(email: &PhoneEmail) -> Self { + Self { + email_id: email.id.clone(), + from: email.from.clone(), + to: email.to.clone(), + subject: email.subject.clone(), + body: email.body.clone(), + timestamp: email.timestamp, + } + } +} + +#[derive(Debug, Default, Serialize, Deserialize)] +struct PhoneSequenceRecord { + #[serde(default)] + value: u64, +} + +pub struct SurrealPhoneRepository; + +impl SurrealPhoneRepository { + fn load_user(&self, uid: &str) -> Result { + Ok(surreal_select::("phone_user", uid, "phone user")?.unwrap_or_default()) + } + + fn save_user(&self, uid: &str, record: &PhoneUserRecord) -> Result<(), String> { + surreal_upsert("phone_user", uid, "phone user", record) + } + + fn message_is_referenced(&self, message_id: &str) -> Result { + Ok( + surreal_select_all::("phone_user", "phone users")? + .into_iter() + .any(|record| record.message_ids.iter().any(|id| id == message_id)), + ) + } + + fn email_is_referenced(&self, email_id: &str) -> Result { + Ok( + surreal_select_all::("phone_user", "phone users")? + .into_iter() + .any(|record| record.email_ids.iter().any(|id| id == email_id)), + ) + } + + fn cleanup_orphaned_records(&self) -> Result<(), String> { + let users = surreal_select_all::("phone_user", "phone users")?; + let referenced_messages = users + .iter() + .flat_map(|record| record.message_ids.iter().cloned()) + .collect::>(); + let referenced_emails = users + .iter() + .flat_map(|record| record.email_ids.iter().cloned()) + .collect::>(); + + for record in surreal_select_all::("phone_message", "phone messages")? { + let message_id = record.message_id.trim(); + if !message_id.is_empty() && !referenced_messages.contains(message_id) { + surreal_delete::("phone_message", message_id, "phone message")?; + } + } + + for record in surreal_select_all::("phone_email", "phone emails")? { + let email_id = record.email_id.trim(); + if !email_id.is_empty() && !referenced_emails.contains(email_id) { + surreal_delete::("phone_email", email_id, "phone email")?; + } + } + + Ok(()) + } +} + +impl PhoneRepository for SurrealPhoneRepository { + fn init(&self, uid: &str) -> Result<(), String> { + if surreal_select::("phone_user", uid, "phone user")?.is_none() { + self.save_user(uid, &PhoneUserRecord::default())?; + } + self.cleanup_orphaned_records()?; + Ok(()) + } + + fn add_contact(&self, uid: &str, contact_uid: &str) -> Result { + let mut record = self.load_user(uid)?; + if !record.contacts.iter().any(|contact| contact == contact_uid) { + record.contacts.push(contact_uid.to_string()); + } + self.save_user(uid, &record)?; + Ok(true) + } + + fn remove_contact(&self, uid: &str, contact_uid: &str) -> Result { + let mut record = self.load_user(uid)?; + let original_len = record.contacts.len(); + record.contacts.retain(|contact| contact != contact_uid); + self.save_user(uid, &record)?; + Ok(record.contacts.len() != original_len) + } + + fn list_contacts(&self, uid: &str) -> Result, String> { + let mut contacts = self.load_user(uid)?.contacts; + contacts.sort(); + contacts.dedup(); + Ok(contacts) + } + + fn remove_phone(&self, uid: &str) -> Result<(), String> { + // Message/email records can be shared by other user indexes. Removing a + // phone only drops this user's index and read state, matching Redis behavior. + surreal_delete::("phone_user", uid, "phone user") + } + + fn append_message(&self, uid: &str, message: PhoneMessage) -> Result<(), String> { + let mut user = self.load_user(uid)?; + if !user.message_ids.iter().any(|id| id == &message.id) { + user.message_ids.push(message.id.clone()); + } + user.message_read + .insert(message.id.clone(), message.from == uid); + + let record = PhoneMessageRecord::from(&message); + surreal_upsert("phone_message", &message.id, "phone message", &record)?; + self.save_user(uid, &user) + } + + fn list_messages(&self, uid: &str) -> Result, String> { + let user = self.load_user(uid)?; + let mut messages = Vec::with_capacity(user.message_ids.len()); + + for message_id in user.message_ids { + if message_id.trim().is_empty() { + continue; + } + + let read = user.message_read.get(&message_id).copied().unwrap_or(false); + if let Some(record) = + surreal_select::("phone_message", &message_id, "phone message")? + { + messages.push(record.into_message(&message_id, read)); + } + } + + messages.sort_by(|left, right| { + left.timestamp + .partial_cmp(&right.timestamp) + .unwrap_or(std::cmp::Ordering::Equal) + }); + Ok(messages) + } + + fn mark_message_read(&self, uid: &str, message_id: &str) -> Result { + let mut user = self.load_user(uid)?; + if !user.message_ids.iter().any(|id| id == message_id) { + return Ok(false); + } + user.message_read.insert(message_id.to_string(), true); + self.save_user(uid, &user)?; + Ok(true) + } + + fn delete_message(&self, uid: &str, message_id: &str) -> Result { + let mut user = self.load_user(uid)?; + let original_len = user.message_ids.len(); + user.message_ids.retain(|id| id != message_id); + if user.message_ids.len() == original_len { + return Ok(false); + } + + user.message_read.remove(message_id); + self.save_user(uid, &user)?; + if !self.message_is_referenced(message_id)? { + surreal_delete::("phone_message", message_id, "phone message")?; + } + Ok(true) + } + + fn append_email(&self, uid: &str, email: PhoneEmail) -> Result<(), String> { + let mut user = self.load_user(uid)?; + if !user.email_ids.iter().any(|id| id == &email.id) { + user.email_ids.push(email.id.clone()); + } + user.email_read.insert(email.id.clone(), false); + + let record = PhoneEmailRecord::from(&email); + surreal_upsert("phone_email", &email.id, "phone email", &record)?; + self.save_user(uid, &user) + } + + fn list_emails(&self, uid: &str) -> Result, String> { + let user = self.load_user(uid)?; + let mut emails = Vec::with_capacity(user.email_ids.len()); + + for email_id in user.email_ids { + if email_id.trim().is_empty() { + continue; + } + + let read = user.email_read.get(&email_id).copied().unwrap_or(false); + if let Some(record) = + surreal_select::("phone_email", &email_id, "phone email")? + { + emails.push(record.into_email(&email_id, read)); + } + } + + emails.sort_by(|left, right| { + right + .timestamp + .partial_cmp(&left.timestamp) + .unwrap_or(std::cmp::Ordering::Equal) + }); + Ok(emails) + } + + fn mark_email_read(&self, uid: &str, email_id: &str) -> Result { + let mut user = self.load_user(uid)?; + if !user.email_ids.iter().any(|id| id == email_id) { + return Ok(false); + } + user.email_read.insert(email_id.to_string(), true); + self.save_user(uid, &user)?; + Ok(true) + } + + fn delete_email(&self, uid: &str, email_id: &str) -> Result { + let mut user = self.load_user(uid)?; + let original_len = user.email_ids.len(); + user.email_ids.retain(|id| id != email_id); + if user.email_ids.len() == original_len { + return Ok(false); + } + + user.email_read.remove(email_id); + self.save_user(uid, &user)?; + if !self.email_is_referenced(email_id)? { + surreal_delete::("phone_email", email_id, "phone email")?; + } + Ok(true) + } + + fn next_sequence(&self) -> Result { + let mut record = + surreal_select::("phone_sequence", "global", "phone sequence")? + .unwrap_or_default(); + record.value = record + .value + .checked_add(1) + .ok_or_else(|| "Phone sequence overflowed.".to_string())?; + surreal_upsert("phone_sequence", "global", "phone sequence", &record)?; + Ok(record.value) + } +} + +pub enum GarageStorageRepository { + Redis(RedisGarageRepository), + Surreal(SurrealGarageRepository), +} + +impl GarageStorageRepository { + pub fn configured() -> Self { + match load().storage.backend { + StorageBackend::Surreal => Self::Surreal(SurrealGarageRepository), + StorageBackend::Redis => { + Self::Redis(RedisGarageRepository::new(ExtensionRedisClient::new())) + } + } + } +} + +impl GarageRepository for GarageStorageRepository { + fn create(&self, uid: &str, garage: &Garage) -> Result<(), String> { + match self { + Self::Redis(repository) => repository.create(uid, garage), + Self::Surreal(repository) => repository.create(uid, garage), + } + } + + fn update(&self, uid: &str, garage: &Garage) -> Result<(), String> { + match self { + Self::Redis(repository) => repository.update(uid, garage), + Self::Surreal(repository) => repository.update(uid, garage), + } + } + + fn get(&self, uid: &str) -> Result, String> { + match self { + Self::Redis(repository) => repository.get(uid), + Self::Surreal(repository) => repository.get(uid), + } + } + + fn delete(&self, uid: &str) -> Result<(), String> { + match self { + Self::Redis(repository) => repository.delete(uid), + Self::Surreal(repository) => repository.delete(uid), + } + } + + fn exists(&self, uid: &str) -> Result { + match self { + Self::Redis(repository) => repository.exists(uid), + Self::Surreal(repository) => repository.exists(uid), + } + } +} + +pub struct SurrealGarageRepository; + +impl GarageRepository for SurrealGarageRepository { + fn create(&self, uid: &str, garage: &Garage) -> Result<(), String> { + self.update(uid, garage) + } + + fn update(&self, uid: &str, garage: &Garage) -> Result<(), String> { + surreal_upsert("garage", uid, "garage", garage) + } + + fn get(&self, uid: &str) -> Result, String> { + surreal_select("garage", uid, "garage") + } + + fn delete(&self, uid: &str) -> Result<(), String> { + surreal_delete::("garage", uid, "garage") + } + + fn exists(&self, uid: &str) -> Result { + self.get(uid).map(|garage| garage.is_some()) + } +} + +pub enum LockerStorageRepository { + Redis(RedisLockerRepository), + Surreal(SurrealLockerRepository), +} + +impl LockerStorageRepository { + pub fn configured() -> Self { + match load().storage.backend { + StorageBackend::Surreal => Self::Surreal(SurrealLockerRepository), + StorageBackend::Redis => { + Self::Redis(RedisLockerRepository::new(ExtensionRedisClient::new())) + } + } + } +} + +impl LockerRepository for LockerStorageRepository { + fn create(&self, uid: &str, locker: &Locker) -> Result<(), String> { + match self { + Self::Redis(repository) => repository.create(uid, locker), + Self::Surreal(repository) => repository.create(uid, locker), + } + } + + fn update(&self, uid: &str, locker: &Locker) -> Result<(), String> { + match self { + Self::Redis(repository) => repository.update(uid, locker), + Self::Surreal(repository) => repository.update(uid, locker), + } + } + + fn get(&self, uid: &str) -> Result, String> { + match self { + Self::Redis(repository) => repository.get(uid), + Self::Surreal(repository) => repository.get(uid), + } + } + + fn delete(&self, uid: &str) -> Result<(), String> { + match self { + Self::Redis(repository) => repository.delete(uid), + Self::Surreal(repository) => repository.delete(uid), + } + } + + fn exists(&self, uid: &str) -> Result { + match self { + Self::Redis(repository) => repository.exists(uid), + Self::Surreal(repository) => repository.exists(uid), + } + } +} + +pub struct SurrealLockerRepository; + +impl LockerRepository for SurrealLockerRepository { + fn create(&self, uid: &str, locker: &Locker) -> Result<(), String> { + self.update(uid, locker) + } + + fn update(&self, uid: &str, locker: &Locker) -> Result<(), String> { + surreal_upsert("locker", uid, "locker", locker) + } + + fn get(&self, uid: &str) -> Result, String> { + surreal_select("locker", uid, "locker") + } + + fn delete(&self, uid: &str) -> Result<(), String> { + surreal_delete::("locker", uid, "locker") + } + + fn exists(&self, uid: &str) -> Result { + self.get(uid).map(|locker| locker.is_some()) + } +} + +pub enum VGarageStorageRepository { + Redis(RedisVGarageRepository), + Surreal(SurrealVGarageRepository), +} + +impl VGarageStorageRepository { + pub fn configured() -> Self { + match load().storage.backend { + StorageBackend::Surreal => Self::Surreal(SurrealVGarageRepository), + StorageBackend::Redis => { + Self::Redis(RedisVGarageRepository::new(ExtensionRedisClient::new())) + } + } + } +} + +impl VGarageRepository for VGarageStorageRepository { + fn create(&self, uid: &str, garage: &VGarage) -> Result<(), String> { + match self { + Self::Redis(repository) => repository.create(uid, garage), + Self::Surreal(repository) => repository.create(uid, garage), + } + } + + fn update(&self, uid: &str, garage: &VGarage) -> Result<(), String> { + match self { + Self::Redis(repository) => repository.update(uid, garage), + Self::Surreal(repository) => repository.update(uid, garage), + } + } + + fn fetch(&self, uid: &str) -> Result, String> { + match self { + Self::Redis(repository) => repository.fetch(uid), + Self::Surreal(repository) => repository.fetch(uid), + } + } + + fn get(&self, uid: &str, field: &str) -> Result, String> { + match self { + Self::Redis(repository) => repository.get(uid, field), + Self::Surreal(repository) => repository.get(uid, field), + } + } + + fn delete(&self, uid: &str) -> Result<(), String> { + match self { + Self::Redis(repository) => repository.delete(uid), + Self::Surreal(repository) => repository.delete(uid), + } + } + + fn exists(&self, uid: &str) -> Result { + match self { + Self::Redis(repository) => repository.exists(uid), + Self::Surreal(repository) => repository.exists(uid), + } + } +} + +pub struct SurrealVGarageRepository; + +impl VGarageRepository for SurrealVGarageRepository { + fn create(&self, uid: &str, garage: &VGarage) -> Result<(), String> { + self.update(uid, garage) + } + + fn update(&self, uid: &str, garage: &VGarage) -> Result<(), String> { + surreal_upsert("owned_garage", uid, "virtual garage", garage) + } + + fn fetch(&self, uid: &str) -> Result, String> { + surreal_select("owned_garage", uid, "virtual garage") + } + + fn get(&self, uid: &str, field: &str) -> Result, String> { + let garage = self.fetch(uid)?.unwrap_or_else(VGarage::new); + match field { + "cars" => Ok(garage.cars), + "armor" => Ok(garage.armor), + "helis" => Ok(garage.helis), + "planes" => Ok(garage.planes), + "naval" => Ok(garage.naval), + "other" => Ok(garage.other), + _ => Err(format!("Unknown virtual garage field '{}'", field)), + } + } + + fn delete(&self, uid: &str) -> Result<(), String> { + surreal_delete::("owned_garage", uid, "virtual garage") + } + + fn exists(&self, uid: &str) -> Result { + self.fetch(uid).map(|garage| garage.is_some()) + } +} + +pub enum VLockerStorageRepository { + Redis(RedisVLockerRepository), + Surreal(SurrealVLockerRepository), +} + +impl VLockerStorageRepository { + pub fn configured() -> Self { + match load().storage.backend { + StorageBackend::Surreal => Self::Surreal(SurrealVLockerRepository), + StorageBackend::Redis => { + Self::Redis(RedisVLockerRepository::new(ExtensionRedisClient::new())) + } + } + } +} + +pub enum OrgStorageRepository { + Redis(RedisOrgRepository), + Surreal(SurrealOrgRepository), +} + +impl OrgStorageRepository { + pub fn configured() -> Self { + match load().storage.backend { + StorageBackend::Surreal => Self::Surreal(SurrealOrgRepository), + StorageBackend::Redis => { + Self::Redis(RedisOrgRepository::new(ExtensionRedisClient::new())) + } + } + } +} + +impl OrgRepository for OrgStorageRepository { + fn create(&self, org: &Org) -> Result<(), String> { + match self { + Self::Redis(repository) => repository.create(org), + Self::Surreal(repository) => repository.create(org), + } + } + + fn get_by_id(&self, id: &str) -> Result, String> { + match self { + Self::Redis(repository) => repository.get_by_id(id), + Self::Surreal(repository) => repository.get_by_id(id), + } + } + + fn update(&self, org: &Org) -> Result<(), String> { + match self { + Self::Redis(repository) => repository.update(org), + Self::Surreal(repository) => repository.update(org), + } + } + + fn delete(&self, id: &str) -> Result<(), String> { + match self { + Self::Redis(repository) => repository.delete(id), + Self::Surreal(repository) => repository.delete(id), + } + } + + fn exists(&self, id: &str) -> Result { + match self { + Self::Redis(repository) => repository.exists(id), + Self::Surreal(repository) => repository.exists(id), + } + } + + fn add_member(&self, org_id: &str, member_uid: &str) -> Result<(), String> { + match self { + Self::Redis(repository) => repository.add_member(org_id, member_uid), + Self::Surreal(repository) => repository.add_member(org_id, member_uid), + } + } + + fn get_members(&self, org_id: &str) -> Result, String> { + match self { + Self::Redis(repository) => repository.get_members(org_id), + Self::Surreal(repository) => repository.get_members(org_id), + } + } + + fn remove_member(&self, org_id: &str, member_uid: &str) -> Result<(), String> { + match self { + Self::Redis(repository) => repository.remove_member(org_id, member_uid), + Self::Surreal(repository) => repository.remove_member(org_id, member_uid), + } + } + + fn get_assets( + &self, + org_id: &str, + ) -> Result>, String> { + match self { + Self::Redis(repository) => repository.get_assets(org_id), + Self::Surreal(repository) => repository.get_assets(org_id), + } + } + + fn update_assets( + &self, + org_id: &str, + assets: &HashMap>, + ) -> Result<(), String> { + match self { + Self::Redis(repository) => repository.update_assets(org_id, assets), + Self::Surreal(repository) => repository.update_assets(org_id, assets), + } + } + + fn get_fleet(&self, org_id: &str) -> Result, String> { + match self { + Self::Redis(repository) => repository.get_fleet(org_id), + Self::Surreal(repository) => repository.get_fleet(org_id), + } + } + + fn update_fleet( + &self, + org_id: &str, + fleet: &HashMap, + ) -> Result<(), String> { + match self { + Self::Redis(repository) => repository.update_fleet(org_id, fleet), + Self::Surreal(repository) => repository.update_fleet(org_id, fleet), + } + } +} + +#[derive(Debug, Default, Serialize, Deserialize)] +struct OrgMemberRecord { + #[serde(default)] + members: Vec, +} + +#[derive(Debug, Default, Serialize, Deserialize)] +struct OrgAssetRecord { + #[serde(default)] + assets: HashMap>, +} + +#[derive(Debug, Default, Serialize, Deserialize)] +struct OrgFleetRecord { + #[serde(default)] + fleet: HashMap, +} + +#[derive(Debug, Default, Serialize, Deserialize)] +struct SurrealOrgRecord { + #[serde(default)] + org_id: String, + #[serde(default)] + owner: String, + #[serde(default)] + name: String, + #[serde(default)] + funds: f64, + #[serde(default)] + reputation: i64, + #[serde(default)] + credit_lines: HashMap, +} + +impl SurrealOrgRecord { + fn into_org(self, fallback_id: &str) -> Org { + let id = if self.org_id.trim().is_empty() { + fallback_id.to_string() + } else { + self.org_id + }; + + Org { + id, + owner: self.owner, + name: self.name, + funds: self.funds, + reputation: self.reputation, + credit_lines: self.credit_lines, + } + } +} + +impl From<&Org> for SurrealOrgRecord { + fn from(org: &Org) -> Self { + Self { + org_id: org.id.clone(), + owner: org.owner.clone(), + name: org.name.clone(), + funds: org.funds, + reputation: org.reputation, + credit_lines: org.credit_lines.clone(), + } + } +} + +pub struct SurrealOrgRepository; + +impl OrgRepository for SurrealOrgRepository { + fn create(&self, org: &Org) -> Result<(), String> { + self.update(org) + } + + fn get_by_id(&self, id: &str) -> Result, String> { + Ok(surreal_select::("org", id, "org")?.map(|record| record.into_org(id))) + } + + fn update(&self, org: &Org) -> Result<(), String> { + let record = SurrealOrgRecord::from(org); + surreal_upsert("org", org.id.as_str(), "org", &record) + } + + fn delete(&self, id: &str) -> Result<(), String> { + surreal_delete::("org", id, "org")?; + surreal_delete::("org_members", id, "org members")?; + surreal_delete::("org_assets", id, "org assets")?; + surreal_delete::("org_fleet", id, "org fleet") + } + + fn exists(&self, id: &str) -> Result { + self.get_by_id(id).map(|org| org.is_some()) + } + + fn add_member(&self, org_id: &str, member_uid: &str) -> Result<(), String> { + if !self.exists(org_id)? { + return Err(format!("Organization {} does not exist", org_id)); + } + + let mut record = surreal_select::("org_members", org_id, "org members")? + .unwrap_or_default(); + if !record.members.iter().any(|uid| uid == member_uid) { + record.members.push(member_uid.to_string()); + } + surreal_upsert("org_members", org_id, "org members", &record) + } + + fn get_members(&self, org_id: &str) -> Result, String> { + let record = surreal_select::("org_members", org_id, "org members")? + .unwrap_or_default(); + let mut members = Vec::with_capacity(record.members.len()); + + for uid in record.members { + if uid.trim().is_empty() { + continue; + } + + let name = match surreal_select::("actor", &uid, "actor")? { + Some(actor) => actor + .name + .filter(|name| !name.trim().is_empty()) + .unwrap_or_else(|| "Unknown".to_string()), + None => "Unknown".to_string(), + }; + + members.push(MemberSummary { uid, name }); + } + + Ok(members) + } + + fn remove_member(&self, org_id: &str, member_uid: &str) -> Result<(), String> { + let mut record = surreal_select::("org_members", org_id, "org members")? + .unwrap_or_default(); + record.members.retain(|uid| uid != member_uid); + surreal_upsert("org_members", org_id, "org members", &record) + } + + fn get_assets( + &self, + org_id: &str, + ) -> Result>, String> { + Ok( + surreal_select::("org_assets", org_id, "org assets")? + .unwrap_or_default() + .assets, + ) + } + + fn update_assets( + &self, + org_id: &str, + assets: &HashMap>, + ) -> Result<(), String> { + let record = OrgAssetRecord { + assets: assets.clone(), + }; + surreal_upsert("org_assets", org_id, "org assets", &record) + } + + fn get_fleet(&self, org_id: &str) -> Result, String> { + Ok( + surreal_select::("org_fleet", org_id, "org fleet")? + .unwrap_or_default() + .fleet, + ) + } + + fn update_fleet( + &self, + org_id: &str, + fleet: &HashMap, + ) -> Result<(), String> { + let record = OrgFleetRecord { + fleet: fleet.clone(), + }; + surreal_upsert("org_fleet", org_id, "org fleet", &record) + } +} + +impl VLockerRepository for VLockerStorageRepository { + fn create(&self, uid: &str, locker: &VLocker) -> Result<(), String> { + match self { + Self::Redis(repository) => repository.create(uid, locker), + Self::Surreal(repository) => repository.create(uid, locker), + } + } + + fn update(&self, uid: &str, locker: &VLocker) -> Result<(), String> { + match self { + Self::Redis(repository) => repository.update(uid, locker), + Self::Surreal(repository) => repository.update(uid, locker), + } + } + + fn fetch(&self, uid: &str) -> Result, String> { + match self { + Self::Redis(repository) => repository.fetch(uid), + Self::Surreal(repository) => repository.fetch(uid), + } + } + + fn get(&self, uid: &str, field: &str) -> Result, String> { + match self { + Self::Redis(repository) => repository.get(uid, field), + Self::Surreal(repository) => repository.get(uid, field), + } + } + + fn delete(&self, uid: &str) -> Result<(), String> { + match self { + Self::Redis(repository) => repository.delete(uid), + Self::Surreal(repository) => repository.delete(uid), + } + } + + fn exists(&self, uid: &str) -> Result { + match self { + Self::Redis(repository) => repository.exists(uid), + Self::Surreal(repository) => repository.exists(uid), + } + } +} + +pub struct SurrealVLockerRepository; + +impl VLockerRepository for SurrealVLockerRepository { + fn create(&self, uid: &str, locker: &VLocker) -> Result<(), String> { + self.update(uid, locker) + } + + fn update(&self, uid: &str, locker: &VLocker) -> Result<(), String> { + surreal_upsert("owned_locker", uid, "virtual locker", locker) + } + + fn fetch(&self, uid: &str) -> Result, String> { + surreal_select("owned_locker", uid, "virtual locker") + } + + fn get(&self, uid: &str, field: &str) -> Result, String> { + let locker = self.fetch(uid)?.unwrap_or_else(VLocker::new); + match field { + "items" => Ok(locker.items), + "weapons" => Ok(locker.weapons), + "magazines" => Ok(locker.magazines), + "backpacks" => Ok(locker.backpacks), + _ => Err(format!("Unknown virtual locker field '{}'", field)), + } + } + + fn delete(&self, uid: &str) -> Result<(), String> { + surreal_delete::("owned_locker", uid, "virtual locker") + } + + fn exists(&self, uid: &str) -> Result { + self.fetch(uid).map(|locker| locker.is_some()) + } +} diff --git a/arma/server/extension/src/surreal.rs b/arma/server/extension/src/surreal.rs new file mode 100644 index 0000000..6faf422 --- /dev/null +++ b/arma/server/extension/src/surreal.rs @@ -0,0 +1,170 @@ +//! SurrealDB connection bootstrap for persistent storage. + +use arma_rs::Group; +use std::sync::{LazyLock, OnceLock, RwLock as StdRwLock}; +use surrealdb::Surreal; +use surrealdb::engine::remote::http::{Client, Http}; +use surrealdb::opt::auth::Root; +use tokio::time::{Duration, sleep, timeout}; + +use crate::log; +use crate::redis::config::SurrealConfig; + +pub type SurrealDb = Surreal; + +const CLIENT_READY_TIMEOUT: Duration = Duration::from_secs(5); +const CLIENT_READY_POLL_INTERVAL: Duration = Duration::from_millis(25); + +static SURREAL_DB: OnceLock = OnceLock::new(); +static SURREAL_CONNECTION_STATE: LazyLock> = + LazyLock::new(|| StdRwLock::new(SurrealConnectionState::Disabled)); +static SURREAL_FAILURE_REASON: LazyLock>> = + LazyLock::new(|| StdRwLock::new(None)); + +#[derive(Clone, Copy, PartialEq, Eq)] +enum SurrealConnectionState { + Disabled, + Initializing, + Connected, + Failed, +} + +pub fn prepare() { + *SURREAL_FAILURE_REASON.write().unwrap() = None; + *SURREAL_CONNECTION_STATE.write().unwrap() = SurrealConnectionState::Initializing; +} + +pub async fn initialize(config: SurrealConfig) { + prepare(); + + log::log( + "surreal", + "INFO", + &format!( + "Connecting to SurrealDB endpoint '{}' namespace '{}' database '{}'", + config.endpoint, config.namespace, config.database + ), + ); + + let timeout_duration = Duration::from_millis(config.connect_timeout_ms.unwrap_or(5000)); + let connection = timeout(timeout_duration, connect(config)).await; + + let db = match connection { + Err(_) => { + log::log( + "surreal", + "ERROR", + &format!( + "SurrealDB connection timed out after {} ms", + timeout_duration.as_millis() + ), + ); + set_failure_reason(format!( + "SurrealDB connection timed out after {} ms", + timeout_duration.as_millis() + )); + *SURREAL_CONNECTION_STATE.write().unwrap() = SurrealConnectionState::Failed; + return; + } + Ok(Ok(db)) => db, + Ok(Err(error)) => { + log::log( + "surreal", + "ERROR", + &format!("Failed to connect to SurrealDB: {}", error), + ); + set_failure_reason(error); + *SURREAL_CONNECTION_STATE.write().unwrap() = SurrealConnectionState::Failed; + return; + } + }; + + if SURREAL_DB.set(db).is_ok() { + log::log("surreal", "INFO", "Connected to SurrealDB server"); + *SURREAL_CONNECTION_STATE.write().unwrap() = SurrealConnectionState::Connected; + } else { + log::log("surreal", "ERROR", "Failed to set SurrealDB client"); + set_failure_reason("Failed to set SurrealDB client".to_string()); + *SURREAL_CONNECTION_STATE.write().unwrap() = SurrealConnectionState::Failed; + } +} + +fn set_failure_reason(reason: String) { + *SURREAL_FAILURE_REASON.write().unwrap() = Some(reason); +} + +fn failure_reason() -> String { + SURREAL_FAILURE_REASON + .read() + .unwrap() + .clone() + .unwrap_or_else(|| "unknown failure".to_string()) +} + +async fn connect(config: SurrealConfig) -> Result { + let db = Surreal::new::(config.endpoint.as_str()) + .await + .map_err(|error| error.to_string())?; + + if let (Some(username), Some(password)) = (&config.username, &config.password) { + db.signin(Root { + username: username.as_str(), + password: password.as_str(), + }) + .await + .map_err(|error| error.to_string())?; + } + + db.use_ns(config.namespace.as_str()) + .use_db(config.database.as_str()) + .await + .map_err(|error| error.to_string())?; + + Ok(db) +} + +pub async fn client() -> Result<&'static SurrealDb, String> { + if let Some(db) = SURREAL_DB.get() { + return Ok(db); + } + + timeout(CLIENT_READY_TIMEOUT, wait_for_client()) + .await + .unwrap_or_else(|_| { + Err("SurrealDB connection did not become ready before timeout".to_string()) + }) +} + +async fn wait_for_client() -> Result<&'static SurrealDb, String> { + loop { + if let Some(db) = SURREAL_DB.get() { + return Ok(db); + } + + match *SURREAL_CONNECTION_STATE.read().unwrap() { + SurrealConnectionState::Disabled => { + return Err("SurrealDB connection is disabled".to_string()); + } + SurrealConnectionState::Failed => { + return Err(format!("SurrealDB connection failed: {}", failure_reason())); + } + SurrealConnectionState::Initializing | SurrealConnectionState::Connected => { + sleep(CLIENT_READY_POLL_INTERVAL).await; + } + } + } +} + +pub fn status() -> String { + let state = *SURREAL_CONNECTION_STATE.read().unwrap(); + match state { + SurrealConnectionState::Disabled => "disabled".to_string(), + SurrealConnectionState::Initializing => "initializing".to_string(), + SurrealConnectionState::Connected => "connected".to_string(), + SurrealConnectionState::Failed => "failed".to_string(), + } +} + +pub fn group() -> Group { + Group::new().command("status", status) +} diff --git a/arma/server/extension/src/v_garage.rs b/arma/server/extension/src/v_garage.rs index 1382d63..187550a 100644 --- a/arma/server/extension/src/v_garage.rs +++ b/arma/server/extension/src/v_garage.rs @@ -1,36 +1,26 @@ use arma_rs::{CallContext, Group}; use forge_models::{VGarage, VehicleCategory}; -use forge_repositories::{InMemoryVGarageHotRepository, RedisVGarageRepository}; +use forge_repositories::InMemoryVGarageHotRepository; use forge_services::{VGarageHotStateService, VGarageService}; use std::sync::LazyLock; -use crate::adapters::ExtensionRedisClient; use crate::enqueue_persistence_task; use crate::helpers::resolve_uid; use crate::log::log; +use crate::storage::VGarageStorageRepository; -static VGARAGE_SERVICE: LazyLock>> = - LazyLock::new(|| { - let redis_client = ExtensionRedisClient::new(); - let repository = RedisVGarageRepository::new(redis_client); - VGarageService::new(repository) - }); +static VGARAGE_SERVICE: LazyLock> = + LazyLock::new(|| VGarageService::new(VGarageStorageRepository::configured())); static HOT_VGARAGE_SERVICE: LazyLock< - VGarageHotStateService< - RedisVGarageRepository, - InMemoryVGarageHotRepository, - >, + VGarageHotStateService, > = LazyLock::new(|| { - let redis_client = ExtensionRedisClient::new(); - let repository = RedisVGarageRepository::new(redis_client); + let repository = VGarageStorageRepository::configured(); let hot_repository = InMemoryVGarageHotRepository::new(); VGarageHotStateService::new(repository, hot_repository) }); -pub(crate) fn hot_service() -> &'static VGarageHotStateService< - RedisVGarageRepository, - InMemoryVGarageHotRepository, -> { +pub(crate) fn hot_service() +-> &'static VGarageHotStateService { &HOT_VGARAGE_SERVICE } diff --git a/arma/server/extension/src/v_locker.rs b/arma/server/extension/src/v_locker.rs index 7064e47..8df133e 100644 --- a/arma/server/extension/src/v_locker.rs +++ b/arma/server/extension/src/v_locker.rs @@ -1,36 +1,26 @@ use arma_rs::{CallContext, Group}; use forge_models::{EquipmentCategory, VLocker}; -use forge_repositories::{InMemoryVLockerHotRepository, RedisVLockerRepository}; +use forge_repositories::InMemoryVLockerHotRepository; use forge_services::{VLockerHotStateService, VLockerService}; use std::sync::LazyLock; -use crate::adapters::ExtensionRedisClient; use crate::enqueue_persistence_task; use crate::helpers::resolve_uid; use crate::log::log; +use crate::storage::VLockerStorageRepository; -static VLOCKER_SERVICE: LazyLock>> = - LazyLock::new(|| { - let redis_client = ExtensionRedisClient::new(); - let repository = RedisVLockerRepository::new(redis_client); - VLockerService::new(repository) - }); +static VLOCKER_SERVICE: LazyLock> = + LazyLock::new(|| VLockerService::new(VLockerStorageRepository::configured())); static HOT_VLOCKER_SERVICE: LazyLock< - VLockerHotStateService< - RedisVLockerRepository, - InMemoryVLockerHotRepository, - >, + VLockerHotStateService, > = LazyLock::new(|| { - let redis_client = ExtensionRedisClient::new(); - let repository = RedisVLockerRepository::new(redis_client); + let repository = VLockerStorageRepository::configured(); let hot_repository = InMemoryVLockerHotRepository::new(); VLockerHotStateService::new(repository, hot_repository) }); -pub(crate) fn hot_service() -> &'static VLockerHotStateService< - RedisVLockerRepository, - InMemoryVLockerHotRepository, -> { +pub(crate) fn hot_service() +-> &'static VLockerHotStateService { &HOT_VLOCKER_SERVICE } diff --git a/lib/repositories/src/phone.rs b/lib/repositories/src/phone.rs index 7303b02..d3f194d 100644 --- a/lib/repositories/src/phone.rs +++ b/lib/repositories/src/phone.rs @@ -13,10 +13,12 @@ pub trait PhoneRepository: Send + Sync { fn append_message(&self, uid: &str, message: PhoneMessage) -> Result<(), String>; fn list_messages(&self, uid: &str) -> Result, String>; fn mark_message_read(&self, uid: &str, message_id: &str) -> Result; + fn delete_message(&self, uid: &str, message_id: &str) -> Result; fn append_email(&self, uid: &str, email: PhoneEmail) -> Result<(), String>; fn list_emails(&self, uid: &str) -> Result, String>; fn mark_email_read(&self, uid: &str, email_id: &str) -> Result; + fn delete_email(&self, uid: &str, email_id: &str) -> Result; fn next_sequence(&self) -> Result; } @@ -146,6 +148,19 @@ impl PhoneRepository for InMemoryPhoneRepository { Ok(found) } + fn delete_message(&self, uid: &str, message_id: &str) -> Result { + let mut state = self + .state + .write() + .map_err(|_| "Phone message state lock poisoned.".to_string())?; + let Some(messages) = state.messages.get_mut(uid) else { + return Ok(false); + }; + let original_len = messages.len(); + messages.retain(|message| message.id != message_id); + Ok(messages.len() != original_len) + } + fn append_email(&self, uid: &str, email: PhoneEmail) -> Result<(), String> { self.state .write() @@ -193,6 +208,19 @@ impl PhoneRepository for InMemoryPhoneRepository { Ok(found) } + fn delete_email(&self, uid: &str, email_id: &str) -> Result { + let mut state = self + .state + .write() + .map_err(|_| "Phone email state lock poisoned.".to_string())?; + let Some(emails) = state.emails.get_mut(uid) else { + return Ok(false); + }; + let original_len = emails.len(); + emails.retain(|email| email.id != email_id); + Ok(emails.len() != original_len) + } + fn next_sequence(&self) -> Result { let mut state = self .state @@ -476,6 +504,38 @@ impl PhoneRepository for RedisPhoneRepository { Ok(true) } + fn delete_message(&self, uid: &str, message_id: &str) -> Result { + let exists = self + .client + .list_range(Self::user_messages_key(uid), 0, -1)? + .iter() + .any(|id| id == message_id); + if !exists { + return Ok(false); + } + + let message = self.load_message_record(uid, message_id)?; + self.client + .list_del(Self::user_messages_key(uid), 0, message_id.to_string())?; + self.client + .hash_del(Self::message_read_key(uid), message_id.to_string())?; + + if let Some(message) = message { + let other_uid = if message.from == uid { + &message.to + } else { + &message.from + }; + self.client.list_del( + Self::message_thread_key(uid, other_uid), + 0, + message_id.to_string(), + )?; + } + + Ok(true) + } + fn append_email(&self, uid: &str, email: PhoneEmail) -> Result<(), String> { self.save_email_record(&email)?; self.client @@ -518,6 +578,23 @@ impl PhoneRepository for RedisPhoneRepository { Ok(true) } + fn delete_email(&self, uid: &str, email_id: &str) -> Result { + let exists = self + .client + .list_range(Self::user_emails_key(uid), 0, -1)? + .iter() + .any(|id| id == email_id); + if !exists { + return Ok(false); + } + + self.client + .list_del(Self::user_emails_key(uid), 0, email_id.to_string())?; + self.client + .hash_del(Self::email_read_key(uid), email_id.to_string())?; + Ok(true) + } + fn next_sequence(&self) -> Result { let value = self.client.incr_key(Self::sequence_key(), 1)?; u64::try_from(value).map_err(|_| "Phone sequence overflowed.".to_string()) diff --git a/lib/services/src/phone.rs b/lib/services/src/phone.rs index bd52de8..0444b19 100644 --- a/lib/services/src/phone.rs +++ b/lib/services/src/phone.rs @@ -1,6 +1,8 @@ use forge_models::{PhoneEmail, PhoneMessage, PhonePayload}; use forge_repositories::PhoneRepository; +const FIELD_COMMANDER_UID: &str = "field_commander"; + pub struct PhoneStateService { repository: R, } @@ -13,15 +15,14 @@ impl PhoneStateService { pub fn init(&self, uid: String) -> Result { let uid = Self::validate_uid(uid)?; self.repository.init(&uid)?; + self.repository.add_contact(&uid, &uid)?; + self.repository.add_contact(&uid, FIELD_COMMANDER_UID)?; self.payload_for(&uid) } pub fn add_contact(&self, uid: String, contact_uid: String) -> Result { let uid = Self::validate_uid(uid)?; let contact_uid = Self::validate_uid(contact_uid)?; - if uid == contact_uid { - return Err("Cannot add self as a phone contact.".to_string()); - } self.repository.add_contact(&uid, &contact_uid) } @@ -46,6 +47,11 @@ impl PhoneStateService { let from_uid = Self::validate_uid(from_uid)?; let to_uid = Self::validate_uid(to_uid)?; let message = Self::validate_non_empty(message, "Message body is required.")?; + Self::validate_send_target( + &from_uid, + &to_uid, + "Field Commander cannot receive player messages.", + )?; let timestamp = Self::parse_timestamp(timestamp); let id = format!( "phone-message:{}:{}:{}", @@ -63,7 +69,9 @@ impl PhoneStateService { }; self.repository.append_message(&from_uid, record.clone())?; - self.repository.append_message(&to_uid, record.clone())?; + if to_uid != from_uid { + self.repository.append_message(&to_uid, record.clone())?; + } Ok(record) } @@ -96,6 +104,12 @@ impl PhoneStateService { self.repository.mark_message_read(&uid, &message_id) } + pub fn delete_message(&self, uid: String, message_id: String) -> Result { + let uid = Self::validate_uid(uid)?; + let message_id = Self::validate_non_empty(message_id, "Message ID is required.")?; + self.repository.delete_message(&uid, &message_id) + } + pub fn send_email( &self, from_uid: String, @@ -106,8 +120,13 @@ impl PhoneStateService { ) -> Result { let from_uid = Self::validate_uid(from_uid)?; let to_uid = Self::validate_uid(to_uid)?; - let subject = Self::validate_non_empty(subject, "Email subject is required.")?; + let subject = Self::default_subject(subject); let body = Self::validate_non_empty(body, "Email body is required.")?; + Self::validate_send_target( + &from_uid, + &to_uid, + "Field Commander cannot receive player emails.", + )?; let timestamp = Self::parse_timestamp(timestamp); let id = format!( "phone-email:{}:{}:{}", @@ -117,7 +136,7 @@ impl PhoneStateService { ); let record = PhoneEmail { id, - from: from_uid, + from: from_uid.clone(), to: to_uid.clone(), subject, body, @@ -126,6 +145,10 @@ impl PhoneStateService { }; self.repository.append_email(&to_uid, record.clone())?; + if from_uid != to_uid { + self.repository.append_email(&from_uid, record.clone())?; + self.repository.mark_email_read(&from_uid, &record.id)?; + } Ok(record) } @@ -140,6 +163,12 @@ impl PhoneStateService { self.repository.mark_email_read(&uid, &email_id) } + pub fn delete_email(&self, uid: String, email_id: String) -> Result { + let uid = Self::validate_uid(uid)?; + let email_id = Self::validate_non_empty(email_id, "Email ID is required.")?; + self.repository.delete_email(&uid, &email_id) + } + pub fn remove(&self, uid: String) -> Result<(), String> { let uid = Self::validate_uid(uid)?; self.repository.remove_phone(&uid) @@ -171,6 +200,23 @@ impl PhoneStateService { } } + fn default_subject(value: String) -> String { + let value = value.trim().to_string(); + if value.is_empty() { + "No subject".to_string() + } else { + value + } + } + + fn validate_send_target(from_uid: &str, to_uid: &str, message: &str) -> Result<(), String> { + if to_uid == FIELD_COMMANDER_UID && from_uid != FIELD_COMMANDER_UID { + Err(message.to_string()) + } else { + Ok(()) + } + } + fn parse_timestamp(timestamp: String) -> f64 { timestamp.trim().parse::().unwrap_or_default() } @@ -212,13 +258,180 @@ mod tests { } #[test] - fn contact_cannot_reference_self() { + fn contact_can_reference_self_for_owner_card() { let service = PhoneStateService::new(InMemoryPhoneRepository::new()); assert!( service .add_contact("same".to_string(), "same".to_string()) + .expect("self contact should be allowed") + ); + } + + #[test] + fn init_seeds_owner_and_field_commander_contacts() { + let service = PhoneStateService::new(InMemoryPhoneRepository::new()); + + let payload = service + .init("player".to_string()) + .expect("phone should initialize"); + + assert!(payload.contacts.iter().any(|uid| uid == "player")); + assert!(payload.contacts.iter().any(|uid| uid == "field_commander")); + } + + #[test] + fn player_cannot_message_field_commander() { + let service = PhoneStateService::new(InMemoryPhoneRepository::new()); + + assert!( + service + .send_message( + "player".to_string(), + "field_commander".to_string(), + "Test".to_string(), + "123".to_string(), + ) .is_err() ); } + + #[test] + fn field_commander_can_message_player() { + let service = PhoneStateService::new(InMemoryPhoneRepository::new()); + + assert!( + service + .send_message( + "field_commander".to_string(), + "player".to_string(), + "Orders".to_string(), + "123".to_string(), + ) + .is_ok() + ); + } + + #[test] + fn player_cannot_email_field_commander() { + let service = PhoneStateService::new(InMemoryPhoneRepository::new()); + + assert!( + service + .send_email( + "player".to_string(), + "field_commander".to_string(), + "Subject".to_string(), + "Body".to_string(), + "123".to_string(), + ) + .is_err() + ); + } + + #[test] + fn email_allows_empty_subject() { + let service = PhoneStateService::new(InMemoryPhoneRepository::new()); + + let email = service + .send_email( + "player".to_string(), + "player".to_string(), + "".to_string(), + "Body".to_string(), + "123".to_string(), + ) + .expect("email should allow empty subject"); + + assert_eq!(email.subject, "No subject"); + } + + #[test] + fn self_message_is_indexed_once() { + let service = PhoneStateService::new(InMemoryPhoneRepository::new()); + + service + .send_message( + "same".to_string(), + "same".to_string(), + "Test".to_string(), + "123".to_string(), + ) + .expect("self message should send"); + + assert_eq!( + service + .list_messages("same".to_string()) + .expect("self messages should load") + .len(), + 1 + ); + } + + #[test] + fn delete_message_removes_only_requesting_users_index() { + let service = PhoneStateService::new(InMemoryPhoneRepository::new()); + + let message = service + .send_message( + "sender".to_string(), + "receiver".to_string(), + "Test".to_string(), + "123".to_string(), + ) + .expect("message should send"); + + assert!( + service + .delete_message("sender".to_string(), message.id.clone()) + .expect("message should delete") + ); + assert!( + service + .list_messages("sender".to_string()) + .expect("sender messages should load") + .is_empty() + ); + assert_eq!( + service + .list_messages("receiver".to_string()) + .expect("receiver messages should load") + .len(), + 1 + ); + } + + #[test] + fn delete_email_removes_requesting_users_index() { + let service = PhoneStateService::new(InMemoryPhoneRepository::new()); + + let email = service + .send_email( + "sender".to_string(), + "receiver".to_string(), + "Subject".to_string(), + "Body".to_string(), + "123".to_string(), + ) + .expect("email should send"); + + assert!( + service + .delete_email("receiver".to_string(), email.id.clone()) + .expect("email should delete") + ); + assert!( + service + .list_emails("receiver".to_string()) + .expect("receiver emails should load") + .is_empty() + ); + assert_eq!( + service + .list_emails("sender".to_string()) + .expect("sender emails should remain") + .len(), + 1 + ); + } }