Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions dnas/relay/zomes/coordinator/relay/src/message.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,16 @@ pub fn create_message(input: SendMessageInput) -> ExternResult<Record> {
(),
)?;

// Create reply link if this is a reply
if let Some(reply_to_hash) = &input.message.reply_to {
create_link(
reply_to_hash.clone(),
message_hash.clone(),
LinkTypes::MessageReplies,
(),
)?;
}

// Signal other agents that a message was created
let my_pub_key = agent_info()?.agent_initial_pubkey;
let agents = input
Expand Down Expand Up @@ -341,3 +351,20 @@ pub fn get_oldest_delete_for_message(
});
Ok(deletes.first().cloned())
}

/// Get reply count for a message (for thread indicators)
#[hdk_extern]
pub fn get_reply_count(message_hash: ActionHash) -> ExternResult<usize> {
let links = get_links(
LinkQuery {
base: message_hash.into(),
link_type: LinkTypes::MessageReplies.try_into_filter()?,
tag_prefix: None,
after: None,
before: None,
author: None,
},
GetStrategy::Network,
)?;
Ok(links.len())
}
35 changes: 35 additions & 0 deletions dnas/relay/zomes/integrity/relay/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ pub enum LinkTypes {
ContactToContacts,
ContactUpdates,
AllContacts,
MessageReplies,
RoomParticipants,
ActiveCalls,
RoomIdToConference,
Expand Down Expand Up @@ -381,6 +382,14 @@ pub fn validate(op: Op) -> ExternResult<ValidateCallbackResult> {
tag,
)
}
LinkTypes::MessageReplies => {
validate_create_link_message_replies(
action,
base_address,
target_address,
tag,
)
}
LinkTypes::RoomParticipants => {
Ok(ValidateCallbackResult::Valid)
}
Expand Down Expand Up @@ -452,6 +461,15 @@ pub fn validate(op: Op) -> ExternResult<ValidateCallbackResult> {
tag,
)
}
LinkTypes::MessageReplies => {
validate_delete_link_message_replies(
action,
original_action,
base_address,
target_address,
tag,
)
}
LinkTypes::RoomParticipants => {
Ok(ValidateCallbackResult::Valid)
}
Expand Down Expand Up @@ -731,6 +749,14 @@ pub fn validate(op: Op) -> ExternResult<ValidateCallbackResult> {
tag,
)
}
LinkTypes::MessageReplies => {
validate_create_link_message_replies(
action,
base_address,
target_address,
tag,
)
}
LinkTypes::RoomParticipants => {
Ok(ValidateCallbackResult::Valid)
}
Expand Down Expand Up @@ -816,6 +842,15 @@ pub fn validate(op: Op) -> ExternResult<ValidateCallbackResult> {
create_link.tag,
)
}
LinkTypes::MessageReplies => {
validate_delete_link_message_replies(
action,
create_link.clone(),
base_address,
create_link.target_address,
create_link.tag,
)
}
LinkTypes::RoomParticipants => {
Ok(ValidateCallbackResult::Valid)
}
Expand Down
47 changes: 47 additions & 0 deletions dnas/relay/zomes/integrity/relay/src/message.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ pub struct Message {
pub content: String,
pub bucket: u32,
pub images: Vec<File>,
pub reply_to: Option<ActionHash>,
}

#[derive(Serialize, Deserialize, Debug)]
Expand Down Expand Up @@ -135,3 +136,49 @@ pub fn validate_delete_link_all_messages(
) -> ExternResult<ValidateCallbackResult> {
Ok(ValidateCallbackResult::Valid)
}

pub fn validate_create_link_message_replies(
_action: CreateLink,
base_address: AnyLinkableHash,
target_address: AnyLinkableHash,
_tag: LinkTag,
) -> ExternResult<ValidateCallbackResult> {
let base_hash = base_address
.into_action_hash()
.ok_or(wasm_error!(WasmErrorInner::Guest(
"No action hash associated with link".to_string()
)))?;
let base_record = must_get_valid_record(base_hash)?;
let _base_message: Message = base_record
.entry()
.to_app_option()
.map_err(|e| wasm_error!(e))?
.ok_or(wasm_error!(WasmErrorInner::Guest(
"Linked action must reference a Message".to_string()
)))?;
let target_hash = target_address
.into_action_hash()
.ok_or(wasm_error!(WasmErrorInner::Guest(
"No action hash associated with link".to_string()
)))?;
let target_record = must_get_valid_record(target_hash)?;
let _target_message: Message = target_record
.entry()
.to_app_option()
.map_err(|e| wasm_error!(e))?
.ok_or(wasm_error!(WasmErrorInner::Guest(
"Linked action must reference a Message".to_string()
)))?;
Ok(ValidateCallbackResult::Valid)
}

pub fn validate_delete_link_message_replies(
_action: DeleteLink,
_original_action: CreateLink,
_base: AnyLinkableHash,
_target: AnyLinkableHash,
_tag: LinkTag,
) -> ExternResult<ValidateCallbackResult> {
Ok(ValidateCallbackResult::Valid)
}

2 changes: 2 additions & 0 deletions ui/src/lib/svgIcons.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,8 @@ export const svgIcons: { [key: string]: string } = {
lock: '<svg width="100%" height="100%" fill="currentColor" viewBox="0 0 38 38" xmlns="http://www.w3.org/2000/svg"><path d="M19.0002 20.5834C18.5181 20.5777 18.0459 20.7199 17.6471 20.9907C17.2483 21.2616 16.9421 21.6481 16.7696 22.0983C16.5971 22.5485 16.5667 23.0407 16.6824 23.5087C16.7981 23.9766 17.0544 24.3979 17.4168 24.7159V26.9167C17.4168 27.3366 17.5836 27.7393 17.8806 28.0363C18.1775 28.3332 18.5802 28.5 19.0002 28.5C19.4201 28.5 19.8228 28.3332 20.1197 28.0363C20.4167 27.7393 20.5835 27.3366 20.5835 26.9167V24.7159C20.9459 24.3979 21.2022 23.9766 21.3179 23.5087C21.4336 23.0407 21.4032 22.5485 21.2307 22.0983C21.0583 21.6481 20.752 21.2616 20.3532 20.9907C19.9544 20.7199 19.4822 20.5777 19.0002 20.5834ZM26.9168 14.25V11.0834C26.9168 8.98372 26.0828 6.97009 24.5981 5.48542C23.1134 4.00076 21.0998 3.16669 19.0002 3.16669C16.9005 3.16669 14.8869 4.00076 13.4022 5.48542C11.9176 6.97009 11.0835 8.98372 11.0835 11.0834V14.25C9.82372 14.25 8.61554 14.7505 7.72474 15.6413C6.83394 16.5321 6.3335 17.7402 6.3335 19V30.0834C6.3335 31.3431 6.83394 32.5513 7.72474 33.4421C8.61554 34.3329 9.82372 34.8334 11.0835 34.8334H26.9168C28.1766 34.8334 29.3848 34.3329 30.2756 33.4421C31.1664 32.5513 31.6668 31.3431 31.6668 30.0834V19C31.6668 17.7402 31.1664 16.5321 30.2756 15.6413C29.3848 14.7505 28.1766 14.25 26.9168 14.25ZM14.2502 11.0834C14.2502 9.82358 14.7506 8.61539 15.6414 7.7246C16.5322 6.8338 17.7404 6.33335 19.0002 6.33335C20.2599 6.33335 21.4681 6.8338 22.3589 7.7246C23.2497 8.61539 23.7502 9.82358 23.7502 11.0834V14.25H14.2502V11.0834ZM28.5002 30.0834C28.5002 30.5033 28.3333 30.906 28.0364 31.2029C27.7395 31.4999 27.3368 31.6667 26.9168 31.6667H11.0835C10.6636 31.6667 10.2608 31.4999 9.96391 31.2029C9.66698 30.906 9.50016 30.5033 9.50016 30.0834V19C9.50016 18.5801 9.66698 18.1774 9.96391 17.8804C10.2608 17.5835 10.6636 17.4167 11.0835 17.4167H26.9168C27.3368 17.4167 27.7395 17.5835 28.0364 17.8804C28.3333 18.1774 28.5002 18.5801 28.5002 19V30.0834Z"/></svg>',
newConversation:
'<svg width="100%" height="100%" fill="currentColor" width="30" height="30" viewBox="0 0 30 30" xmlns="http://www.w3.org/2000/svg"><path d="M15 0C6.75 0 0 6 0 13.5C0 17.1 1.65 20.55 4.5 23.1V30L13.2 26.85C13.8 27 14.4 27 15 27C23.25 27 30 21 30 13.5C30 6 23.25 0 15 0ZM15 24C14.4 24 13.95 24 13.2 23.85H12.9L7.5 25.8V21.75L6.9 21.3C4.5 19.2 3 16.35 3 13.5C3 7.65 8.4 3 15 3C21.6 3 27 7.65 27 13.5C27 19.35 21.6 24 15 24Z"/><path d="M16.5 7.5H13.5V12H9V15H13.5V19.5H16.5V15H21V12H16.5V7.5Z"/></svg>',
reply:
'<svg width="100%" height="100%" fill="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M10 9V5L3 12L10 19V14.9C15 14.9 18.5 16.5 21 20C20 15 17 10 10 9Z"/></svg>',
newPerson:
'<svg width="100%" height="100%" fill="currentColor" viewBox="0 0 53 41" xmlns="http://www.w3.org/2000/svg"><path d="M36.8885 18.1583C38.6842 18.1583 40.4396 17.6258 41.9326 16.6282C43.4257 15.6306 44.5894 14.2126 45.2766 12.5536C45.9637 10.8946 46.1435 9.06909 45.7932 7.3079C45.4429 5.54672 44.5782 3.92897 43.3085 2.65923C42.0387 1.38948 40.421 0.524779 38.6598 0.174458C36.8986 -0.175864 35.0731 0.00393383 33.4141 0.691114C31.7551 1.37829 30.3371 2.54199 29.3395 4.03505C28.3419 5.52811 27.8094 7.28347 27.8094 9.07916C27.8094 11.4871 28.7659 13.7964 30.4686 15.4991C32.1713 17.2018 34.4806 18.1583 36.8885 18.1583ZM36.8885 4.53958C37.7864 4.53958 38.664 4.80582 39.4106 5.30464C40.1571 5.80345 40.739 6.51244 41.0825 7.34194C41.4261 8.17144 41.516 9.0842 41.3409 9.96479C41.1657 10.8454 40.7334 11.6543 40.0985 12.2891C39.4636 12.924 38.6547 13.3564 37.7742 13.5315C36.8936 13.7067 35.9808 13.6168 35.1513 13.2732C34.3218 12.9296 33.6128 12.3477 33.114 11.6012C32.6152 10.8547 32.3489 9.977 32.3489 9.07916C32.3489 7.87519 32.8272 6.72053 33.6786 5.86919C34.5299 5.01786 35.6846 4.53958 36.8885 4.53958ZM36.8885 22.6979C32.6746 22.6979 28.6333 24.3719 25.6536 27.3515C22.674 30.3312 21 34.3725 21 38.5864C21 39.1884 21.2391 39.7657 21.6648 40.1914C22.0905 40.6171 22.6678 40.8562 23.2698 40.8562C23.8718 40.8562 24.4491 40.6171 24.8748 40.1914C25.3004 39.7657 25.5396 39.1884 25.5396 38.5864C25.5396 35.5765 26.7353 32.6898 28.8636 30.5615C30.9919 28.4332 33.8786 27.2375 36.8885 27.2375C39.8985 27.2375 42.7851 28.4332 44.9134 30.5615C47.0418 32.6898 48.2375 35.5765 48.2375 38.5864C48.2375 39.1884 48.4766 39.7657 48.9023 40.1914C49.3279 40.6171 49.9053 40.8562 50.5073 40.8562C51.1092 40.8562 51.6866 40.6171 52.1122 40.1914C52.5379 39.7657 52.777 39.1884 52.777 38.5864C52.777 34.3725 51.1031 30.3312 48.1234 27.3515C45.1437 24.3719 41.1024 22.6979 36.8885 22.6979Z" /><path d="M8.60156 27.0469C8.60156 24.691 6.69178 22.7812 4.33594 22.7812H2.56641C1.14902 22.7812 0 21.6322 0 20.2148C0 18.7975 1.14902 17.6484 2.56641 17.6484H4.30078C6.67604 17.6484 8.60156 15.7229 8.60156 13.3477V11.5664C8.60156 10.149 9.75058 9 11.168 9C12.5854 9 13.7344 10.149 13.7344 11.5664V13.3594C13.7344 15.7282 15.6547 17.6484 18.0234 17.6484H19.7461C21.1635 17.6484 22.3125 18.7975 22.3125 20.2148C22.3125 21.6322 21.1635 22.7812 19.7461 22.7812H18C15.6442 22.7812 13.7344 24.691 13.7344 27.0469V28.7461C13.7344 30.1635 12.5854 31.3125 11.168 31.3125C9.75058 31.3125 8.60156 30.1635 8.60156 28.7461V27.0469Z" /></svg>',
person:
Expand Down
15 changes: 15 additions & 0 deletions ui/src/lib/types.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type {
ActionHash,
ActionHashB64,
AgentPubKey,
AgentPubKeyB64,
CellId,
Expand All @@ -14,6 +15,8 @@ import type {
Update,
} from "@holochain/client";

export type { ActionHashB64 };

/**
* App Signals
*/
Expand Down Expand Up @@ -135,12 +138,17 @@ export interface Message {
content: string;
bucket: number;
images: MessageFile[];
reply_to?: ActionHash;
}

export interface MessageExtended {
message: Message;
authorAgentPubKeyB64: AgentPubKeyB64;
timestamp: number;
replyToMessage?: MessageExtended;
replyCount?: number;
// True if this message has ever been replied to (independent of cache)
hasReplies?: boolean;
}

export interface MessageRecord {
Expand All @@ -149,6 +157,13 @@ export interface MessageRecord {
message?: Message;
}

export interface ThreadInfo {
rootMessageHash: ActionHashB64;
replyCount: number;
latestReplyTimestamp: number;
messages: MessageExtended[];
}

export type ConferenceLogEvent = "started" | "ended";

export interface ConferenceLog {
Expand Down
85 changes: 79 additions & 6 deletions ui/src/routes/conversations/[id]/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
import { goto } from "$app/navigation";
import Header from "$lib/Header.svelte";
import { t } from "$translations";
import { Privacy, type LocalFile } from "$lib/types";
import { Privacy, type LocalFile, type MessageExtended } from "$lib/types";
import ConversationMessageInput from "./ConversationMessageInput.svelte";
import ConversationEmpty from "./ConversationEmpty.svelte";
import ConversationMessages from "./ConversationMessages.svelte";
Expand All @@ -30,8 +30,12 @@
deriveCellMergedProfileContactInviteJoinedStore,
type MergedProfileContactInviteJoinedStore,
} from "$store/MergedProfileContactInviteJoinedStore";
import {
deriveCellMergedProfileContactInviteStore,
type MergedProfileContactInviteStore,
} from "$store/MergedProfileContactInviteStore";
import { POLLING_INTERVAL_FAST, POLLING_INTERVAL_SLOW } from "$config";
import SvgIcon from "$lib/SvgIcon.svelte";
import { deriveThreadViewEnabled } from "$store/ThreadViewStore";
import DialogConfirm from "$lib/DialogConfirm.svelte";
import ConversationHeader from "./ConversationHeader.svelte";
import InlineConferenceInvite from "./InlineConferenceInvite.svelte";
Expand All @@ -45,6 +49,9 @@
const mergedProfileContactInviteJoinedStore = getContext<{
getStore: () => MergedProfileContactInviteJoinedStore;
}>("mergedProfileContactInviteJoinedStore").getStore();
const mergedProfileContactInviteStore = getContext<{
getStore: () => MergedProfileContactInviteStore;
}>("mergedProfileContactInviteStore").getStore();
const myPubKeyB64 = getContext<{ getMyPubKeyB64: () => AgentPubKeyB64 }>(
"myPubKey",
).getMyPubKeyB64();
Expand All @@ -66,6 +73,11 @@
mergedProfileContactInviteJoinedStore,
$page.params.id,
);
let mergedProfileContact = deriveCellMergedProfileContactInviteStore(
mergedProfileContactInviteStore,
$page.params.id,
myPubKeyB64,
);

let configTimeout: NodeJS.Timeout;
let agentTimeout: NodeJS.Timeout;
Expand All @@ -80,13 +92,24 @@
let deleteMessageActionHashB64: undefined | ActionHashB64 = undefined;
let isDeletingMessage = false;

// Reply state
let replyToMessage: MessageExtended | undefined = undefined;
let replyToActionHash: ActionHashB64 | undefined = undefined;

let isStartingCall = false;

let isFirstConfigLoad = true;
let isFirstProfilesLoad = true;
let isFirstLoadMessages = true;

const threadViewEnabled = deriveThreadViewEnabled($page.params.id);

$: iAmProgenitor = $conversation.dnaProperties.progenitor === myPubKeyB64;
$: participantCount = $mergedProfileContact.list.length;
$: isSmallConversation = participantCount <= 2 || !$threadViewEnabled;
$: displayMessages = isSmallConversation
? $messages.list
: $messages.list.filter(([, msg]) => !msg.message.reply_to);

async function handleDeleteMessage() {
if (deleteMessageActionHashB64 === undefined) return;
Expand Down Expand Up @@ -207,22 +230,55 @@
loadingMessagesNew = false;
}

async function sendMessage(text: string, files: LocalFile[]) {
async function sendMessage(text: string, files: LocalFile[], replyTo?: ActionHashB64) {
if (sending) return;

// Focus on input field to ensure the keyboard remains open after sending message on android
conversationMessageInputRef.focus();

sending = true;
try {
await messages.sendMessage(text, files);
await messages.sendMessage(text, files, replyTo);

// Clear reply context
replyToMessage = undefined;
replyToActionHash = undefined;
} catch (e) {
console.error(e);
toast.error(`${$t("common.error_sending_message")}: ${(e as Error).message || e}`);
}
sending = false;
}

function handleReply(event: CustomEvent<ActionHashB64>) {
const actionHashB64 = event.detail;
console.log("[+page] handleReply called:", {
actionHashB64,
participantCount,
isSmallConversation,
});

if (isSmallConversation) {
// Small conversation: show inline reply context
replyToActionHash = actionHashB64;
replyToMessage = $messages.data[actionHashB64];
conversationMessageInputRef.focus();
} else {
// for arge conversation open thread view
// don't set reply context
openThreadView(actionHashB64);
}
}

function openThreadView(rootMessageHash: ActionHashB64) {
goto(`/conversations/${$page.params.id}/thread/${rootMessageHash}`);
}

function scrollToMessage(actionHashB64: ActionHashB64) {
// TODO: Implement scroll-to-message functionality
console.log("Scroll to message:", actionHashB64);
}

async function startVideoCall() {
if (isStartingCall) return;

Expand Down Expand Up @@ -370,11 +426,16 @@
<ConversationMessages
loadingTop={loadingMessagesOld}
cellIdB64={$page.params.id}
messages={$messages.list.reverse()}
messages={displayMessages.reverse()}
{participantCount}
threadViewEnabled={$threadViewEnabled}
on:delete={(e) => {
deleteMessageActionHashB64 = e.detail;
showDeleteDialog = true;
}}
on:reply={handleReply}
on:openThread={(e) => openThreadView(e.detail)}
on:scrollToMessage={(e) => scrollToMessage(e.detail)}
on:scrollAtTop={loadMoreMessages}
/>
</div>
Expand All @@ -386,9 +447,21 @@

<ConversationMessageInput
bind:ref={conversationMessageInputRef}
bind:replyToMessage
bind:replyToActionHash
cellIdB64={$page.params.id}
disabled={sending}
loading={sending}
on:send={(e) => sendMessage(e.detail.text, e.detail.files)}
on:send={(e) =>
sendMessage(
e.detail.text,
e.detail.files,
e.detail.replyTo ? encodeHashToBase64(e.detail.replyTo) : undefined,
)}
on:cancelReply={() => {
replyToMessage = undefined;
replyToActionHash = undefined;
}}
/>

<DialogConfirm
Expand Down
Loading