(undefined);
const ignoreSyncRef = useRef(false);
@@ -143,7 +176,22 @@ export default function Input(props: InputProps) {
});
}, [props.value]);
- return ;
+ return (
+
+ );
}
/**
@@ -204,7 +252,7 @@ function createEditorExtensions(
}),
EditorView.theme({
"&": {
- fontSize: "1rem",
+ fontSize: "inherit",
},
"&.cm-editor": {
width: "100%",
diff --git a/packages/markdown/src/markdown.tsx b/packages/markdown/src/markdown.tsx
index c3a17a6..46ebb92 100644
--- a/packages/markdown/src/markdown.tsx
+++ b/packages/markdown/src/markdown.tsx
@@ -560,7 +560,7 @@ export function renderBlocks(blocks: MarkdownBlock[]): React.ReactElement {
}
return (
-
+
{block.text.split("\n").map((line, lineIndex) => (
{lineIndex > 0 ?
: null}
@@ -645,7 +645,6 @@ export const markdownStyles = `
.tm-md-h4 { font-size: 1.1rem; }
.tm-md-h5 { font-size: 1rem; }
.tm-md-h6 { font-size: 0.95rem; opacity: 0.9; }
-.tm-md-p { margin: 0.25rem 0; }
.tm-md-blockquote { margin: 0.45rem 0; padding-left: 0.75rem; opacity: 0.95; }
.tm-md-blockquote p { margin: 0.2rem 0; }
.tm-md-pre { margin: 0.45rem 0; padding: 0.65rem 0.75rem; border-radius: 0.5rem; background: hsl(var(--muted)); overflow-x: auto; }
@@ -665,12 +664,12 @@ export const markdownStyles = `
.tm-md-table th { background: hsl(var(--muted)); font-weight: 600; }
.tm-md-hr { margin: 0.55rem 0; }
-.cm-editor.tm-md-editor { border-radius: 0.65rem; background: hsl(var(--card)); caret-color: var(--foreground); }
+.cm-editor.tm-md-editor { border-radius: inherit; background: transparent; caret-color: var(--foreground); }
.cm-editor.tm-md-editor.cm-focused { outline: none; box-shadow: none; }
.cm-editor.tm-md-editor .cm-scroller { font-family: inherit; line-height: 1.55; max-height: 30vh; overflow-y: auto; overflow-x: hidden; }
.cm-editor.tm-md-editor .cm-content { caret-color: var(--foreground); }
-.cm-editor.tm-md-editor .cm-content { padding: 0.7rem 0.85rem; min-height: 2.75rem; }
-.cm-editor.tm-md-editor .cm-line { padding: 0 1px; color: hsl(var(--foreground)); }
+.cm-editor.tm-md-editor .cm-content { padding: var(--tm-md-content-padding, 0.25rem 0.625rem); min-height: 2rem; }
+.cm-editor.tm-md-editor .cm-line { padding: 0; color: hsl(var(--foreground)); }
.cm-editor.tm-md-editor .tm-md-hidden-token { color: transparent; opacity: 0; font-size: inherit; }
.cm-editor.tm-md-editor .tm-md-code-line { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; background: hsl(var(--muted)); border-radius: 0.3rem; }
`;
diff --git a/packages/notifications/package.json b/packages/notifications/package.json
index af90c45..a6b06be 100644
--- a/packages/notifications/package.json
+++ b/packages/notifications/package.json
@@ -12,6 +12,7 @@
"build": "tsc -p tsconfig.json --noEmit"
},
"dependencies": {
+ "@tanstack/react-router": "^1.169.1",
"@tauri-apps/api": "^2.11.0",
"@tensamin/crypto": "workspace:*",
"@tensamin/shared": "workspace:*",
diff --git a/packages/notifications/src/context.tsx b/packages/notifications/src/context.tsx
index 4e433a3..8da45f4 100644
--- a/packages/notifications/src/context.tsx
+++ b/packages/notifications/src/context.tsx
@@ -10,25 +10,27 @@ import { message as messageSchema } from "@tensamin/shared/data";
import { Avatar, AvatarFallback, AvatarImage } from "@tensamin/ui";
import { isTauri } from "@tauri-apps/api/core";
import { useSession } from "@tensamin/storage/session";
+import { useNavigate } from "@tanstack/react-router";
export const context = createContext(undefined);
-function reduceDisplay(display: string) {
- const words = display.split(" ");
- if (words.length === 1) {
- return display.slice(0, 2).toUpperCase();
- } else {
- return words[0].charAt(0).toUpperCase() + words[1].charAt(0).toUpperCase();
- }
+async function requestNotificationPermission() {
+ if (!("Notification" in window)) return false;
+
+ if (Notification.permission === "granted") return true;
+
+ const permission = await Notification.requestPermission();
+ return permission === "granted";
}
export default function Provider(props: { children: React.ReactNode }) {
- const { subscribePush } = useTTP();
+ const { subscribePush, send } = useTTP();
const { load } = useStorage();
const { get } = useUser();
const { decryptText, getSharedSecret } = useCrypto();
const { addLiveMessage, userId } = useChat();
const { moveUserIdToTop } = useSession();
+ const navigate = useNavigate();
useEffect(() => {
return subscribePush(async (ttpMessage) => {
@@ -49,7 +51,32 @@ export default function Provider(props: { children: React.ReactNode }) {
message.content,
);
- console.log(userId, sender_id, userId === sender_id);
+ // Update message state
+ if (
+ (await load("settings.read_confirmations")) &&
+ userId === sender_id
+ ) {
+ void send(
+ "message_state",
+ {
+ message_state: "read",
+ },
+ {
+ id: ttpMessage.id,
+ },
+ );
+ } else {
+ void send(
+ "message_state",
+ {
+ message_state: "received",
+ },
+ {
+ id: ttpMessage.id,
+ },
+ );
+ }
+
if (userId === sender_id) {
addLiveMessage({
...message,
@@ -60,25 +87,48 @@ export default function Provider(props: { children: React.ReactNode }) {
return;
}
- // add notification symbol to conversation cards
+ // todo: add notification symbol to conversation cards (incl. message start)
moveUserIdToTop(sender_id);
if (isTauri()) {
console.log("weewoo");
} else {
- sonnerToast(user.display, {
- classNames: {
- content: "pl-4",
- },
- description: decryptedContent,
- icon: (
-
-
- {reduceDisplay(user.display)}
-
- ),
- });
+ const hasPermissions = await requestNotificationPermission();
+
+ if (hasPermissions) {
+ const notification = new Notification(user.display, {
+ body: decryptedContent,
+ icon: user.avatar || user.display.slice(0, 2).toUpperCase(),
+ badge: user.avatar || user.display.slice(0, 2).toUpperCase(),
+ tag: `message-${user.user_id}`,
+ silent: true,
+ });
+
+ notification.onclick = () => {
+ window.focus();
+ navigate({
+ to: `/chat?id=${user.user_id}`,
+ });
+
+ notification.close();
+ };
+ } else {
+ sonnerToast(user.display, {
+ classNames: {
+ content: "pl-4",
+ },
+ description: decryptedContent,
+ icon: (
+
+
+
+ {user.display.slice(0, 2).toUpperCase()}
+
+
+ ),
+ });
+ }
}
}
});
@@ -91,6 +141,8 @@ export default function Provider(props: { children: React.ReactNode }) {
addLiveMessage,
userId,
moveUserIdToTop,
+ send,
+ navigate,
]);
return (
diff --git a/packages/shared/src/data.ts b/packages/shared/src/data.ts
index a059e9c..2210192 100644
--- a/packages/shared/src/data.ts
+++ b/packages/shared/src/data.ts
@@ -48,6 +48,31 @@ export type Communities = z.infer<
export type Calls = z.infer;
// TTP
+const user = z.object({
+ about: z.string().max(255).optional(),
+ avatar: z.string().optional(),
+ display: z.string().min(1).max(15),
+ iota_id: z.number(),
+ omikron_connections: z.array(z.number()),
+ omikron_id: z.number().optional(),
+ online_status: z.enum([
+ "user_offline",
+ "user_online",
+ "user_dnd",
+ "user_idle",
+ "user_wc",
+ "user_borked",
+ "iota_offline",
+ "iota_online",
+ "iota_borked",
+ ]),
+ public_key: z.base64(),
+ status: z.string().max(15).optional(),
+ sub_end: z.number(),
+ sub_level: z.number(),
+ user_id: z.number(),
+ username: z.string().min(1).max(15),
+});
export const ttp = {
identification: {
request: z.object({
@@ -90,33 +115,14 @@ export const ttp = {
},
get_user_data: {
request: z.object({
- user_id: z.number(),
- }),
- response: z.object({
- about: z.string().max(255).optional(),
- avatar: z.string().optional(),
- display: z.string().max(15),
- iota_id: z.number(),
- omikron_connections: z.array(z.number()),
- omikron_id: z.number().optional(),
- online_status: z.enum([
- "user_offline",
- "user_online",
- "user_dnd",
- "user_idle",
- "user_wc",
- "user_borked",
- "iota_offline",
- "iota_online",
- "iota_borked",
- ]),
- public_key: z.base64(),
- status: z.string().max(15).optional(),
- sub_end: z.number(),
- sub_level: z.number(),
- user_id: z.number(),
- username: z.string().max(15),
+ user_id: z.number().optional(),
+ username: z.string().optional(),
}),
+ response: user,
+ },
+ change_user_data: {
+ request: user.partial(),
+ response: z.object({}),
},
ping: {
request: z.object({
@@ -161,11 +167,17 @@ export const ttp = {
response: z.object({}),
},
message_state: {
- request: z.object({
- chat_partner_id: z.number(),
- send_time: z.number(),
- message_state: message.shape.message_state,
- }),
+ request: z
+ .object({
+ chat_partner_id: z.number(),
+ send_time: z.number(),
+ message_state: message.shape.message_state,
+ })
+ .or(
+ z.object({
+ message_state: message.shape.message_state,
+ }),
+ ),
response: z.object({
chat_partner_id: z.number(),
message_state: message.shape.message_state,
@@ -244,6 +256,9 @@ export interface Storage extends SettingsStorageDefaults {
legal_docs: z.infer;
cached_contacts: Contacts;
cached_communities: Communities;
+ ttp_url: string;
+ call_mute_range_start: number;
+ call_mute_range_end: number;
}
export const storageDefaults: Storage = {
@@ -276,4 +291,32 @@ export const storageDefaults: Storage = {
},
cached_contacts: [],
cached_communities: [],
+ ttp_url: "https://tensamin.net:959",
+ call_mute_range_start: -55,
+ call_mute_range_end: -45,
};
+
+// User Status
+export function getStatusColor(
+ status: z.infer,
+) {
+ switch (status) {
+ case "user_online":
+ return "#22c55e";
+ case "iota_online":
+ return "#22c55e";
+ case "user_dnd":
+ return "#ef4444";
+ case "user_idle":
+ return "#f59e0b";
+ case "user_wc":
+ return "#3b82f6";
+ case "user_borked":
+ case "iota_borked":
+ return "#6b7280";
+ case "user_offline":
+ case "iota_offline":
+ default:
+ return "#9ca3af";
+ }
+}
diff --git a/packages/shared/src/settings.ts b/packages/shared/src/settings.ts
index 0099f64..aacc162 100644
--- a/packages/shared/src/settings.ts
+++ b/packages/shared/src/settings.ts
@@ -28,6 +28,21 @@ const settings = {
type: "boolean",
default: false,
},
+ read_confirmations: {
+ display: "Enable Read Confirmations",
+ type: "boolean",
+ default: true,
+ },
+ receive_confirmations: {
+ display: "Enable Receive Confirmations",
+ type: "boolean",
+ default: true,
+ },
+ show_start_of_last_message_in_sidebar: {
+ display: "Show Start of Last Message in Sidebar",
+ type: "boolean",
+ default: true,
+ },
},
},
application: {
@@ -35,6 +50,7 @@ const settings = {
},
} as const satisfies SettingsSchema;
+// Assemble storage defaults
export default settings;
type BooleanSettingNames = {
diff --git a/packages/storage/src/session.tsx b/packages/storage/src/session.tsx
index 03b3826..d992a03 100644
--- a/packages/storage/src/session.tsx
+++ b/packages/storage/src/session.tsx
@@ -14,6 +14,7 @@ interface SessionContextType {
communities: Communities;
calls: Calls;
moveUserIdToTop: (userId: number) => void;
+ insertContact: (userId: number) => void;
}
const SessionContext = createContext(undefined);
@@ -69,9 +70,31 @@ export default function SessionProvider({ children }: { children: ReactNode }) {
});
};
+ const insertContact = (userId: number) => {
+ setContacts((prevContacts) => {
+ if (prevContacts.some((contact) => contact.user_id === userId)) {
+ return prevContacts;
+ }
+
+ const newUser = {
+ user_id: userId,
+ last_message_at: new Date().getTime(),
+ messages: [],
+ } satisfies Contacts[0];
+
+ return [newUser, ...prevContacts];
+ });
+ };
+
return (
{children}
diff --git a/packages/ttp/src/context.tsx b/packages/ttp/src/context.tsx
index f1c30aa..d33313d 100644
--- a/packages/ttp/src/context.tsx
+++ b/packages/ttp/src/context.tsx
@@ -24,7 +24,6 @@ import {
RECONNECT_RESET,
RECONNECT_TRIES,
RETRY_INTERVAL,
- TRANSPORT_URL,
} from "./values";
import {
type Calls,
@@ -192,6 +191,15 @@ export function Provider(props: {
typeof createTransportClient
> | null>(null);
const identificationStartedRef = useRef(false);
+ const identificationCancelRef = useRef(false);
+
+ // Load ttp url
+ const [ttpUrl, setTtpUrl] = useState(null);
+ useEffect(() => {
+ load("ttp_url").then((url) => {
+ setTtpUrl(url);
+ });
+ }, [load]);
/**
* Sends typed protocol messages through the active transport client.
@@ -235,6 +243,23 @@ export function Provider(props: {
return client.subscribePush(handler);
}, []);
+ // Check for error_no_iota
+ useEffect(() => {
+ if (!connected) return;
+
+ return subscribePush((message) => {
+ if (message.type !== "error_no_iota") return;
+
+ identificationCancelRef.current = true;
+ setIdentified(false);
+ setIdentifying(false);
+ setError("We couldn't reach your Iota");
+ setErrorDescription(
+ "You could try to restart your Iota, check for updates or check your network connection.",
+ );
+ });
+ }, [connected, subscribePush]);
+
useEffect(() => {
if (!connected || !identified) {
return;
@@ -266,6 +291,10 @@ export function Provider(props: {
}, [connected, identified, send]);
useEffect(() => {
+ if (!ttpUrl) {
+ return;
+ }
+
let attempts = 0;
let reconnectTimer: ReturnType | null = null;
let reconnectResetTimer: ReturnType | null = null;
@@ -343,7 +372,7 @@ export function Provider(props: {
const transportClient = !props.blockConnection
? createTransportClient(schemas, {
- url: TRANSPORT_URL,
+ url: ttpUrl,
onReadyStateChange: (state) => {
currentReadyState = state;
setReadyState(state);
@@ -352,6 +381,7 @@ export function Provider(props: {
clearReconnectTimer();
scheduleReconnectReset();
identificationStartedRef.current = false;
+ identificationCancelRef.current = false;
setConnected(true);
setIdentified(false);
setError("");
@@ -392,8 +422,12 @@ export function Provider(props: {
return;
}
+ if (!ttpUrl) {
+ return;
+ }
+
try {
- await transportClient?.connect(TRANSPORT_URL);
+ await transportClient?.connect(ttpUrl);
} catch (connectError) {
if (disposed) {
return;
@@ -460,7 +494,7 @@ export function Provider(props: {
setIdentifying(false);
identificationStartedRef.current = false;
};
- }, [props.blockConnection]);
+ }, [props.blockConnection, ttpUrl]);
useEffect(() => {
if (!connected) {
@@ -468,6 +502,11 @@ export function Provider(props: {
return;
}
+ if (identificationCancelRef.current) {
+ identificationStartedRef.current = false;
+ return;
+ }
+
if (identificationStartedRef.current) {
return;
}
@@ -536,10 +575,12 @@ export function Provider(props: {
const finalResponse = await send("challenge_response", {
challenge: decryptedChallenge,
}).catch((error) => {
- setError("Identification Failed");
- setErrorDescription(
- "Unable to complete secure identification. Please verify your credentials and try again.",
- );
+ if (!identificationCancelRef.current) {
+ setError("Identification Failed");
+ setErrorDescription(
+ "Unable to complete secure identification. Please verify your credentials and try again.",
+ );
+ }
throw error;
});
@@ -547,7 +588,7 @@ export function Provider(props: {
setFreshContacts(finalResponse.data.contacts);
setFreshCommunities(finalResponse.data.communities);
setFreshCalls(finalResponse.data.calls);
- if (cancelled) {
+ if (cancelled || identificationCancelRef.current) {
return;
}
@@ -559,6 +600,10 @@ export function Provider(props: {
return;
}
+ if (identificationCancelRef.current) {
+ return;
+ }
+
if (isStopSendingError(identificationError)) {
setError("Connection closed");
setErrorDescription(
@@ -589,7 +634,7 @@ export function Provider(props: {
: "Unable to complete secure identification because the transport request failed.",
);
} finally {
- if (!cancelled) {
+ if (!cancelled && !identificationCancelRef.current) {
setIdentifying(false);
}
}
@@ -603,14 +648,19 @@ export function Provider(props: {
}, [connected, decrypt, getSharedSecret, load, send]);
const progress = useMemo(() => {
+ if (!ttpUrl) return 10;
if (readyState === READY_STATE.CONNECTING) return 30;
if (!connected) return 45;
if (identifying) return 75;
if (!identified) return 90;
return 100;
- }, [connected, identified, identifying, readyState]);
+ }, [connected, identified, identifying, readyState, ttpUrl]);
const loadingTitle = useMemo(() => {
+ if (!ttpUrl) {
+ return "Looking up configuration";
+ }
+
if (readyState === READY_STATE.CONNECTING || !connected) {
return "Connecting to Tensamin";
}
@@ -620,9 +670,13 @@ export function Provider(props: {
}
return "Loading";
- }, [connected, identified, identifying, readyState]);
+ }, [connected, identified, identifying, readyState, ttpUrl]);
const loadingDescription = useMemo(() => {
+ if (!ttpUrl) {
+ return "Loading connection details";
+ }
+
if (readyState === READY_STATE.CONNECTING || !connected) {
return "Establishing transport channel";
}
@@ -632,13 +686,13 @@ export function Provider(props: {
}
return undefined;
- }, [connected, identified, identifying, readyState]);
+ }, [connected, identified, identifying, readyState, ttpUrl]);
if (error !== "" && errorDescription !== "") {
return ;
}
- if (!connected || !identified) {
+ if (!connected || !identified || !ttpUrl) {
return (