feat(mtp): move useful stuff over to mtp directly
All checks were successful
Dependency builds / Build web (pull_request) Has been skipped
Dependency builds / Build desktop (pull_request) Has been skipped
Dependency builds / Test native MTP (pull_request) Has been skipped
Dependency builds / Build mobile (pull_request) Has been skipped
/ build-web (push) Successful in 6m14s
/ build-desktop (linux) (push) Successful in 11m36s
/ build-mobile (push) Successful in 24m26s
/ release (push) Successful in 1m39s
All checks were successful
Dependency builds / Build web (pull_request) Has been skipped
Dependency builds / Build desktop (pull_request) Has been skipped
Dependency builds / Test native MTP (pull_request) Has been skipped
Dependency builds / Build mobile (pull_request) Has been skipped
/ build-web (push) Successful in 6m14s
/ build-desktop (linux) (push) Successful in 11m36s
/ build-mobile (push) Successful in 24m26s
/ release (push) Successful in 1m39s
This commit is contained in:
parent
094cb910aa
commit
0a304e44f2
22 changed files with 1217 additions and 1870 deletions
2
.cargo/config.toml
Normal file
2
.cargo/config.toml
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
[env]
|
||||||
|
MTP_TYPE_MAPS = { value = "mtp-type-maps/type-maps.yaml", relative = true }
|
||||||
|
|
@ -1,2 +0,0 @@
|
||||||
[env]
|
|
||||||
MTP_TYPE_MAPS = { value = "../../mtp-type-maps/type-maps.yaml", relative = true }
|
|
||||||
|
|
@ -341,7 +341,7 @@ async fn supervise(generation: u64) {
|
||||||
let notification_config = config.clone();
|
let notification_config = config.clone();
|
||||||
let notification_worker = tokio::spawn(async move {
|
let notification_worker = tokio::spawn(async move {
|
||||||
while let Some(frame) = notification_rx.recv().await {
|
while let Some(frame) = notification_rx.recv().await {
|
||||||
if !manager().is_current(generation) {
|
if !manager.is_current(generation) {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
if let Err(error) = notify_message(
|
if let Err(error) = notify_message(
|
||||||
|
|
@ -497,7 +497,7 @@ fn prepare_initial_state_ack(
|
||||||
.ok_or("ClientStateSync payload is not an object")?;
|
.ok_or("ClientStateSync payload is not an object")?;
|
||||||
let session_id = required_integer(data, "SessionId")?;
|
let session_id = required_integer(data, "SessionId")?;
|
||||||
let version = required_integer(data, "VersionNumber")?;
|
let version = required_integer(data, "VersionNumber")?;
|
||||||
let ack = CommunicationValue::new(CommunicationType::ClientStateAck)
|
let ack = CommunicationValue::new(communication_type("ClientStateAck")?)
|
||||||
.with_id(request_ids.next()?)
|
.with_id(request_ids.next()?)
|
||||||
.add_typed_default(DataType::SessionId, number_to_data(session_id))
|
.add_typed_default(DataType::SessionId, number_to_data(session_id))
|
||||||
.add_typed_default(DataType::VersionNumber, number_to_data(version));
|
.add_typed_default(DataType::VersionNumber, number_to_data(version));
|
||||||
|
|
@ -645,7 +645,7 @@ async fn await_initial_state(
|
||||||
return Err("No Iota is currently connected".into());
|
return Err("No Iota is currently connected".into());
|
||||||
}
|
}
|
||||||
|
|
||||||
if frame.is_type(CommunicationType::ClientStateSync) {
|
if frame.get_type_name() == Some("ClientStateSync") {
|
||||||
return Ok((frame, buffered));
|
return Ok((frame, buffered));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -740,7 +740,7 @@ async fn notify_message(
|
||||||
let message = frame
|
let message = frame
|
||||||
.get_data(DataType::Message)
|
.get_data(DataType::Message)
|
||||||
.ok_or("MessageLive omitted Message")?;
|
.ok_or("MessageLive omitted Message")?;
|
||||||
let content = container_value(message, DataType::AppContent)
|
let content = container_value_by_name(message, "Content")
|
||||||
.and_then(DataValue::as_str)
|
.and_then(DataValue::as_str)
|
||||||
.ok_or("MessageLive omitted Content")?;
|
.ok_or("MessageLive omitted Content")?;
|
||||||
let keyring_bytes = decode_browser_base64(&config.keyring)?;
|
let keyring_bytes = decode_browser_base64(&config.keyring)?;
|
||||||
|
|
@ -888,42 +888,24 @@ fn container_value(value: &DataValue, field: DataType) -> Option<&DataValue> {
|
||||||
value.get_field(id)
|
value.get_field(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn wire_field_name(name: &str) -> &str {
|
fn container_value_by_name<'a>(value: &'a DataValue, field: &str) -> Option<&'a DataValue> {
|
||||||
match name {
|
container_value(value, DataType::from_name(field)?)
|
||||||
"Content" => "AppContent",
|
|
||||||
"CreatedAt" => "AppCreatedAt",
|
|
||||||
"MessageId" => "AppMessageId",
|
|
||||||
_ => name,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn application_field_name(name: &str) -> &str {
|
fn communication_type(name: &str) -> Result<CommunicationType, String> {
|
||||||
match name {
|
CommunicationType::from_name(name).ok_or_else(|| format!("unknown communication type: {name}"))
|
||||||
"AppContent" => "Content",
|
|
||||||
"AppCreatedAt" => "CreatedAt",
|
|
||||||
"AppMessageId" => "MessageId",
|
|
||||||
_ => name,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn json_to_frame(type_name: &str, data: Value, id: u32) -> Result<CommunicationValue, String> {
|
fn json_to_frame(type_name: &str, data: Value, id: u32) -> Result<CommunicationValue, String> {
|
||||||
let comm_type = CommunicationType::from_name(type_name)
|
let comm_type = communication_type(type_name)?;
|
||||||
.ok_or_else(|| format!("unknown communication type: {type_name}"))?;
|
|
||||||
let mut frame = CommunicationValue::new(comm_type).with_id(id);
|
let mut frame = CommunicationValue::new(comm_type).with_id(id);
|
||||||
let Value::Object(fields) = data else {
|
let Value::Object(fields) = data else {
|
||||||
return Err("MTP request data must be an object".into());
|
return Err("MTP request data must be an object".into());
|
||||||
};
|
};
|
||||||
let mut translated_fields = std::collections::HashSet::<String>::with_capacity(fields.len());
|
|
||||||
for (name, value) in fields {
|
for (name, value) in fields {
|
||||||
let wire_name = wire_field_name(&name).to_owned();
|
|
||||||
if !translated_fields.insert(wire_name.clone()) {
|
|
||||||
return Err(format!(
|
|
||||||
"duplicate MTP field after translation: {wire_name}"
|
|
||||||
));
|
|
||||||
}
|
|
||||||
let data_type =
|
let data_type =
|
||||||
DataType::from_name(&wire_name).ok_or_else(|| format!("unknown data type: {name}"))?;
|
DataType::from_name(&name).ok_or_else(|| format!("unknown data type: {name}"))?;
|
||||||
frame = frame.add_typed_default(data_type, json_to_data(&wire_name, value)?);
|
frame = frame.add_typed_default(data_type, json_to_data(&name, value)?);
|
||||||
}
|
}
|
||||||
Ok(frame)
|
Ok(frame)
|
||||||
}
|
}
|
||||||
|
|
@ -961,21 +943,13 @@ fn json_to_data(field: &str, value: Value) -> Result<DataValue, String> {
|
||||||
),
|
),
|
||||||
Value::Object(fields) => {
|
Value::Object(fields) => {
|
||||||
let mut entries = Vec::with_capacity(fields.len());
|
let mut entries = Vec::with_capacity(fields.len());
|
||||||
let mut translated_fields =
|
|
||||||
std::collections::HashSet::<String>::with_capacity(fields.len());
|
|
||||||
for (name, value) in fields {
|
for (name, value) in fields {
|
||||||
let wire_name = wire_field_name(&name).to_owned();
|
let data_type = DataType::from_name(&name)
|
||||||
if !translated_fields.insert(wire_name.clone()) {
|
|
||||||
return Err(format!(
|
|
||||||
"duplicate MTP field after translation: {wire_name}"
|
|
||||||
));
|
|
||||||
}
|
|
||||||
let data_type = DataType::from_name(&wire_name)
|
|
||||||
.ok_or_else(|| format!("unknown nested data type: {name}"))?;
|
.ok_or_else(|| format!("unknown nested data type: {name}"))?;
|
||||||
let id = data_type
|
let id = data_type
|
||||||
.try_to_id(&TypeMap::latest())
|
.try_to_id(&TypeMap::latest())
|
||||||
.ok_or_else(|| format!("unmapped data type: {name}"))?;
|
.ok_or_else(|| format!("unmapped data type: {name}"))?;
|
||||||
entries.push((id, json_to_data(&wire_name, value)?));
|
entries.push((id, json_to_data(&name, value)?));
|
||||||
}
|
}
|
||||||
DataValue::Container(entries)
|
DataValue::Container(entries)
|
||||||
}
|
}
|
||||||
|
|
@ -1020,13 +994,7 @@ fn frame_data_to_json(frame: &CommunicationValue) -> Result<Value, String> {
|
||||||
let name = map
|
let name = map
|
||||||
.data_type_name(id.0)
|
.data_type_name(id.0)
|
||||||
.ok_or_else(|| format!("unknown data type id: {}", id.0))?;
|
.ok_or_else(|| format!("unknown data type id: {}", id.0))?;
|
||||||
let application_name = application_field_name(name);
|
result.insert(name.to_owned(), data_to_json(value, &map)?);
|
||||||
if result.contains_key(application_name) {
|
|
||||||
return Err(format!(
|
|
||||||
"duplicate MTP field after translation: {application_name}"
|
|
||||||
));
|
|
||||||
}
|
|
||||||
result.insert(application_name.to_owned(), data_to_json(value, &map)?);
|
|
||||||
}
|
}
|
||||||
Ok(Value::Object(result))
|
Ok(Value::Object(result))
|
||||||
}
|
}
|
||||||
|
|
@ -1057,13 +1025,7 @@ fn data_to_json(value: &DataValue, map: &TypeMap) -> Result<Value, String> {
|
||||||
let name = map
|
let name = map
|
||||||
.data_type_name(id.0)
|
.data_type_name(id.0)
|
||||||
.ok_or_else(|| format!("unknown nested data type id: {}", id.0))?;
|
.ok_or_else(|| format!("unknown nested data type id: {}", id.0))?;
|
||||||
let application_name = application_field_name(name);
|
object.insert(name.to_owned(), data_to_json(value, map)?);
|
||||||
if object.contains_key(application_name) {
|
|
||||||
return Err(format!(
|
|
||||||
"duplicate MTP field after translation: {application_name}"
|
|
||||||
));
|
|
||||||
}
|
|
||||||
object.insert(application_name.to_owned(), data_to_json(value, map)?);
|
|
||||||
}
|
}
|
||||||
Value::Object(object)
|
Value::Object(object)
|
||||||
}
|
}
|
||||||
|
|
@ -1084,7 +1046,7 @@ mod tests {
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
|
|
||||||
use super::{
|
use super::{
|
||||||
container_value, decode_browser_base64, decode_sdk_bytes, frame_to_json,
|
container_value_by_name, decode_browser_base64, decode_sdk_bytes, frame_to_json,
|
||||||
jittered_retry_delay, json_to_frame, prepare_initial_state_ack, RequestIdAllocator,
|
jittered_retry_delay, json_to_frame, prepare_initial_state_ack, RequestIdAllocator,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -1117,7 +1079,7 @@ mod tests {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn json_content_uses_app_content_wire_type() {
|
fn json_content_uses_content_wire_type() {
|
||||||
let frame = json_to_frame(
|
let frame = json_to_frame(
|
||||||
"MessageEdit",
|
"MessageEdit",
|
||||||
json!({
|
json!({
|
||||||
|
|
@ -1131,14 +1093,14 @@ mod tests {
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
frame
|
frame
|
||||||
.get_data(DataType::AppContent)
|
.get_data(DataType::Content)
|
||||||
.and_then(DataValue::as_str),
|
.and_then(DataValue::as_str),
|
||||||
Some("ciphertext")
|
Some("ciphertext")
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn nested_json_content_uses_app_content_wire_type() {
|
fn nested_json_content_uses_content_wire_type() {
|
||||||
let frame = json_to_frame(
|
let frame = json_to_frame(
|
||||||
"MessageEdit",
|
"MessageEdit",
|
||||||
json!({
|
json!({
|
||||||
|
|
@ -1150,35 +1112,24 @@ mod tests {
|
||||||
let message = frame.get_data(DataType::Message).unwrap();
|
let message = frame.get_data(DataType::Message).unwrap();
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
container_value(message, DataType::AppContent).and_then(DataValue::as_str),
|
container_value_by_name(message, "Content").and_then(DataValue::as_str),
|
||||||
Some("ciphertext")
|
Some("ciphertext")
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn app_content_is_exposed_as_content_to_frontend() {
|
fn content_is_exposed_to_frontend() {
|
||||||
let frame = CommunicationValue::new(CommunicationType::MessageEditLive)
|
let frame = CommunicationValue::new(CommunicationType::MessageEditLive)
|
||||||
.with_id(1)
|
.with_id(1)
|
||||||
.add_typed_default(DataType::AppContent, DataValue::Str("ciphertext".into()));
|
.add_typed_default(DataType::Content, DataValue::Str("ciphertext".into()));
|
||||||
|
|
||||||
let json = frame_to_json(&frame).unwrap();
|
let json = frame_to_json(&frame).unwrap();
|
||||||
|
|
||||||
assert_eq!(json["data"]["Content"], "ciphertext");
|
assert_eq!(json["data"]["Content"], "ciphertext");
|
||||||
assert!(json["data"].get("AppContent").is_none());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn translated_field_collisions_are_rejected() {
|
|
||||||
assert!(json_to_frame(
|
|
||||||
"MessageEdit",
|
|
||||||
json!({ "Content": "a", "AppContent": "b" }),
|
|
||||||
1,
|
|
||||||
)
|
|
||||||
.is_err());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn valid_initial_state() -> CommunicationValue {
|
fn valid_initial_state() -> CommunicationValue {
|
||||||
CommunicationValue::new(CommunicationType::ClientStateSync)
|
CommunicationValue::new(communication_type("ClientStateSync").unwrap())
|
||||||
.add_typed_default(DataType::SessionId, DataValue::UnsignedNumber(1))
|
.add_typed_default(DataType::SessionId, DataValue::UnsignedNumber(1))
|
||||||
.add_typed_default(DataType::VersionNumber, DataValue::UnsignedNumber(0))
|
.add_typed_default(DataType::VersionNumber, DataValue::UnsignedNumber(0))
|
||||||
.add_typed_default(DataType::CacheSchemaVersion, DataValue::UnsignedNumber(0))
|
.add_typed_default(DataType::CacheSchemaVersion, DataValue::UnsignedNumber(0))
|
||||||
|
|
@ -1195,7 +1146,7 @@ mod tests {
|
||||||
let (state, ack) = prepare_initial_state_ack(&valid_initial_state(), &ids).unwrap();
|
let (state, ack) = prepare_initial_state_ack(&valid_initial_state(), &ids).unwrap();
|
||||||
|
|
||||||
assert_eq!(state["SyncMode"], "full");
|
assert_eq!(state["SyncMode"], "full");
|
||||||
assert!(ack.is_type(CommunicationType::ClientStateAck));
|
assert_eq!(ack.get_type_name(), Some("ClientStateAck"));
|
||||||
assert_eq!(ack.id(), Some(1));
|
assert_eq!(ack.id(), Some(1));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1216,7 +1167,7 @@ mod tests {
|
||||||
#[test]
|
#[test]
|
||||||
fn malformed_nested_initial_state_does_not_prepare_ack() {
|
fn malformed_nested_initial_state_does_not_prepare_ack() {
|
||||||
let ids = RequestIdAllocator::new();
|
let ids = RequestIdAllocator::new();
|
||||||
let malformed = CommunicationValue::new(CommunicationType::ClientStateSync)
|
let malformed = CommunicationValue::new(communication_type("ClientStateSync").unwrap())
|
||||||
.add_typed_default(DataType::SessionId, DataValue::UnsignedNumber(1))
|
.add_typed_default(DataType::SessionId, DataValue::UnsignedNumber(1))
|
||||||
.add_typed_default(DataType::VersionNumber, DataValue::UnsignedNumber(0))
|
.add_typed_default(DataType::VersionNumber, DataValue::UnsignedNumber(0))
|
||||||
.add_typed_default(DataType::CacheSchemaVersion, DataValue::UnsignedNumber(0))
|
.add_typed_default(DataType::CacheSchemaVersion, DataValue::UnsignedNumber(0))
|
||||||
|
|
|
||||||
18
packages/cache/src/sync.tsx
vendored
18
packages/cache/src/sync.tsx
vendored
|
|
@ -21,8 +21,7 @@ export function removeMissingContactSnapshots<T extends { UserId: number }>(
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function CacheSync() {
|
export default function CacheSync() {
|
||||||
const { addInterceptor, contextReady, freshContacts, subscribePush } =
|
const { addInterceptor, contextReady, freshContacts, subscribe } = useMTP();
|
||||||
useMTP();
|
|
||||||
const { load } = useStorage();
|
const { load } = useStorage();
|
||||||
const [accountId, setAccountId] = useState(0);
|
const [accountId, setAccountId] = useState(0);
|
||||||
const queueRef = useRef(Promise.resolve());
|
const queueRef = useRef(Promise.resolve());
|
||||||
|
|
@ -321,10 +320,19 @@ export default function CacheSync() {
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!accountId || !contextReady) return;
|
if (!accountId || !contextReady) return;
|
||||||
return subscribePush((message) => {
|
const handleMessage = (message: ProtocolMessage) => {
|
||||||
void enqueue(() => synchronizePush(message));
|
void enqueue(() => synchronizePush(message));
|
||||||
});
|
};
|
||||||
}, [accountId, contextReady, enqueue, subscribePush, synchronizePush]);
|
const unsubscribers = [
|
||||||
|
subscribe("GetStates", handleMessage),
|
||||||
|
subscribe("MessageLive", handleMessage),
|
||||||
|
subscribe("MessageEditLive", handleMessage),
|
||||||
|
subscribe("MessageDeleteLive", handleMessage),
|
||||||
|
subscribe("MessageState", handleMessage),
|
||||||
|
subscribe("MessageReactionLive", handleMessage),
|
||||||
|
];
|
||||||
|
return () => unsubscribers.forEach((unsubscribe) => unsubscribe());
|
||||||
|
}, [accountId, contextReady, enqueue, subscribe, synchronizePush]);
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1223,7 +1223,7 @@ export const useCall = create<{
|
||||||
export function useInitializeCall() {
|
export function useInitializeCall() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const { send, subscribePush } = useMTP();
|
const { send, subscribe } = useMTP();
|
||||||
const { load } = useStorage();
|
const { load } = useStorage();
|
||||||
const { insertCall } = useSession();
|
const { insertCall } = useSession();
|
||||||
const { get } = useUser();
|
const { get } = useUser();
|
||||||
|
|
@ -1313,14 +1313,9 @@ export function useInitializeCall() {
|
||||||
|
|
||||||
// listen to call invites
|
// listen to call invites
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return subscribePush(async (message) => {
|
return subscribe("CallInvite", async ({ data }) => {
|
||||||
if (message.type !== "CallInvite") return;
|
const { CallId, CallSecret, SenderId } = data;
|
||||||
|
if (!CallId || !CallSecret || !SenderId) return;
|
||||||
const { CallId, CallSecret, SenderId } = message.data as {
|
|
||||||
CallId: string;
|
|
||||||
CallSecret: ProtocolCallSecret;
|
|
||||||
SenderId: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
if (SenderId === Number(await load("user_id"))) {
|
if (SenderId === Number(await load("user_id"))) {
|
||||||
return;
|
return;
|
||||||
|
|
@ -1337,7 +1332,7 @@ export function useInitializeCall() {
|
||||||
SenderId,
|
SenderId,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
}, [load, subscribePush, showCallingScreen]);
|
}, [load, subscribe, showCallingScreen]);
|
||||||
|
|
||||||
// get callId from url
|
// get callId from url
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
|
||||||
|
|
@ -226,7 +226,7 @@ export async function fetchReplyMessage({
|
||||||
|
|
||||||
export default function Provider({ children }: { children: ReactNode }) {
|
export default function Provider({ children }: { children: ReactNode }) {
|
||||||
const { load } = useStorage();
|
const { load } = useStorage();
|
||||||
const { send, subscribePush } = useMTP();
|
const { send, subscribe } = useMTP();
|
||||||
const { get: getUser } = useUser();
|
const { get: getUser } = useUser();
|
||||||
const { moveUserIdToTop } = useSession();
|
const { moveUserIdToTop } = useSession();
|
||||||
|
|
||||||
|
|
@ -912,30 +912,9 @@ export default function Provider({ children }: { children: ReactNode }) {
|
||||||
|
|
||||||
// Get live updates for message states
|
// Get live updates for message states
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return subscribePush((message) => {
|
const unsubscribeEdit = subscribe("MessageEditLive", ({ data }) => {
|
||||||
if (message.type === "MessageEditLive") {
|
|
||||||
if (!currentChatSecret) return;
|
if (!currentChatSecret) return;
|
||||||
|
if (data.ChatPartnerId !== userIdValue) {
|
||||||
const rawData = message.data as {
|
|
||||||
ChatPartnerId: unknown;
|
|
||||||
SendTime: unknown;
|
|
||||||
Content: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
const chatPartnerId = Number(rawData.ChatPartnerId);
|
|
||||||
const sendTime = Number(rawData.SendTime);
|
|
||||||
|
|
||||||
if (!Number.isFinite(chatPartnerId) || !Number.isFinite(sendTime)) {
|
|
||||||
log(
|
|
||||||
3,
|
|
||||||
"chat",
|
|
||||||
"yellow",
|
|
||||||
"Cancel message edit update due to invalid data",
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (chatPartnerId !== userIdValue) {
|
|
||||||
log(
|
log(
|
||||||
3,
|
3,
|
||||||
"chat",
|
"chat",
|
||||||
|
|
@ -943,93 +922,37 @@ export default function Provider({ children }: { children: ReactNode }) {
|
||||||
"Cancel message edit update due to user ID mismatch",
|
"Cancel message edit update due to user ID mismatch",
|
||||||
{
|
{
|
||||||
expected: userIdValue,
|
expected: userIdValue,
|
||||||
received: chatPartnerId,
|
received: data.ChatPartnerId,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
void decryptChatText(currentChatSecret, rawData.Content)
|
void decryptChatText(currentChatSecret, data.Content)
|
||||||
.then((content) => {
|
.then((content) => {
|
||||||
editMessage(sendTime, { Content: content, Edited: true });
|
editMessage(data.SendTime, { Content: content, Edited: true });
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
log(1, "chat", "red", "Failed to decrypt message edit", err, {
|
log(1, "chat", "red", "Failed to decrypt message edit", err, {
|
||||||
SendTime: sendTime,
|
SendTime: data.SendTime,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
return;
|
});
|
||||||
}
|
const unsubscribeReaction = subscribe("MessageReactionLive", ({ data }) => {
|
||||||
|
|
||||||
if (message.type === "MessageReactionLive") {
|
|
||||||
const rawData = message.data as {
|
|
||||||
ChatPartnerId: unknown;
|
|
||||||
SendTime: unknown;
|
|
||||||
Reaction: string;
|
|
||||||
SenderId: unknown;
|
|
||||||
Accepted: boolean;
|
|
||||||
};
|
|
||||||
const chatPartnerId = Number(rawData.ChatPartnerId);
|
|
||||||
const sendTime = Number(rawData.SendTime);
|
|
||||||
const senderId = Number(rawData.SenderId);
|
|
||||||
|
|
||||||
if (
|
|
||||||
chatPartnerId !== userIdValue ||
|
|
||||||
!Number.isFinite(sendTime) ||
|
|
||||||
!Number.isFinite(senderId)
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
applyLiveReaction(
|
|
||||||
sendTime,
|
|
||||||
rawData.Reaction,
|
|
||||||
senderId,
|
|
||||||
rawData.Accepted,
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (message.type === "MessageDeleteLive") {
|
|
||||||
const data = message.data as {
|
|
||||||
ChatPartnerId: number;
|
|
||||||
SendTime: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
if (data.ChatPartnerId !== userIdValue) return;
|
if (data.ChatPartnerId !== userIdValue) return;
|
||||||
|
applyLiveReaction(
|
||||||
removeMessage(data.SendTime);
|
data.SendTime,
|
||||||
return;
|
data.Reaction,
|
||||||
}
|
data.SenderId,
|
||||||
|
data.Accepted,
|
||||||
if (message.type !== "MessageState") return;
|
|
||||||
|
|
||||||
const rawData = message.data as {
|
|
||||||
ChatPartnerId: unknown;
|
|
||||||
SendTime: unknown;
|
|
||||||
MessageState: RawMessage["MessageState"];
|
|
||||||
};
|
|
||||||
|
|
||||||
const nextState = {
|
|
||||||
ChatPartnerId: Number(rawData.ChatPartnerId),
|
|
||||||
SendTime: Number(rawData.SendTime),
|
|
||||||
MessageState: rawData.MessageState,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (
|
|
||||||
!Number.isFinite(nextState.ChatPartnerId) ||
|
|
||||||
!Number.isFinite(nextState.SendTime)
|
|
||||||
) {
|
|
||||||
log(
|
|
||||||
3,
|
|
||||||
"chat",
|
|
||||||
"yellow",
|
|
||||||
"Cancel message state update due to invalid data",
|
|
||||||
);
|
);
|
||||||
return;
|
});
|
||||||
}
|
const unsubscribeDelete = subscribe("MessageDeleteLive", ({ data }) => {
|
||||||
|
if (data.ChatPartnerId !== userIdValue) return;
|
||||||
if (nextState.ChatPartnerId !== userIdValue) {
|
removeMessage(data.SendTime);
|
||||||
|
});
|
||||||
|
const unsubscribeState = subscribe("MessageState", ({ data }) => {
|
||||||
|
if (data.ChatPartnerId !== userIdValue) {
|
||||||
log(
|
log(
|
||||||
3,
|
3,
|
||||||
"chat",
|
"chat",
|
||||||
|
|
@ -1037,22 +960,27 @@ export default function Provider({ children }: { children: ReactNode }) {
|
||||||
"Cancel message state update due to user ID mismatch",
|
"Cancel message state update due to user ID mismatch",
|
||||||
{
|
{
|
||||||
expected: userIdValue,
|
expected: userIdValue,
|
||||||
received: nextState.ChatPartnerId,
|
received: data.ChatPartnerId,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
editMessage(data.SendTime, {
|
||||||
editMessage(nextState.SendTime, {
|
MessageState: data.MessageState,
|
||||||
MessageState: nextState.MessageState,
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
return () => {
|
||||||
|
unsubscribeEdit();
|
||||||
|
unsubscribeReaction();
|
||||||
|
unsubscribeDelete();
|
||||||
|
unsubscribeState();
|
||||||
|
};
|
||||||
}, [
|
}, [
|
||||||
currentChatSecret,
|
currentChatSecret,
|
||||||
applyLiveReaction,
|
applyLiveReaction,
|
||||||
editMessage,
|
editMessage,
|
||||||
removeMessage,
|
removeMessage,
|
||||||
subscribePush,
|
subscribe,
|
||||||
userIdValue,
|
userIdValue,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -159,7 +159,7 @@ function CopyableCode({
|
||||||
* @param input Parameter input.
|
* @param input Parameter input.
|
||||||
* @returns InlineNode[].
|
* @returns InlineNode[].
|
||||||
*/
|
*/
|
||||||
export function parseInlineNodes(input: string): InlineNode[] {
|
function parseInlineNodes(input: string): InlineNode[] {
|
||||||
const nodes: InlineNode[] = [];
|
const nodes: InlineNode[] = [];
|
||||||
|
|
||||||
let cursor = 0;
|
let cursor = 0;
|
||||||
|
|
@ -205,7 +205,7 @@ export function parseInlineNodes(input: string): InlineNode[] {
|
||||||
return nodes;
|
return nodes;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function parseEmojiText(input: string): InlineNode[] {
|
function parseEmojiText(input: string): InlineNode[] {
|
||||||
const nodes: InlineNode[] = [];
|
const nodes: InlineNode[] = [];
|
||||||
let cursor = 0;
|
let cursor = 0;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,19 +9,16 @@
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"format": "pnpm exec prettier --write .",
|
"format": "pnpm exec prettier --write .",
|
||||||
"lint": "eslint src --ext .ts,.tsx",
|
"lint": "eslint src --ext .ts,.tsx",
|
||||||
"test": "vitest run",
|
"test": "vitest run --passWithNoTests",
|
||||||
"build": "pnpm run test && tsc -p tsconfig.json --noEmit"
|
"build": "pnpm run test && tsc -p tsconfig.json --noEmit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@methanium/ui": "*",
|
"@methanium/ui": "*",
|
||||||
"@tauri-apps/api": "^2.11.1",
|
"@tauri-apps/api": "^2.11.1",
|
||||||
"@tensamin/crypto": "workspace:*",
|
|
||||||
"@tensamin/shared": "workspace:*",
|
"@tensamin/shared": "workspace:*",
|
||||||
"@tensamin/storage": "workspace:*",
|
"@tensamin/storage": "workspace:*",
|
||||||
"mtp": "*",
|
"mtp": "*",
|
||||||
"react": "^19.2.8",
|
"react": "^19.2.8"
|
||||||
"react-dom": "^19.2.8",
|
|
||||||
"zod": "^4.4.3"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"eslint": "^10.8.0"
|
"eslint": "^10.8.0"
|
||||||
|
|
|
||||||
529
packages/mtp/src/browser.tsx
Normal file
529
packages/mtp/src/browser.tsx
Normal file
|
|
@ -0,0 +1,529 @@
|
||||||
|
import { type ReactNode, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import { toast as sonnerToast } from "@methanium/ui";
|
||||||
|
import { base64ToBytes, ConnectionState, MTPClient } from "mtp";
|
||||||
|
import createAsyncQueue from "@tensamin/shared/asyncQueue";
|
||||||
|
import {
|
||||||
|
mtp as mtpSchemas,
|
||||||
|
type Calls,
|
||||||
|
type Communities,
|
||||||
|
type Contacts,
|
||||||
|
} from "@tensamin/shared/data";
|
||||||
|
import { log } from "@tensamin/shared/log";
|
||||||
|
import { useStorage } from "@tensamin/storage/context";
|
||||||
|
|
||||||
|
import {
|
||||||
|
type BoundSendFn,
|
||||||
|
MTPContext,
|
||||||
|
type MTPContextType,
|
||||||
|
type ProtocolMessage,
|
||||||
|
removeMissingContacts,
|
||||||
|
useMessageHandlers,
|
||||||
|
} from "./mtpContext";
|
||||||
|
import {
|
||||||
|
DISCOVERY_TIMEOUT,
|
||||||
|
INITIAL_SYNC_TIMEOUT,
|
||||||
|
RECONNECT_JITTER,
|
||||||
|
RECONNECT_LONG_INTERVAL,
|
||||||
|
RECONNECT_RESET,
|
||||||
|
RECONNECT_TRIES,
|
||||||
|
RETRY_INTERVAL,
|
||||||
|
STATE_ACK_TIMEOUT,
|
||||||
|
} from "./values";
|
||||||
|
|
||||||
|
type BrowserMtpClient = Awaited<ReturnType<typeof createBrowserClient>>;
|
||||||
|
|
||||||
|
function createBrowserClient(
|
||||||
|
options: Omit<Parameters<typeof MTPClient.create>[0], "schemas">,
|
||||||
|
) {
|
||||||
|
return MTPClient.create({
|
||||||
|
...options,
|
||||||
|
schemas: mtpSchemas,
|
||||||
|
throwProtocolErrors: true,
|
||||||
|
onValidationError: (error) => {
|
||||||
|
log(1, "mtp", "red", "Failed to validate push message", error);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function abortError(signal: AbortSignal): Error {
|
||||||
|
return signal.reason instanceof Error
|
||||||
|
? signal.reason
|
||||||
|
: new Error("Initial state synchronization was cancelled");
|
||||||
|
}
|
||||||
|
|
||||||
|
function withDeadline<T>(
|
||||||
|
promise: Promise<T>,
|
||||||
|
timeoutMs: number,
|
||||||
|
timeoutMessage: string,
|
||||||
|
signal: AbortSignal,
|
||||||
|
): Promise<T> {
|
||||||
|
return new Promise<T>((resolve, reject) => {
|
||||||
|
if (signal.aborted) {
|
||||||
|
reject(abortError(signal));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let settled = false;
|
||||||
|
const finish = (complete: () => void) => {
|
||||||
|
if (settled) return;
|
||||||
|
settled = true;
|
||||||
|
clearTimeout(timeout);
|
||||||
|
signal.removeEventListener("abort", onAbort);
|
||||||
|
complete();
|
||||||
|
};
|
||||||
|
const timeout = setTimeout(
|
||||||
|
() => finish(() => reject(new Error(timeoutMessage))),
|
||||||
|
timeoutMs,
|
||||||
|
);
|
||||||
|
const onAbort = () => finish(() => reject(abortError(signal)));
|
||||||
|
signal.addEventListener("abort", onAbort, { once: true });
|
||||||
|
promise.then(
|
||||||
|
(value) => finish(() => resolve(value)),
|
||||||
|
(error: unknown) => finish(() => reject(error)),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function completeInitialSynchronization(
|
||||||
|
client: BrowserMtpClient,
|
||||||
|
subscribe: MTPContextType["subscribe"],
|
||||||
|
signal: AbortSignal,
|
||||||
|
syncTimeoutMs = INITIAL_SYNC_TIMEOUT,
|
||||||
|
ackTimeoutMs = STATE_ACK_TIMEOUT,
|
||||||
|
): Promise<ProtocolMessage<"ClientStateSync">> {
|
||||||
|
const stateSync = new Promise<ProtocolMessage<"ClientStateSync">>(
|
||||||
|
(resolve, reject) => {
|
||||||
|
let unsubscribeStateSync = () => {};
|
||||||
|
let unsubscribeNoIota = () => {};
|
||||||
|
const cleanup = () => {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
unsubscribeStateSync();
|
||||||
|
unsubscribeNoIota();
|
||||||
|
signal.removeEventListener("abort", onAbort);
|
||||||
|
};
|
||||||
|
const onAbort = () => {
|
||||||
|
cleanup();
|
||||||
|
reject(abortError(signal));
|
||||||
|
};
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
cleanup();
|
||||||
|
reject(new Error("Initial state synchronization timed out"));
|
||||||
|
}, syncTimeoutMs);
|
||||||
|
signal.addEventListener("abort", onAbort, { once: true });
|
||||||
|
if (signal.aborted) {
|
||||||
|
onAbort();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
unsubscribeStateSync = subscribe("ClientStateSync", (message) => {
|
||||||
|
cleanup();
|
||||||
|
resolve(message);
|
||||||
|
});
|
||||||
|
unsubscribeNoIota = subscribe("ErrorNoIota", () => {
|
||||||
|
cleanup();
|
||||||
|
reject(new Error("No Iota is currently connected"));
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const [, state] = await Promise.all([
|
||||||
|
withDeadline(
|
||||||
|
client.auth(),
|
||||||
|
syncTimeoutMs,
|
||||||
|
"MTP authentication timed out",
|
||||||
|
signal,
|
||||||
|
),
|
||||||
|
stateSync,
|
||||||
|
]);
|
||||||
|
await withDeadline(
|
||||||
|
client.request("ClientStateAck", {
|
||||||
|
SessionId: state.data.SessionId,
|
||||||
|
VersionNumber: state.data.VersionNumber,
|
||||||
|
}),
|
||||||
|
ackTimeoutMs,
|
||||||
|
"State acknowledgement timed out",
|
||||||
|
signal,
|
||||||
|
);
|
||||||
|
if (signal.aborted) throw abortError(signal);
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
function protocolErrorDetails(error: unknown) {
|
||||||
|
if (typeof error !== "object" || error === null || !("type" in error)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const protocolError = error as {
|
||||||
|
id?: unknown;
|
||||||
|
type?: unknown;
|
||||||
|
frame?: unknown;
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
id: protocolError.id,
|
||||||
|
type: protocolError.type,
|
||||||
|
frame: protocolError.frame,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BrowserProvider(props: {
|
||||||
|
children: ReactNode;
|
||||||
|
blockConnection?: boolean;
|
||||||
|
}) {
|
||||||
|
const { load } = useStorage();
|
||||||
|
const [readyState, setReadyState] = useState<number>(
|
||||||
|
ConnectionState.Disconnected,
|
||||||
|
);
|
||||||
|
const [identified, setIdentified] = useState(false);
|
||||||
|
const [identifying, setIdentifying] = useState(false);
|
||||||
|
const [freshCommunities, setFreshCommunities] = useState<Communities>([]);
|
||||||
|
const [freshContacts, setFreshContacts] = useState<Contacts>([]);
|
||||||
|
const [freshCalls, setFreshCalls] = useState<Calls>([]);
|
||||||
|
const clientRef = useRef<BrowserMtpClient | null>(null);
|
||||||
|
const { addInterceptor, attachSubscriptions, interceptorsRef, subscribe } =
|
||||||
|
useMessageHandlers();
|
||||||
|
const connected = readyState === ConnectionState.Connected;
|
||||||
|
|
||||||
|
const [mtpUrl, setMtpUrl] = useState<string | null>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
load("omega_url").then(setMtpUrl);
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
const send: BoundSendFn = useMemo(
|
||||||
|
() => async (type, data, options) => {
|
||||||
|
const client = clientRef.current;
|
||||||
|
if (!client) throw new Error("mtp is not connected");
|
||||||
|
const response = await client.request(type, data, options);
|
||||||
|
if (response.type === "GetStates") {
|
||||||
|
setFreshContacts((contacts) =>
|
||||||
|
removeMissingContacts(
|
||||||
|
contacts,
|
||||||
|
response as ProtocolMessage<"GetStates">,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return response;
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const resolveConnectionRef = useRef(() => {});
|
||||||
|
useEffect(() => {
|
||||||
|
if (!mtpUrl) return;
|
||||||
|
let attempts = 0;
|
||||||
|
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
let reconnectResetTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
let reconnectScheduled = false;
|
||||||
|
let disposed = false;
|
||||||
|
let connectionGeneration = 0;
|
||||||
|
let cleanupConnection = () => {};
|
||||||
|
|
||||||
|
const clearReconnectTimer = () => {
|
||||||
|
if (!reconnectTimer) return;
|
||||||
|
clearTimeout(reconnectTimer);
|
||||||
|
reconnectTimer = null;
|
||||||
|
reconnectScheduled = false;
|
||||||
|
};
|
||||||
|
const clearReconnectResetTimer = () => {
|
||||||
|
if (!reconnectResetTimer) return;
|
||||||
|
clearTimeout(reconnectResetTimer);
|
||||||
|
reconnectResetTimer = null;
|
||||||
|
};
|
||||||
|
const scheduleReconnect = (error: unknown) => {
|
||||||
|
if (disposed || reconnectScheduled) return;
|
||||||
|
attempts += 1;
|
||||||
|
const shortRetry = attempts <= RECONNECT_TRIES;
|
||||||
|
if (!shortRetry) {
|
||||||
|
log(0, "mtp", "red", "Reconnection attempts exhausted", error);
|
||||||
|
sonnerToast.error("Connection failed", {
|
||||||
|
id: "mtp-connection-toast",
|
||||||
|
description:
|
||||||
|
error instanceof Error
|
||||||
|
? `${error.message.split(":")[0]}. Retrying in the background.`
|
||||||
|
: "Connection lost. Retrying in the background.",
|
||||||
|
icon: null,
|
||||||
|
duration: Infinity,
|
||||||
|
closeButton: true,
|
||||||
|
promise: null,
|
||||||
|
} as unknown as Parameters<typeof sonnerToast.error>[1]);
|
||||||
|
} else {
|
||||||
|
sonnerToast.loading(
|
||||||
|
`Reconnecting to server... (attempt ${attempts} of ${RECONNECT_TRIES})`,
|
||||||
|
{ id: "mtp-connection-toast" },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const baseDelay = shortRetry ? RETRY_INTERVAL : RECONNECT_LONG_INTERVAL;
|
||||||
|
const jitter = 1 + (Math.random() * 2 - 1) * RECONNECT_JITTER;
|
||||||
|
reconnectScheduled = true;
|
||||||
|
reconnectTimer = setTimeout(
|
||||||
|
() => {
|
||||||
|
reconnectScheduled = false;
|
||||||
|
reconnectTimer = null;
|
||||||
|
void connect();
|
||||||
|
},
|
||||||
|
Math.round(baseDelay * jitter),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
async function connect() {
|
||||||
|
if (disposed || props.blockConnection) return;
|
||||||
|
const generation = ++connectionGeneration;
|
||||||
|
let client: BrowserMtpClient | null = null;
|
||||||
|
let failed = false;
|
||||||
|
let connectionReady = false;
|
||||||
|
let detachSubscriptions = () => {};
|
||||||
|
let unsubscribeNoIota = () => {};
|
||||||
|
const attemptAbort = new AbortController();
|
||||||
|
const cleanup = () => {
|
||||||
|
attemptAbort.abort(
|
||||||
|
new Error("Initial state synchronization was cancelled"),
|
||||||
|
);
|
||||||
|
unsubscribeNoIota();
|
||||||
|
detachSubscriptions();
|
||||||
|
client?.disconnect();
|
||||||
|
if (clientRef.current === client) clientRef.current = null;
|
||||||
|
clearReconnectResetTimer();
|
||||||
|
if (generation === connectionGeneration) {
|
||||||
|
setReadyState(ConnectionState.Disconnected);
|
||||||
|
setIdentified(false);
|
||||||
|
setIdentifying(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
cleanupConnection = cleanup;
|
||||||
|
try {
|
||||||
|
setIdentified(false);
|
||||||
|
setIdentifying(false);
|
||||||
|
const [userId, keyring] = await Promise.all([
|
||||||
|
load("user_id"),
|
||||||
|
load("mtp_keyring"),
|
||||||
|
]);
|
||||||
|
if (!userId || !keyring) throw new Error("Missing login credentials");
|
||||||
|
const forcedOmikronUrl = await load("forced_omikron_url");
|
||||||
|
const forcedOmikronPublicKey = await load("forced_omikron_public_key");
|
||||||
|
let url = null;
|
||||||
|
let omikronPublicKey = null;
|
||||||
|
if (forcedOmikronUrl && forcedOmikronPublicKey) {
|
||||||
|
url = forcedOmikronUrl;
|
||||||
|
omikronPublicKey = forcedOmikronPublicKey;
|
||||||
|
} else {
|
||||||
|
log(2, "mtp", "purple", "Fetching Omikron data.");
|
||||||
|
const data = await fetch(`${mtpUrl}api/get/omikron/${userId}`, {
|
||||||
|
signal: AbortSignal.any([
|
||||||
|
attemptAbort.signal,
|
||||||
|
AbortSignal.timeout(DISCOVERY_TIMEOUT),
|
||||||
|
]),
|
||||||
|
});
|
||||||
|
if (data.status === 404) {
|
||||||
|
throw new Error("No Omikron assignment is currently available");
|
||||||
|
}
|
||||||
|
if (!data.ok)
|
||||||
|
throw new Error(`Omikron discovery failed: HTTP ${data.status}`);
|
||||||
|
const omikronData = (await data.json()) as {
|
||||||
|
ip_address: string;
|
||||||
|
port: number;
|
||||||
|
public_key: string;
|
||||||
|
};
|
||||||
|
if (
|
||||||
|
!omikronData.ip_address ||
|
||||||
|
!omikronData.port ||
|
||||||
|
!omikronData.public_key
|
||||||
|
) {
|
||||||
|
throw new Error("Invalid Omikron data");
|
||||||
|
}
|
||||||
|
url = `https://${omikronData.ip_address}:${omikronData.port}`;
|
||||||
|
omikronPublicKey = omikronData.public_key;
|
||||||
|
}
|
||||||
|
if (!url || !omikronPublicKey) {
|
||||||
|
throw new Error("Missing Omikron URL or Public Key");
|
||||||
|
}
|
||||||
|
log(2, "mtp", "green", "Connecting to: " + url);
|
||||||
|
client = await createBrowserClient({
|
||||||
|
url,
|
||||||
|
credentials: { clientId: userId, keyring: base64ToBytes(keyring) },
|
||||||
|
hostPublicKey: { value: omikronPublicKey, encoding: "base64" },
|
||||||
|
descriptor: "client",
|
||||||
|
pings: true,
|
||||||
|
logger: (event) => {
|
||||||
|
if (event.type === "state") {
|
||||||
|
if (generation !== connectionGeneration) return;
|
||||||
|
const state = client?.state ?? ConnectionState.Disconnected;
|
||||||
|
setReadyState(state);
|
||||||
|
if (
|
||||||
|
state === ConnectionState.Disconnected &&
|
||||||
|
clientRef.current === client &&
|
||||||
|
!failed
|
||||||
|
) {
|
||||||
|
failed = true;
|
||||||
|
const error = new Error("MTP connection lost");
|
||||||
|
attemptAbort.abort(error);
|
||||||
|
if (connectionReady) {
|
||||||
|
cleanup();
|
||||||
|
scheduleReconnect(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (event.type !== "Pong" && event.type !== "Ping") {
|
||||||
|
log(
|
||||||
|
2,
|
||||||
|
"mtp",
|
||||||
|
event.type === "state"
|
||||||
|
? "purple"
|
||||||
|
: event.direction === "recv"
|
||||||
|
? "cyan"
|
||||||
|
: event.direction === "send"
|
||||||
|
? "gray"
|
||||||
|
: "blue",
|
||||||
|
event.type === "state"
|
||||||
|
? event.data
|
||||||
|
: event.direction === "recv"
|
||||||
|
? "< " + event.type
|
||||||
|
: event.direction === "send"
|
||||||
|
? "> " + event.type
|
||||||
|
: event.type,
|
||||||
|
event,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (disposed || generation !== connectionGeneration) {
|
||||||
|
client.disconnect();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const activeClient = client;
|
||||||
|
clientRef.current = activeClient;
|
||||||
|
detachSubscriptions = attachSubscriptions(activeClient);
|
||||||
|
unsubscribeNoIota = subscribe("ErrorNoIota", () => {
|
||||||
|
if (clientRef.current !== activeClient || failed) return;
|
||||||
|
failed = true;
|
||||||
|
const error = new Error("No Iota is currently connected");
|
||||||
|
attemptAbort.abort(error);
|
||||||
|
cleanup();
|
||||||
|
scheduleReconnect(error);
|
||||||
|
});
|
||||||
|
setReadyState(activeClient.state);
|
||||||
|
setIdentifying(true);
|
||||||
|
const finalResponse = await completeInitialSynchronization(
|
||||||
|
activeClient,
|
||||||
|
subscribe,
|
||||||
|
attemptAbort.signal,
|
||||||
|
);
|
||||||
|
if (disposed || clientRef.current !== activeClient) return;
|
||||||
|
setFreshContacts(finalResponse.data.Contacts);
|
||||||
|
setFreshCommunities(finalResponse.data.Communities);
|
||||||
|
setFreshCalls(finalResponse.data.Calls);
|
||||||
|
connectionReady = true;
|
||||||
|
setIdentifying(false);
|
||||||
|
setIdentified(true);
|
||||||
|
clearReconnectTimer();
|
||||||
|
clearReconnectResetTimer();
|
||||||
|
reconnectResetTimer = setTimeout(() => {
|
||||||
|
attempts = 0;
|
||||||
|
reconnectResetTimer = null;
|
||||||
|
}, RECONNECT_RESET * 1_000);
|
||||||
|
resolveConnectionRef.current?.();
|
||||||
|
} catch (connectError) {
|
||||||
|
if (disposed || generation !== connectionGeneration) {
|
||||||
|
client?.disconnect();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
failed = true;
|
||||||
|
cleanup();
|
||||||
|
const message =
|
||||||
|
connectError instanceof Error
|
||||||
|
? connectError.message
|
||||||
|
: String(connectError ?? "Unknown error");
|
||||||
|
log(
|
||||||
|
0,
|
||||||
|
"mtp",
|
||||||
|
"red",
|
||||||
|
`Connection/authentication attempt failed: ${message}`,
|
||||||
|
protocolErrorDetails(connectError) ?? connectError,
|
||||||
|
);
|
||||||
|
scheduleReconnect(connectError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void connect();
|
||||||
|
return () => {
|
||||||
|
disposed = true;
|
||||||
|
clearReconnectTimer();
|
||||||
|
clearReconnectResetTimer();
|
||||||
|
cleanupConnection();
|
||||||
|
setReadyState(ConnectionState.Disconnected);
|
||||||
|
setIdentified(false);
|
||||||
|
setIdentifying(false);
|
||||||
|
sonnerToast.dismiss("mtp-connection-toast");
|
||||||
|
};
|
||||||
|
}, [attachSubscriptions, load, mtpUrl, props.blockConnection, subscribe]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return subscribe("ErrorNoIota", () => {
|
||||||
|
setIdentified(false);
|
||||||
|
setIdentifying(false);
|
||||||
|
sonnerToast.error("We couldn't reach your Iota", {
|
||||||
|
description:
|
||||||
|
"Check your network connection and try restarting your Iota",
|
||||||
|
icon: null,
|
||||||
|
duration: Infinity,
|
||||||
|
closeButton: true,
|
||||||
|
});
|
||||||
|
resolveConnectionRef.current?.();
|
||||||
|
});
|
||||||
|
}, [subscribe]);
|
||||||
|
|
||||||
|
useEffect(
|
||||||
|
() =>
|
||||||
|
subscribe("GetStates", (message) => {
|
||||||
|
setFreshContacts((contacts) =>
|
||||||
|
removeMissingContacts(contacts, message),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
[subscribe],
|
||||||
|
);
|
||||||
|
|
||||||
|
const loadingDescription = useMemo(() => {
|
||||||
|
if (!mtpUrl) return "Loading connection details";
|
||||||
|
if (readyState === ConnectionState.Connecting || !connected) {
|
||||||
|
return "Establishing transport channel";
|
||||||
|
}
|
||||||
|
if (identifying || !identified) return "Waiting for authenticated session";
|
||||||
|
return "Loading...";
|
||||||
|
}, [connected, identified, identifying, readyState, mtpUrl]);
|
||||||
|
const contextReady = connected && identified && mtpUrl !== null;
|
||||||
|
const mtpRef = useMemo(() => createAsyncQueue<{ send: typeof send }>(), []);
|
||||||
|
useEffect(() => {
|
||||||
|
if (connected && identified && mtpUrl) {
|
||||||
|
mtpRef.set({ send });
|
||||||
|
}
|
||||||
|
}, [connected, identified, mtpUrl, send, mtpRef]);
|
||||||
|
|
||||||
|
const sendQueued: BoundSendFn = useMemo(
|
||||||
|
() => async (type, data, options) => {
|
||||||
|
const mtp = await mtpRef.get();
|
||||||
|
const response = await mtp.send(type, data, options);
|
||||||
|
for (const interceptor of interceptorsRef.current) {
|
||||||
|
void Promise.resolve(interceptor({ type, data, response })).catch(
|
||||||
|
(error) => {
|
||||||
|
log(1, "mtp", "yellow", "MTP interceptor failed", error, { type });
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return response;
|
||||||
|
},
|
||||||
|
[interceptorsRef, mtpRef],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MTPContext.Provider
|
||||||
|
value={{
|
||||||
|
send: sendQueued,
|
||||||
|
subscribe,
|
||||||
|
addInterceptor,
|
||||||
|
readyState,
|
||||||
|
identified,
|
||||||
|
freshContacts,
|
||||||
|
freshCommunities,
|
||||||
|
freshCalls,
|
||||||
|
contextReady,
|
||||||
|
loadingDescription,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{props.children}
|
||||||
|
</MTPContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,220 +0,0 @@
|
||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
||||||
|
|
||||||
import { RequestIdAllocator } from "./requestIds";
|
|
||||||
|
|
||||||
vi.mock("@methanium/ui", () => ({
|
|
||||||
toast: {
|
|
||||||
dismiss: vi.fn(),
|
|
||||||
error: vi.fn(),
|
|
||||||
loading: vi.fn(),
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@tensamin/shared/log", () => ({ log: vi.fn() }));
|
|
||||||
vi.mock("@tensamin/storage/context", () => ({
|
|
||||||
useStorage: () => ({ load: vi.fn() }),
|
|
||||||
}));
|
|
||||||
|
|
||||||
const { completeInitialSynchronization, isPushType, validateResponse } =
|
|
||||||
await import("./context");
|
|
||||||
|
|
||||||
const validState = {
|
|
||||||
SessionId: 7,
|
|
||||||
VersionNumber: 2,
|
|
||||||
CacheSchemaVersion: 0,
|
|
||||||
SyncMode: "full",
|
|
||||||
Contacts: [],
|
|
||||||
Communities: [],
|
|
||||||
Calls: [],
|
|
||||||
Messages: [],
|
|
||||||
DeletedMessageIds: [],
|
|
||||||
DeletedContactIds: [],
|
|
||||||
};
|
|
||||||
|
|
||||||
function mockInitialSyncClient(
|
|
||||||
state: { type: string; data: unknown } = {
|
|
||||||
type: "ClientStateSync",
|
|
||||||
data: validState,
|
|
||||||
},
|
|
||||||
acknowledgement: unknown = { type: "ClientStateAck", data: {} },
|
|
||||||
) {
|
|
||||||
const handlers = new Map<string, (message: never) => void>();
|
|
||||||
const request = vi.fn().mockResolvedValue(acknowledgement);
|
|
||||||
const client = {
|
|
||||||
auth: vi.fn(async () => {
|
|
||||||
handlers.get(state.type)?.(state as never);
|
|
||||||
}),
|
|
||||||
subscribe: vi.fn((type: string, handler: (message: never) => void) => {
|
|
||||||
handlers.set(type, handler);
|
|
||||||
return () => handlers.delete(type);
|
|
||||||
}),
|
|
||||||
request,
|
|
||||||
disconnect: vi.fn(),
|
|
||||||
} as unknown as Parameters<typeof completeInitialSynchronization>[0];
|
|
||||||
return { client, handlers, request };
|
|
||||||
}
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
vi.useRealTimers();
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("MTP protocol dispatch", () => {
|
|
||||||
it("preserves protocol errors for the request layer", () => {
|
|
||||||
const error = validateResponse("GetStates", {
|
|
||||||
id: 12,
|
|
||||||
type: "ErrorInternal",
|
|
||||||
data: { ErrorType: "temporary" },
|
|
||||||
});
|
|
||||||
expect(error.type).toBe("ErrorInternal");
|
|
||||||
expect(error.id).toBe(12);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("recognizes initial and live presence pushes", () => {
|
|
||||||
expect(isPushType("GetStates")).toBe(true);
|
|
||||||
expect(isPushType("ClientChanged")).toBe(true);
|
|
||||||
expect(isPushType("UnknownMessage")).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("browser initial synchronization", () => {
|
|
||||||
it("validates state, sends a nonzero acknowledgement, then resolves", async () => {
|
|
||||||
const { client, request } = mockInitialSyncClient();
|
|
||||||
|
|
||||||
const state = await completeInitialSynchronization(
|
|
||||||
client,
|
|
||||||
new RequestIdAllocator(),
|
|
||||||
new AbortController().signal,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(state.data).toEqual(validState);
|
|
||||||
expect(request).toHaveBeenCalledWith(
|
|
||||||
"ClientStateAck",
|
|
||||||
{ SessionId: 7, VersionNumber: 2 },
|
|
||||||
{ id: 1 },
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not acknowledge malformed state", async () => {
|
|
||||||
const { client, request } = mockInitialSyncClient({
|
|
||||||
type: "ClientStateSync",
|
|
||||||
data: { ...validState, SyncMode: "invalid" },
|
|
||||||
});
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
completeInitialSynchronization(
|
|
||||||
client,
|
|
||||||
new RequestIdAllocator(),
|
|
||||||
new AbortController().signal,
|
|
||||||
),
|
|
||||||
).rejects.toThrow("Response validation failed");
|
|
||||||
expect(request).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects ErrorNoIota during initial synchronization", async () => {
|
|
||||||
const { client, request } = mockInitialSyncClient({
|
|
||||||
type: "ErrorNoIota",
|
|
||||||
data: {},
|
|
||||||
});
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
completeInitialSynchronization(
|
|
||||||
client,
|
|
||||||
new RequestIdAllocator(),
|
|
||||||
new AbortController().signal,
|
|
||||||
),
|
|
||||||
).rejects.toThrow("No Iota is currently connected");
|
|
||||||
expect(request).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects an acknowledgement protocol error", async () => {
|
|
||||||
const { client } = mockInitialSyncClient(undefined, {
|
|
||||||
type: "ErrorInvalidData",
|
|
||||||
data: {},
|
|
||||||
});
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
completeInitialSynchronization(
|
|
||||||
client,
|
|
||||||
new RequestIdAllocator(),
|
|
||||||
new AbortController().signal,
|
|
||||||
),
|
|
||||||
).rejects.toThrow("State acknowledgement failed: ErrorInvalidData");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("times out a missing acknowledgement", async () => {
|
|
||||||
vi.useFakeTimers();
|
|
||||||
const { client } = mockInitialSyncClient();
|
|
||||||
vi.mocked(client.request).mockReturnValue(new Promise(() => {}));
|
|
||||||
const result = completeInitialSynchronization(
|
|
||||||
client,
|
|
||||||
new RequestIdAllocator(),
|
|
||||||
new AbortController().signal,
|
|
||||||
100,
|
|
||||||
10,
|
|
||||||
);
|
|
||||||
const assertion = expect(result).rejects.toThrow(
|
|
||||||
"State acknowledgement timed out",
|
|
||||||
);
|
|
||||||
|
|
||||||
await vi.advanceTimersByTimeAsync(10);
|
|
||||||
await assertion;
|
|
||||||
});
|
|
||||||
|
|
||||||
it("stops immediately when the connection attempt is cancelled", async () => {
|
|
||||||
const { client } = mockInitialSyncClient();
|
|
||||||
vi.mocked(client.auth).mockImplementation(() => new Promise(() => {}));
|
|
||||||
const controller = new AbortController();
|
|
||||||
const result = completeInitialSynchronization(
|
|
||||||
client,
|
|
||||||
new RequestIdAllocator(),
|
|
||||||
controller.signal,
|
|
||||||
);
|
|
||||||
|
|
||||||
controller.abort(new Error("MTP connection lost"));
|
|
||||||
await expect(result).rejects.toThrow("MTP connection lost");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("times out authentication while waiting for initial state", async () => {
|
|
||||||
vi.useFakeTimers();
|
|
||||||
const { client, handlers } = mockInitialSyncClient();
|
|
||||||
vi.mocked(client.auth).mockImplementation(() => {
|
|
||||||
handlers.get("ClientStateSync")?.({
|
|
||||||
type: "ClientStateSync",
|
|
||||||
data: validState,
|
|
||||||
} as never);
|
|
||||||
return new Promise(() => {});
|
|
||||||
});
|
|
||||||
const result = completeInitialSynchronization(
|
|
||||||
client,
|
|
||||||
new RequestIdAllocator(),
|
|
||||||
new AbortController().signal,
|
|
||||||
100,
|
|
||||||
10,
|
|
||||||
);
|
|
||||||
const assertion = expect(result).rejects.toThrow(
|
|
||||||
"MTP authentication timed out",
|
|
||||||
);
|
|
||||||
|
|
||||||
await vi.advanceTimersByTimeAsync(100);
|
|
||||||
await assertion;
|
|
||||||
});
|
|
||||||
|
|
||||||
it("uses a fresh request ID namespace for each connection", async () => {
|
|
||||||
const first = mockInitialSyncClient();
|
|
||||||
const second = mockInitialSyncClient();
|
|
||||||
|
|
||||||
await completeInitialSynchronization(
|
|
||||||
first.client,
|
|
||||||
new RequestIdAllocator(),
|
|
||||||
new AbortController().signal,
|
|
||||||
);
|
|
||||||
await completeInitialSynchronization(
|
|
||||||
second.client,
|
|
||||||
new RequestIdAllocator(),
|
|
||||||
new AbortController().signal,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(first.request.mock.calls[0]?.[2]).toEqual({ id: 1 });
|
|
||||||
expect(second.request.mock.calls[0]?.[2]).toEqual({ id: 1 });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,9 +1,7 @@
|
||||||
export { Provider, useMTP } from "./context";
|
export { Provider, useMTP } from "./context";
|
||||||
export { RequestIdAllocator } from "./requestIds";
|
|
||||||
export type {
|
export type {
|
||||||
BoundSendFn,
|
BoundSendFn,
|
||||||
MTPExchange,
|
MTPExchange,
|
||||||
MTPInterceptor,
|
MTPInterceptor,
|
||||||
PushHandler,
|
|
||||||
ProtocolMessage,
|
ProtocolMessage,
|
||||||
} from "./context";
|
} from "./mtpContext";
|
||||||
|
|
|
||||||
159
packages/mtp/src/mtpContext.tsx
Normal file
159
packages/mtp/src/mtpContext.tsx
Normal file
|
|
@ -0,0 +1,159 @@
|
||||||
|
import { createContext, useCallback, useRef } from "react";
|
||||||
|
import type {
|
||||||
|
MTPRequestFunction,
|
||||||
|
MTPResponseFrame,
|
||||||
|
MTPSubscriptionFunction,
|
||||||
|
} from "mtp";
|
||||||
|
import {
|
||||||
|
mtp as mtpSchemas,
|
||||||
|
type Calls,
|
||||||
|
type Communities,
|
||||||
|
type Contacts,
|
||||||
|
} from "@tensamin/shared/data";
|
||||||
|
import { log } from "@tensamin/shared/log";
|
||||||
|
|
||||||
|
export type ProtocolMessage<
|
||||||
|
Type extends keyof typeof mtpSchemas & string = keyof typeof mtpSchemas &
|
||||||
|
string,
|
||||||
|
> = MTPResponseFrame<typeof mtpSchemas, Type>;
|
||||||
|
|
||||||
|
export type BoundSendFn = MTPRequestFunction<typeof mtpSchemas>;
|
||||||
|
|
||||||
|
export type MTPExchange = {
|
||||||
|
type: keyof typeof mtpSchemas & string;
|
||||||
|
data: unknown;
|
||||||
|
response: ProtocolMessage;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MTPInterceptor = (exchange: MTPExchange) => void | Promise<void>;
|
||||||
|
|
||||||
|
export type MTPContextType = {
|
||||||
|
send: BoundSendFn;
|
||||||
|
subscribe: MTPSubscriptionFunction<typeof mtpSchemas>;
|
||||||
|
addInterceptor: (interceptor: MTPInterceptor) => () => void;
|
||||||
|
readyState: number;
|
||||||
|
identified: boolean;
|
||||||
|
freshContacts: Contacts;
|
||||||
|
freshCommunities: Communities;
|
||||||
|
freshCalls: Calls;
|
||||||
|
contextReady: boolean;
|
||||||
|
loadingDescription: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const MTPContext = createContext<MTPContextType | undefined>(undefined);
|
||||||
|
|
||||||
|
export function removeMissingContacts(
|
||||||
|
contacts: Contacts,
|
||||||
|
message: ProtocolMessage<"GetStates">,
|
||||||
|
): Contacts {
|
||||||
|
const missing = new Set(message.data.MissingUserIds ?? []);
|
||||||
|
return contacts.filter((contact) => !missing.has(contact.UserId));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useMessageHandlers() {
|
||||||
|
const interceptorsRef = useRef(new Set<MTPInterceptor>());
|
||||||
|
const subscriptionHandlersRef = useRef(
|
||||||
|
new Map<string, Set<(message: ProtocolMessage) => void | Promise<void>>>(),
|
||||||
|
);
|
||||||
|
const transportRef = useRef<{
|
||||||
|
subscribe: MTPSubscriptionFunction<typeof mtpSchemas>;
|
||||||
|
} | null>(null);
|
||||||
|
const transportGenerationRef = useRef(0);
|
||||||
|
const transportUnsubscribersRef = useRef(new Map<string, () => void>());
|
||||||
|
const lastInitialStateRef = useRef<ProtocolMessage<"GetStates"> | null>(null);
|
||||||
|
|
||||||
|
const attachType = useCallback(
|
||||||
|
<Type extends keyof typeof mtpSchemas & string>(type: Type) => {
|
||||||
|
const transport = transportRef.current;
|
||||||
|
if (!transport || transportUnsubscribersRef.current.has(type)) return;
|
||||||
|
const generation = transportGenerationRef.current;
|
||||||
|
const unsubscribe = transport.subscribe(type, (message) => {
|
||||||
|
if (
|
||||||
|
transportRef.current !== transport ||
|
||||||
|
transportGenerationRef.current !== generation
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
if (type === "GetStates") {
|
||||||
|
lastInitialStateRef.current = message as ProtocolMessage<"GetStates">;
|
||||||
|
}
|
||||||
|
for (const handler of [
|
||||||
|
...(subscriptionHandlersRef.current.get(type) ?? []),
|
||||||
|
]) {
|
||||||
|
void Promise.resolve(handler(message as ProtocolMessage)).catch(
|
||||||
|
(error) => {
|
||||||
|
log(1, "mtp", "red", "Subscription handler failed", error, {
|
||||||
|
type,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
transportUnsubscribersRef.current.set(type, unsubscribe);
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const attachSubscriptions = useCallback(
|
||||||
|
(transport: { subscribe: MTPSubscriptionFunction<typeof mtpSchemas> }) => {
|
||||||
|
for (const unsubscribe of transportUnsubscribersRef.current.values()) {
|
||||||
|
unsubscribe();
|
||||||
|
}
|
||||||
|
transportUnsubscribersRef.current.clear();
|
||||||
|
transportRef.current = transport;
|
||||||
|
const generation = ++transportGenerationRef.current;
|
||||||
|
for (const type of subscriptionHandlersRef.current.keys()) {
|
||||||
|
attachType(type as keyof typeof mtpSchemas & string);
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
if (
|
||||||
|
transportRef.current !== transport ||
|
||||||
|
transportGenerationRef.current !== generation
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
transportRef.current = null;
|
||||||
|
transportGenerationRef.current += 1;
|
||||||
|
for (const unsubscribe of transportUnsubscribersRef.current.values()) {
|
||||||
|
unsubscribe();
|
||||||
|
}
|
||||||
|
transportUnsubscribersRef.current.clear();
|
||||||
|
};
|
||||||
|
},
|
||||||
|
[attachType],
|
||||||
|
);
|
||||||
|
|
||||||
|
const subscribe = useCallback<MTPSubscriptionFunction<typeof mtpSchemas>>(
|
||||||
|
(type, handler) => {
|
||||||
|
const handlers = subscriptionHandlersRef.current.get(type) ?? new Set();
|
||||||
|
const untypedHandler = handler as (
|
||||||
|
message: ProtocolMessage,
|
||||||
|
) => void | Promise<void>;
|
||||||
|
handlers.add(untypedHandler);
|
||||||
|
subscriptionHandlersRef.current.set(type, handlers);
|
||||||
|
attachType(type);
|
||||||
|
const initialState = lastInitialStateRef.current;
|
||||||
|
if (type === "GetStates" && initialState) {
|
||||||
|
void Promise.resolve(untypedHandler(initialState)).catch(
|
||||||
|
() => undefined,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
handlers.delete(untypedHandler);
|
||||||
|
if (handlers.size !== 0) return;
|
||||||
|
subscriptionHandlersRef.current.delete(type);
|
||||||
|
transportUnsubscribersRef.current.get(type)?.();
|
||||||
|
transportUnsubscribersRef.current.delete(type);
|
||||||
|
};
|
||||||
|
},
|
||||||
|
[attachType],
|
||||||
|
);
|
||||||
|
const addInterceptor = useCallback((interceptor: MTPInterceptor) => {
|
||||||
|
interceptorsRef.current.add(interceptor);
|
||||||
|
return () => interceptorsRef.current.delete(interceptor);
|
||||||
|
}, []);
|
||||||
|
return {
|
||||||
|
addInterceptor,
|
||||||
|
attachSubscriptions,
|
||||||
|
interceptorsRef,
|
||||||
|
subscribe,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -1,44 +0,0 @@
|
||||||
import { describe, expect, it } from "vitest";
|
|
||||||
|
|
||||||
import { fromWireData, toWireData } from "./protocolFields";
|
|
||||||
|
|
||||||
describe("MTP protocol field translation", () => {
|
|
||||||
it("maps application message fields to MTP wire fields", () => {
|
|
||||||
expect(
|
|
||||||
toWireData({
|
|
||||||
Messages: [{ Content: "abc", MessageId: 4 }],
|
|
||||||
}),
|
|
||||||
).toEqual({
|
|
||||||
Messages: [{ AppContent: "abc", AppMessageId: 4 }],
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("maps MTP wire fields back to application fields", () => {
|
|
||||||
expect(
|
|
||||||
fromWireData({
|
|
||||||
AppCreatedAt: 123,
|
|
||||||
Message: { AppContent: "abc", AppMessageId: 4 },
|
|
||||||
}),
|
|
||||||
).toEqual({
|
|
||||||
CreatedAt: 123,
|
|
||||||
Message: { Content: "abc", MessageId: 4 },
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("preserves byte arrays and unrelated fields", () => {
|
|
||||||
const bytes = new Uint8Array([1, 2, 3]);
|
|
||||||
const translated = toWireData({ Payload: bytes, Other: "value" }) as {
|
|
||||||
Payload: Uint8Array;
|
|
||||||
Other: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
expect(translated.Payload).toBe(bytes);
|
|
||||||
expect(translated.Other).toBe("value");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects field mapping collisions", () => {
|
|
||||||
expect(() => toWireData({ Content: "a", AppContent: "b" })).toThrow(
|
|
||||||
"MTP field translation collision",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,58 +0,0 @@
|
||||||
const APPLICATION_TO_WIRE_FIELDS = {
|
|
||||||
Content: "AppContent",
|
|
||||||
CreatedAt: "AppCreatedAt",
|
|
||||||
MessageId: "AppMessageId",
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
const WIRE_TO_APPLICATION_FIELDS = {
|
|
||||||
AppContent: "Content",
|
|
||||||
AppCreatedAt: "CreatedAt",
|
|
||||||
AppMessageId: "MessageId",
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
function mapProtocolFields(
|
|
||||||
value: unknown,
|
|
||||||
fieldMap: Readonly<Record<string, string>>,
|
|
||||||
): unknown {
|
|
||||||
if (
|
|
||||||
value === null ||
|
|
||||||
typeof value !== "object" ||
|
|
||||||
value instanceof Uint8Array
|
|
||||||
) {
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Array.isArray(value)) {
|
|
||||||
return value.map((item) => mapProtocolFields(item, fieldMap));
|
|
||||||
}
|
|
||||||
|
|
||||||
const source = value as Record<string, unknown>;
|
|
||||||
const target: Record<string, unknown> = {};
|
|
||||||
|
|
||||||
for (const [key, child] of Object.entries(source)) {
|
|
||||||
const mappedKey = fieldMap[key] ?? key;
|
|
||||||
|
|
||||||
if (mappedKey in target) {
|
|
||||||
throw new Error(`MTP field translation collision for ${mappedKey}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
target[mappedKey] = mapProtocolFields(child, fieldMap);
|
|
||||||
}
|
|
||||||
|
|
||||||
return target;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function toWireData(value: unknown): unknown {
|
|
||||||
return mapProtocolFields(value, APPLICATION_TO_WIRE_FIELDS);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function fromWireData(value: unknown): unknown {
|
|
||||||
return mapProtocolFields(value, WIRE_TO_APPLICATION_FIELDS);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function fromWireMessage<T extends { data: unknown }>(message: T): T {
|
|
||||||
return {
|
|
||||||
...message,
|
|
||||||
data: fromWireData(message.data),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
@ -1,25 +0,0 @@
|
||||||
import { describe, expect, it } from "vitest";
|
|
||||||
|
|
||||||
import { RequestIdAllocator } from "./requestIds";
|
|
||||||
|
|
||||||
describe("MTP request ID allocation", () => {
|
|
||||||
it("allocates nonzero request IDs monotonically", () => {
|
|
||||||
const ids = new RequestIdAllocator();
|
|
||||||
|
|
||||||
expect(ids.allocate()).toBe(1);
|
|
||||||
expect(ids.allocate()).toBe(2);
|
|
||||||
expect(new RequestIdAllocator().allocate()).toBe(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not wrap exhausted request IDs", () => {
|
|
||||||
const ids = new RequestIdAllocator(0x1_0000_0000);
|
|
||||||
|
|
||||||
expect(() => ids.allocate()).toThrow("MTP request ID space exhausted");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects invalid allocator states", () => {
|
|
||||||
expect(() => new RequestIdAllocator(0)).toThrow(
|
|
||||||
"invalid MTP request ID allocator state",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,24 +0,0 @@
|
||||||
const MAX_MTP_REQUEST_ID = 0xffff_ffff;
|
|
||||||
|
|
||||||
export class RequestIdAllocator {
|
|
||||||
#next: number;
|
|
||||||
|
|
||||||
constructor(next = 1) {
|
|
||||||
if (
|
|
||||||
!Number.isSafeInteger(next) ||
|
|
||||||
next <= 0 ||
|
|
||||||
next > MAX_MTP_REQUEST_ID + 1
|
|
||||||
) {
|
|
||||||
throw new RangeError("invalid MTP request ID allocator state");
|
|
||||||
}
|
|
||||||
this.#next = next;
|
|
||||||
}
|
|
||||||
|
|
||||||
allocate(): number {
|
|
||||||
if (this.#next > MAX_MTP_REQUEST_ID) {
|
|
||||||
throw new Error("MTP request ID space exhausted for this connection");
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.#next++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
258
packages/mtp/src/tauri.tsx
Normal file
258
packages/mtp/src/tauri.tsx
Normal file
|
|
@ -0,0 +1,258 @@
|
||||||
|
import {
|
||||||
|
type ReactNode,
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
import { listen, type UnlistenFn } from "@tauri-apps/api/event";
|
||||||
|
import {
|
||||||
|
ConnectionState,
|
||||||
|
MTPProxyConnection,
|
||||||
|
type MTPFrame,
|
||||||
|
type MTPProxyAdapter,
|
||||||
|
} from "mtp";
|
||||||
|
import {
|
||||||
|
mtp as mtpSchemas,
|
||||||
|
type Calls,
|
||||||
|
type Communities,
|
||||||
|
type Contacts,
|
||||||
|
} from "@tensamin/shared/data";
|
||||||
|
import { log } from "@tensamin/shared/log";
|
||||||
|
|
||||||
|
import {
|
||||||
|
type BoundSendFn,
|
||||||
|
MTPContext,
|
||||||
|
type ProtocolMessage,
|
||||||
|
removeMissingContacts,
|
||||||
|
useMessageHandlers,
|
||||||
|
} from "./mtpContext";
|
||||||
|
|
||||||
|
type NativeSnapshot = {
|
||||||
|
generation: number;
|
||||||
|
readyState: number;
|
||||||
|
identified: boolean;
|
||||||
|
state?: unknown;
|
||||||
|
error?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function createTauriAdapter() {
|
||||||
|
const subscriptions = new Map<string, Set<(message: MTPFrame) => void>>();
|
||||||
|
const adapter: MTPProxyAdapter = {
|
||||||
|
request: (type, data) =>
|
||||||
|
invoke<MTPFrame>("mtp_request", { typeName: type, data }),
|
||||||
|
subscribe(type, handler) {
|
||||||
|
const handlers = subscriptions.get(type) ?? new Set();
|
||||||
|
handlers.add(handler);
|
||||||
|
subscriptions.set(type, handlers);
|
||||||
|
return () => {
|
||||||
|
handlers.delete(handler);
|
||||||
|
if (handlers.size === 0) subscriptions.delete(type);
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
adapter,
|
||||||
|
dispatch(message: MTPFrame) {
|
||||||
|
for (const handler of subscriptions.get(message.type) ?? [])
|
||||||
|
handler(message);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TauriProvider(props: {
|
||||||
|
children: ReactNode;
|
||||||
|
blockConnection?: boolean;
|
||||||
|
}) {
|
||||||
|
const [snapshot, setSnapshot] = useState<NativeSnapshot>({
|
||||||
|
generation: 0,
|
||||||
|
readyState: ConnectionState.Disconnected,
|
||||||
|
identified: false,
|
||||||
|
});
|
||||||
|
const [freshContacts, setFreshContacts] = useState<Contacts>([]);
|
||||||
|
const [freshCommunities, setFreshCommunities] = useState<Communities>([]);
|
||||||
|
const [freshCalls, setFreshCalls] = useState<Calls>([]);
|
||||||
|
const generationRef = useRef(0);
|
||||||
|
const { addInterceptor, attachSubscriptions, interceptorsRef, subscribe } =
|
||||||
|
useMessageHandlers();
|
||||||
|
const [{ bridge, connection }] = useState(() => {
|
||||||
|
const bridge = createTauriAdapter();
|
||||||
|
return {
|
||||||
|
bridge,
|
||||||
|
connection: new MTPProxyConnection(bridge.adapter, {
|
||||||
|
schemas: mtpSchemas,
|
||||||
|
throwProtocolErrors: true,
|
||||||
|
onValidationError: (error) => {
|
||||||
|
log(1, "mtp", "red", "Failed to validate native MTP message", error);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const applySnapshot = useCallback(async (next: NativeSnapshot) => {
|
||||||
|
if (next.generation < generationRef.current) return;
|
||||||
|
generationRef.current = next.generation;
|
||||||
|
if (next.error)
|
||||||
|
log(0, "android", "orange", "MTP connection failed", next.error);
|
||||||
|
if (!next.identified) {
|
||||||
|
setSnapshot(next);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (next.state === undefined) {
|
||||||
|
setSnapshot({
|
||||||
|
...next,
|
||||||
|
identified: false,
|
||||||
|
error: "Native MTP connection omitted initial state",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const state = await mtpSchemas.ClientStateSync.response.parseAsync(
|
||||||
|
next.state,
|
||||||
|
);
|
||||||
|
setFreshContacts(state.Contacts);
|
||||||
|
setFreshCommunities(state.Communities);
|
||||||
|
setFreshCalls(state.Calls);
|
||||||
|
setSnapshot(next);
|
||||||
|
} catch (error) {
|
||||||
|
log(0, "mtp", "red", "Invalid native MTP state", error);
|
||||||
|
setSnapshot({
|
||||||
|
...next,
|
||||||
|
identified: false,
|
||||||
|
error: "Invalid ClientStateSync payload",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const dispatchMessage = useCallback(
|
||||||
|
(message: MTPFrame) => {
|
||||||
|
bridge.dispatch(message);
|
||||||
|
},
|
||||||
|
[bridge],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return attachSubscriptions(connection);
|
||||||
|
}, [attachSubscriptions, connection]);
|
||||||
|
|
||||||
|
useEffect(
|
||||||
|
() =>
|
||||||
|
subscribe("GetStates", (message) => {
|
||||||
|
setFreshContacts((contacts) =>
|
||||||
|
removeMissingContacts(contacts, message),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
[subscribe],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (props.blockConnection) return;
|
||||||
|
let disposed = false;
|
||||||
|
let unlisten: UnlistenFn | undefined;
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
const nextUnlisten = await listen<
|
||||||
|
| { kind: "state"; snapshot: NativeSnapshot }
|
||||||
|
| { kind: "message"; generation: number; message: MTPFrame }
|
||||||
|
| { kind: "log"; level: number; message: string; details?: unknown }
|
||||||
|
>("mtp://event", ({ payload }) => {
|
||||||
|
if (disposed) return;
|
||||||
|
if (payload.kind === "state") {
|
||||||
|
void applySnapshot(payload.snapshot);
|
||||||
|
} else if (payload.kind === "message") {
|
||||||
|
if (payload.generation === generationRef.current) {
|
||||||
|
dispatchMessage(payload.message);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
log(
|
||||||
|
payload.level,
|
||||||
|
"android",
|
||||||
|
"orange",
|
||||||
|
payload.message,
|
||||||
|
payload.details,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (disposed) nextUnlisten();
|
||||||
|
else unlisten = nextUnlisten;
|
||||||
|
} catch (error) {
|
||||||
|
log(0, "mtp", "red", "Failed to subscribe to native MTP events", error);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const current = await invoke<NativeSnapshot>("mtp_status");
|
||||||
|
if (!disposed) await applySnapshot(current);
|
||||||
|
} catch (error) {
|
||||||
|
log(0, "mtp", "red", "Failed to load native MTP status", error);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return () => {
|
||||||
|
disposed = true;
|
||||||
|
unlisten?.();
|
||||||
|
};
|
||||||
|
}, [applySnapshot, dispatchMessage, props.blockConnection]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (props.blockConnection) return;
|
||||||
|
const updateVisibility = () => {
|
||||||
|
void invoke("mtp_set_ui_visible", {
|
||||||
|
visible: document.visibilityState === "visible" && document.hasFocus(),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
updateVisibility();
|
||||||
|
document.addEventListener("visibilitychange", updateVisibility);
|
||||||
|
window.addEventListener("focus", updateVisibility);
|
||||||
|
window.addEventListener("blur", updateVisibility);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("visibilitychange", updateVisibility);
|
||||||
|
window.removeEventListener("focus", updateVisibility);
|
||||||
|
window.removeEventListener("blur", updateVisibility);
|
||||||
|
void invoke("mtp_set_ui_visible", { visible: false });
|
||||||
|
};
|
||||||
|
}, [props.blockConnection]);
|
||||||
|
|
||||||
|
const send = useCallback<BoundSendFn>(
|
||||||
|
async (type, data, options) => {
|
||||||
|
const response = await connection.request(type, data, options);
|
||||||
|
if (response.type === "GetStates") {
|
||||||
|
setFreshContacts((contacts) =>
|
||||||
|
removeMissingContacts(
|
||||||
|
contacts,
|
||||||
|
response as ProtocolMessage<"GetStates">,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for (const interceptor of interceptorsRef.current) {
|
||||||
|
void Promise.resolve(interceptor({ type, data, response })).catch(
|
||||||
|
(error) => {
|
||||||
|
log(1, "mtp", "yellow", "MTP interceptor failed", error, { type });
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return response;
|
||||||
|
},
|
||||||
|
[connection, interceptorsRef],
|
||||||
|
);
|
||||||
|
const connected = snapshot.readyState === ConnectionState.Connected;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MTPContext.Provider
|
||||||
|
value={{
|
||||||
|
send,
|
||||||
|
subscribe,
|
||||||
|
addInterceptor,
|
||||||
|
readyState: snapshot.readyState,
|
||||||
|
identified: snapshot.identified,
|
||||||
|
freshContacts,
|
||||||
|
freshCommunities,
|
||||||
|
freshCalls,
|
||||||
|
contextReady: connected && snapshot.identified,
|
||||||
|
loadingDescription: connected
|
||||||
|
? "Waiting for authenticated session"
|
||||||
|
: "Establishing native transport channel",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{props.children}
|
||||||
|
</MTPContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -16,7 +16,6 @@ import { useLocation, useNavigate } from "@tanstack/react-router";
|
||||||
import { decryptChatText } from "@tensamin/crypto/chatSecret";
|
import { decryptChatText } from "@tensamin/crypto/chatSecret";
|
||||||
import { log } from "@tensamin/shared/log";
|
import { log } from "@tensamin/shared/log";
|
||||||
import { playSound } from "@tensamin/shared/sounds";
|
import { playSound } from "@tensamin/shared/sounds";
|
||||||
import { type RawMessage } from "@tensamin/chat/values";
|
|
||||||
|
|
||||||
export const context = createContext<contextType | undefined>(undefined);
|
export const context = createContext<contextType | undefined>(undefined);
|
||||||
|
|
||||||
|
|
@ -30,7 +29,7 @@ async function requestNotificationPermission() {
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Provider(props: { children: React.ReactNode }) {
|
export default function Provider(props: { children: React.ReactNode }) {
|
||||||
const { subscribePush, send } = useMTP();
|
const { subscribe, send } = useMTP();
|
||||||
const { load } = useStorage();
|
const { load } = useStorage();
|
||||||
const { get } = useUser();
|
const { get } = useUser();
|
||||||
const { addLiveMessage, chatSecret, getChatSecret, userId } = useChat();
|
const { addLiveMessage, chatSecret, getChatSecret, userId } = useChat();
|
||||||
|
|
@ -39,13 +38,7 @@ export default function Provider(props: { children: React.ReactNode }) {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return subscribePush(async (message) => {
|
return subscribe("MessageLive", async ({ data }) => {
|
||||||
if (message.type === "MessageLive") {
|
|
||||||
const data = message.data as {
|
|
||||||
Message?: RawMessage;
|
|
||||||
SenderId?: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!data.SenderId) return;
|
if (!data.SenderId) return;
|
||||||
|
|
||||||
const isCurrentChat =
|
const isCurrentChat =
|
||||||
|
|
@ -179,8 +172,6 @@ export default function Provider(props: { children: React.ReactNode }) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
return;
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}, [
|
}, [
|
||||||
addLiveMessage,
|
addLiveMessage,
|
||||||
|
|
@ -191,7 +182,7 @@ export default function Provider(props: { children: React.ReactNode }) {
|
||||||
navigate,
|
navigate,
|
||||||
send,
|
send,
|
||||||
moveUserIdToTop,
|
moveUserIdToTop,
|
||||||
subscribePush,
|
subscribe,
|
||||||
getChatSecret,
|
getChatSecret,
|
||||||
userId,
|
userId,
|
||||||
]);
|
]);
|
||||||
|
|
|
||||||
|
|
@ -9,13 +9,12 @@ import {
|
||||||
useState,
|
useState,
|
||||||
useSyncExternalStore,
|
useSyncExternalStore,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { useMTP, type ProtocolMessage } from "@tensamin/mtp";
|
import { useMTP } from "@tensamin/mtp";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
clientUserStateSchema,
|
clientUserStateSchema,
|
||||||
mtp as schemas,
|
mtp as schemas,
|
||||||
publicUserStateSchema,
|
publicUserStateSchema,
|
||||||
userStateEntrySchema,
|
|
||||||
} from "@tensamin/shared/data";
|
} from "@tensamin/shared/data";
|
||||||
import type z from "zod";
|
import type z from "zod";
|
||||||
import { createCache } from "@tensamin/cache";
|
import { createCache } from "@tensamin/cache";
|
||||||
|
|
@ -89,7 +88,7 @@ export default function UserProvider(props: { children: ReactNode }) {
|
||||||
);
|
);
|
||||||
const revisionsRef = useRef(new Map<number, Map<UserField, number>>());
|
const revisionsRef = useRef(new Map<number, Map<UserField, number>>());
|
||||||
|
|
||||||
const { send, subscribePush } = useMTP();
|
const { send, subscribe: subscribeMTP } = useMTP();
|
||||||
const { load } = useStorage();
|
const { load } = useStorage();
|
||||||
const { contacts } = useSession();
|
const { contacts } = useSession();
|
||||||
const [accountId, setAccountId] = useState<number | null>(null);
|
const [accountId, setAccountId] = useState<number | null>(null);
|
||||||
|
|
@ -174,39 +173,6 @@ export default function UserProvider(props: { children: ReactNode }) {
|
||||||
[publishUser],
|
[publishUser],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleStatePush = useCallback(
|
|
||||||
async (message: ProtocolMessage) => {
|
|
||||||
if (!accountId) return;
|
|
||||||
const data = message.data as Record<string, unknown>;
|
|
||||||
if (message.type === "GetStates") {
|
|
||||||
if (Array.isArray(data.MissingUserIds)) {
|
|
||||||
for (const userId of data.MissingUserIds) {
|
|
||||||
if (typeof userId === "number") removePresence(userId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!Array.isArray(data.UserStates)) return;
|
|
||||||
for (const entry of data.UserStates) {
|
|
||||||
const parsed = userStateEntrySchema.safeParse(entry);
|
|
||||||
if (!parsed.success) continue;
|
|
||||||
if (parsed.data.UserId === accountId) continue;
|
|
||||||
initialStatesRef.current.set(
|
|
||||||
parsed.data.UserId,
|
|
||||||
parsed.data.UserState,
|
|
||||||
);
|
|
||||||
if (applyUserState(parsed.data.UserId, parsed.data.UserState)) {
|
|
||||||
initialStatesRef.current.delete(parsed.data.UserId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (message.type !== "ClientChanged") return;
|
|
||||||
const parsed = schemas.ClientChanged.response.safeParse(data);
|
|
||||||
if (!parsed.success) return;
|
|
||||||
applyUserState(parsed.data.UserId, parsed.data.UserState, true);
|
|
||||||
},
|
|
||||||
[accountId, applyUserState, removePresence],
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void load("user_id").then((accountId) => {
|
void load("user_id").then((accountId) => {
|
||||||
accountIdRef.current = accountId;
|
accountIdRef.current = accountId;
|
||||||
|
|
@ -223,8 +189,24 @@ export default function UserProvider(props: { children: ReactNode }) {
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!accountId) return;
|
if (!accountId) return;
|
||||||
return subscribePush(handleStatePush);
|
const unsubscribeStates = subscribeMTP("GetStates", ({ data }) => {
|
||||||
}, [accountId, handleStatePush, subscribePush]);
|
for (const userId of data.MissingUserIds ?? []) removePresence(userId);
|
||||||
|
for (const entry of data.UserStates) {
|
||||||
|
if (!entry || entry.UserId === accountId) continue;
|
||||||
|
initialStatesRef.current.set(entry.UserId, entry.UserState);
|
||||||
|
if (applyUserState(entry.UserId, entry.UserState)) {
|
||||||
|
initialStatesRef.current.delete(entry.UserId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const unsubscribeChanged = subscribeMTP("ClientChanged", ({ data }) => {
|
||||||
|
applyUserState(data.UserId, data.UserState, true);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
unsubscribeStates();
|
||||||
|
unsubscribeChanged();
|
||||||
|
};
|
||||||
|
}, [accountId, applyUserState, removePresence, subscribeMTP]);
|
||||||
|
|
||||||
const loadUser = useCallback(
|
const loadUser = useCallback(
|
||||||
async (userId: number): Promise<User> => {
|
async (userId: number): Promise<User> => {
|
||||||
|
|
|
||||||
41
pnpm-lock.yaml
generated
41
pnpm-lock.yaml
generated
|
|
@ -6,7 +6,7 @@ settings:
|
||||||
|
|
||||||
overrides:
|
overrides:
|
||||||
'@methanium/ui': https://git.methanium.net/methanium/ui/releases/download/0.0.29/methanium-ui.tgz
|
'@methanium/ui': https://git.methanium.net/methanium/ui/releases/download/0.0.29/methanium-ui.tgz
|
||||||
mtp: https://git.methanium.net/methanium/mtp/releases/download/0.3.0-b331b9f6a3/mtp-0.3.0.tgz
|
mtp: https://git.methanium.net/methanium/mtp/releases/download/0.3.0-dev-c7c7afe/mtp-0.3.0.tgz
|
||||||
|
|
||||||
importers:
|
importers:
|
||||||
|
|
||||||
|
|
@ -16,8 +16,8 @@ importers:
|
||||||
specifier: https://git.methanium.net/methanium/ui/releases/download/0.0.29/methanium-ui.tgz
|
specifier: https://git.methanium.net/methanium/ui/releases/download/0.0.29/methanium-ui.tgz
|
||||||
version: https://git.methanium.net/methanium/ui/releases/download/0.0.29/methanium-ui.tgz(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(emojibase@17.0.0)(react-dom@19.2.8(react@19.2.8))(react-is@19.2.8)(react@19.2.8)(redux@5.0.1)(supports-color@7.2.0)(typescript@6.0.3)
|
version: https://git.methanium.net/methanium/ui/releases/download/0.0.29/methanium-ui.tgz(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(emojibase@17.0.0)(react-dom@19.2.8(react@19.2.8))(react-is@19.2.8)(react@19.2.8)(redux@5.0.1)(supports-color@7.2.0)(typescript@6.0.3)
|
||||||
mtp:
|
mtp:
|
||||||
specifier: https://git.methanium.net/methanium/mtp/releases/download/0.3.0-b331b9f6a3/mtp-0.3.0.tgz
|
specifier: https://git.methanium.net/methanium/mtp/releases/download/0.3.0-dev-c7c7afe/mtp-0.3.0.tgz
|
||||||
version: https://git.methanium.net/methanium/mtp/releases/download/0.3.0-b331b9f6a3/mtp-0.3.0.tgz
|
version: https://git.methanium.net/methanium/mtp/releases/download/0.3.0-dev-c7c7afe/mtp-0.3.0.tgz
|
||||||
sonner:
|
sonner:
|
||||||
specifier: ^2.0.8
|
specifier: ^2.0.8
|
||||||
version: 2.0.8(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
|
version: 2.0.8(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
|
||||||
|
|
@ -98,8 +98,8 @@ importers:
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../../packages/storage
|
version: link:../../packages/storage
|
||||||
mtp:
|
mtp:
|
||||||
specifier: https://git.methanium.net/methanium/mtp/releases/download/0.3.0-b331b9f6a3/mtp-0.3.0.tgz
|
specifier: https://git.methanium.net/methanium/mtp/releases/download/0.3.0-dev-c7c7afe/mtp-0.3.0.tgz
|
||||||
version: https://git.methanium.net/methanium/mtp/releases/download/0.3.0-b331b9f6a3/mtp-0.3.0.tgz
|
version: https://git.methanium.net/methanium/mtp/releases/download/0.3.0-dev-c7c7afe/mtp-0.3.0.tgz
|
||||||
react:
|
react:
|
||||||
specifier: ^19.2.8
|
specifier: ^19.2.8
|
||||||
version: 19.2.8
|
version: 19.2.8
|
||||||
|
|
@ -290,8 +290,8 @@ importers:
|
||||||
specifier: ^17.9.0
|
specifier: ^17.9.0
|
||||||
version: 17.9.0
|
version: 17.9.0
|
||||||
mtp:
|
mtp:
|
||||||
specifier: https://git.methanium.net/methanium/mtp/releases/download/0.3.0-b331b9f6a3/mtp-0.3.0.tgz
|
specifier: https://git.methanium.net/methanium/mtp/releases/download/0.3.0-dev-c7c7afe/mtp-0.3.0.tgz
|
||||||
version: https://git.methanium.net/methanium/mtp/releases/download/0.3.0-b331b9f6a3/mtp-0.3.0.tgz
|
version: https://git.methanium.net/methanium/mtp/releases/download/0.3.0-dev-c7c7afe/mtp-0.3.0.tgz
|
||||||
typescript:
|
typescript:
|
||||||
specifier: ~6.0.3
|
specifier: ~6.0.3
|
||||||
version: 6.0.3
|
version: 6.0.3
|
||||||
|
|
@ -356,8 +356,8 @@ importers:
|
||||||
specifier: ^1.29.0
|
specifier: ^1.29.0
|
||||||
version: 1.30.0(react@19.2.8)
|
version: 1.30.0(react@19.2.8)
|
||||||
mtp:
|
mtp:
|
||||||
specifier: https://git.methanium.net/methanium/mtp/releases/download/0.3.0-b331b9f6a3/mtp-0.3.0.tgz
|
specifier: https://git.methanium.net/methanium/mtp/releases/download/0.3.0-dev-c7c7afe/mtp-0.3.0.tgz
|
||||||
version: https://git.methanium.net/methanium/mtp/releases/download/0.3.0-b331b9f6a3/mtp-0.3.0.tgz
|
version: https://git.methanium.net/methanium/mtp/releases/download/0.3.0-dev-c7c7afe/mtp-0.3.0.tgz
|
||||||
react:
|
react:
|
||||||
specifier: ^19.2.8
|
specifier: ^19.2.8
|
||||||
version: 19.2.8
|
version: 19.2.8
|
||||||
|
|
@ -431,8 +431,8 @@ importers:
|
||||||
packages/crypto:
|
packages/crypto:
|
||||||
dependencies:
|
dependencies:
|
||||||
mtp:
|
mtp:
|
||||||
specifier: https://git.methanium.net/methanium/mtp/releases/download/0.3.0-b331b9f6a3/mtp-0.3.0.tgz
|
specifier: https://git.methanium.net/methanium/mtp/releases/download/0.3.0-dev-c7c7afe/mtp-0.3.0.tgz
|
||||||
version: https://git.methanium.net/methanium/mtp/releases/download/0.3.0-b331b9f6a3/mtp-0.3.0.tgz
|
version: https://git.methanium.net/methanium/mtp/releases/download/0.3.0-dev-c7c7afe/mtp-0.3.0.tgz
|
||||||
react:
|
react:
|
||||||
specifier: ^19.2.8
|
specifier: ^19.2.8
|
||||||
version: 19.2.8
|
version: 19.2.8
|
||||||
|
|
@ -506,9 +506,6 @@ importers:
|
||||||
'@tauri-apps/api':
|
'@tauri-apps/api':
|
||||||
specifier: ^2.11.1
|
specifier: ^2.11.1
|
||||||
version: 2.11.1
|
version: 2.11.1
|
||||||
'@tensamin/crypto':
|
|
||||||
specifier: workspace:*
|
|
||||||
version: link:../crypto
|
|
||||||
'@tensamin/shared':
|
'@tensamin/shared':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../shared
|
version: link:../shared
|
||||||
|
|
@ -516,17 +513,11 @@ importers:
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../storage
|
version: link:../storage
|
||||||
mtp:
|
mtp:
|
||||||
specifier: https://git.methanium.net/methanium/mtp/releases/download/0.3.0-b331b9f6a3/mtp-0.3.0.tgz
|
specifier: https://git.methanium.net/methanium/mtp/releases/download/0.3.0-dev-c7c7afe/mtp-0.3.0.tgz
|
||||||
version: https://git.methanium.net/methanium/mtp/releases/download/0.3.0-b331b9f6a3/mtp-0.3.0.tgz
|
version: https://git.methanium.net/methanium/mtp/releases/download/0.3.0-dev-c7c7afe/mtp-0.3.0.tgz
|
||||||
react:
|
react:
|
||||||
specifier: ^19.2.8
|
specifier: ^19.2.8
|
||||||
version: 19.2.8
|
version: 19.2.8
|
||||||
react-dom:
|
|
||||||
specifier: ^19.2.8
|
|
||||||
version: 19.2.8(react@19.2.8)
|
|
||||||
zod:
|
|
||||||
specifier: ^4.4.3
|
|
||||||
version: 4.4.3
|
|
||||||
devDependencies:
|
devDependencies:
|
||||||
eslint:
|
eslint:
|
||||||
specifier: ^10.8.0
|
specifier: ^10.8.0
|
||||||
|
|
@ -5085,8 +5076,8 @@ packages:
|
||||||
ms@2.1.3:
|
ms@2.1.3:
|
||||||
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
|
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
|
||||||
|
|
||||||
mtp@https://git.methanium.net/methanium/mtp/releases/download/0.3.0-b331b9f6a3/mtp-0.3.0.tgz:
|
mtp@https://git.methanium.net/methanium/mtp/releases/download/0.3.0-dev-c7c7afe/mtp-0.3.0.tgz:
|
||||||
resolution: {integrity: sha512-rI+xskgAp93o9EdBO/b7HnA0PZTQQPTYuF4Snon/hTvayN+x9O40oad4n/ZUF0ahS6grw00MPKPe89Mfe3BHnw==, tarball: https://git.methanium.net/methanium/mtp/releases/download/0.3.0-b331b9f6a3/mtp-0.3.0.tgz}
|
resolution: {integrity: sha512-MzqeWSaS2lVoiK0coNfY8EPgGNk7QYFS3eRqVYeYBCT0NNm2lQ7T8lLjBuIuMZ6Zh2KAVWhwI2xQIkMdZ7QThA==, tarball: https://git.methanium.net/methanium/mtp/releases/download/0.3.0-dev-c7c7afe/mtp-0.3.0.tgz}
|
||||||
version: 0.3.0
|
version: 0.3.0
|
||||||
|
|
||||||
nanoid@3.3.18:
|
nanoid@3.3.18:
|
||||||
|
|
@ -11337,7 +11328,7 @@ snapshots:
|
||||||
|
|
||||||
ms@2.1.3: {}
|
ms@2.1.3: {}
|
||||||
|
|
||||||
mtp@https://git.methanium.net/methanium/mtp/releases/download/0.3.0-b331b9f6a3/mtp-0.3.0.tgz:
|
mtp@https://git.methanium.net/methanium/mtp/releases/download/0.3.0-dev-c7c7afe/mtp-0.3.0.tgz:
|
||||||
dependencies:
|
dependencies:
|
||||||
yaml: 2.9.0
|
yaml: 2.9.0
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,4 +7,4 @@ allowBuilds:
|
||||||
esbuild: true
|
esbuild: true
|
||||||
overrides:
|
overrides:
|
||||||
"@methanium/ui": "https://git.methanium.net/methanium/ui/releases/download/0.0.29/methanium-ui.tgz"
|
"@methanium/ui": "https://git.methanium.net/methanium/ui/releases/download/0.0.29/methanium-ui.tgz"
|
||||||
mtp: "https://git.methanium.net/methanium/mtp/releases/download/0.3.0-b331b9f6a3/mtp-0.3.0.tgz"
|
mtp: "https://git.methanium.net/methanium/mtp/releases/download/0.3.0-dev-c7c7afe/mtp-0.3.0.tgz"
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue