Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 065f036807 | |||
| f53cd49803 | |||
| 4e48815b91 | |||
| d9b3162f1c | |||
| 6384588122 | |||
| 978e94da90 | |||
| 34c18c13ae | |||
| cca6ea0e37 |
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
|
||||
"productName": "dumpsterChat",
|
||||
"version": "0.2.9",
|
||||
"version": "0.2.10",
|
||||
"identifier": "coffee.dustin.dumpster",
|
||||
"build": {
|
||||
"frontendDist": "../dist",
|
||||
|
||||
@@ -292,6 +292,7 @@ export function ChatArea() {
|
||||
);
|
||||
const isLoading = useMessageStore((s) => s.isLoading);
|
||||
const isLoadingOlder = useMessageStore((s) => s.isLoadingOlder);
|
||||
const hasMore = useMessageStore((s) => activeChannelId ? s.hasMoreByChannel[activeChannelId] !== false : true);
|
||||
const fetchMessages = useMessageStore((s) => s.fetchMessages);
|
||||
const fetchOlderMessages = useMessageStore((s) => s.fetchOlderMessages);
|
||||
const sendMessage = useMessageStore((s) => s.sendMessage);
|
||||
@@ -447,17 +448,16 @@ export function ChatArea() {
|
||||
|
||||
const handleScroll = useCallback(() => {
|
||||
const el = scrollContainerRef.current;
|
||||
if (!el || !activeChannelId || isLoadingOlder) return;
|
||||
if (!el || !activeChannelId || isLoadingOlder || !hasMore) return;
|
||||
if (el.scrollTop < 100) {
|
||||
const prevHeight = el.scrollHeight;
|
||||
fetchOlderMessages(activeChannelId).then(() => {
|
||||
// ponytail: maintain scroll position after prepending older messages
|
||||
requestAnimationFrame(() => {
|
||||
el.scrollTop = el.scrollHeight - prevHeight;
|
||||
});
|
||||
});
|
||||
}
|
||||
}, [activeChannelId, isLoadingOlder, fetchOlderMessages]);
|
||||
}, [activeChannelId, isLoadingOlder, hasMore, fetchOlderMessages]);
|
||||
|
||||
useEffect(() => {
|
||||
bottomRef.current?.scrollIntoView({ behavior: "auto" });
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useRef, useState, useCallback, memo } from "react";
|
||||
import { useEffect, useRef, useState, useCallback, memo, Fragment } from "react";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { useConversationStore, type ConversationMessage } from "../stores/conversation.ts";
|
||||
import { useAuthStore } from "../stores/auth.ts";
|
||||
@@ -175,6 +175,7 @@ export function DMChat() {
|
||||
|
||||
const id = conversationId || activeId;
|
||||
const conversation = conversations.find((c) => c.id === id);
|
||||
const hasMore = useConversationStore((s) => id ? s.hasMoreByConversation[id] !== false : true);
|
||||
|
||||
const handleAddReaction = useCallback(async (messageId: string, emoji: string) => {
|
||||
if (!id) return;
|
||||
@@ -189,7 +190,7 @@ export function DMChat() {
|
||||
|
||||
const handleScroll = useCallback(() => {
|
||||
const el = scrollContainerRef.current;
|
||||
if (!el || !id || isLoadingOlder) return;
|
||||
if (!el || !id || isLoadingOlder || !hasMore) return;
|
||||
if (el.scrollTop < 100) {
|
||||
const prevHeight = el.scrollHeight;
|
||||
fetchOlderMessages(id).then(() => {
|
||||
@@ -198,7 +199,7 @@ export function DMChat() {
|
||||
});
|
||||
});
|
||||
}
|
||||
}, [id, isLoadingOlder, fetchOlderMessages]);
|
||||
}, [id, isLoadingOlder, hasMore, fetchOlderMessages]);
|
||||
const messages = id ? messagesByConv[id] || [] : [];
|
||||
|
||||
useEffect(() => {
|
||||
@@ -329,9 +330,8 @@ export function DMChat() {
|
||||
const lastReadId = id ? convStates[id] : undefined;
|
||||
const showDivider = lastReadId && msg.id === lastReadId && i < messages.length - 1;
|
||||
return (
|
||||
<>
|
||||
<Fragment key={msg.id}>
|
||||
<DMMessageItem
|
||||
key={msg.id}
|
||||
msg={msg}
|
||||
onAddReaction={handleAddReaction}
|
||||
activeReactionMessageId={activeReactionMessageId}
|
||||
@@ -348,7 +348,7 @@ export function DMChat() {
|
||||
<span className="flex-1 border-t border-gb-red"></span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
<div ref={bottomRef} />
|
||||
|
||||
@@ -117,7 +117,7 @@ export function LoginForm() {
|
||||
</button>
|
||||
</form>
|
||||
{!isRegister && (
|
||||
<div className="mt-3 text-center">
|
||||
<div className="mt-4 text-center">
|
||||
<Link
|
||||
to="/forgot-password"
|
||||
className="text-gb-yellow hover:text-gb-orange text-xs"
|
||||
@@ -127,7 +127,7 @@ export function LoginForm() {
|
||||
</div>
|
||||
)}
|
||||
{!isRegister && (
|
||||
<div className="mt-3">
|
||||
<div className="mt-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={async () => {
|
||||
@@ -157,13 +157,13 @@ export function LoginForm() {
|
||||
console.error("Passkey login failed:", err);
|
||||
}
|
||||
}}
|
||||
className="terminal-button w-full border-gb-aqua text-gb-aqua"
|
||||
className="terminal-button w-full border-gb-aqua text-gb-aqua text-xs"
|
||||
>
|
||||
[SIGN IN WITH PASSKEY]
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-4 text-center">
|
||||
<div className="mt-5 text-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
|
||||
@@ -610,8 +610,8 @@ export const MessageInput = forwardRef<HTMLTextAreaElement, MessageInputProps>(f
|
||||
<button type="button" disabled={disabled || uploading}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
title="Upload file"
|
||||
className="text-gb-fg-f hover:text-gb-orange p-1 disabled:opacity-50">
|
||||
<FontAwesomeIcon icon={faPlus} className={`w-3.5 h-3.5 ${uploading ? "animate-pulse" : ""}`} />
|
||||
className="text-gb-fg-f hover:text-gb-orange p-1.5 disabled:opacity-50">
|
||||
<FontAwesomeIcon icon={faPlus} className={`w-4 h-4 ${uploading ? "animate-pulse" : ""}`} />
|
||||
</button>
|
||||
|
||||
<span className="text-gb-bg-t mx-1">│</span>
|
||||
@@ -619,28 +619,28 @@ export const MessageInput = forwardRef<HTMLTextAreaElement, MessageInputProps>(f
|
||||
{/* block formatting */}
|
||||
<button type="button" disabled={disabled}
|
||||
onClick={() => execBlock("ul")} title="Unordered list"
|
||||
className="text-gb-fg-f hover:text-gb-orange p-1 disabled:opacity-50">
|
||||
<FontAwesomeIcon icon={faListUl} className="w-3 h-3" />
|
||||
className="text-gb-fg-f hover:text-gb-orange p-1.5 disabled:opacity-50">
|
||||
<FontAwesomeIcon icon={faListUl} className="w-4 h-4" />
|
||||
</button>
|
||||
<button type="button" disabled={disabled}
|
||||
onClick={() => execBlock("ol")} title="Ordered list"
|
||||
className="text-gb-fg-f hover:text-gb-orange p-1 disabled:opacity-50">
|
||||
<FontAwesomeIcon icon={faListOl} className="w-3 h-3" />
|
||||
className="text-gb-fg-f hover:text-gb-orange p-1.5 disabled:opacity-50">
|
||||
<FontAwesomeIcon icon={faListOl} className="w-4 h-4" />
|
||||
</button>
|
||||
<button type="button" disabled={disabled}
|
||||
onClick={() => execBlock("blockquote")} title="Blockquote"
|
||||
className="text-gb-fg-f hover:text-gb-orange p-1 disabled:opacity-50">
|
||||
<FontAwesomeIcon icon={faQuoteRight} className="w-3 h-3" />
|
||||
className="text-gb-fg-f hover:text-gb-orange p-1.5 disabled:opacity-50">
|
||||
<FontAwesomeIcon icon={faQuoteRight} className="w-4 h-4" />
|
||||
</button>
|
||||
<button type="button" disabled={disabled}
|
||||
onClick={execLink} title="Insert link"
|
||||
className="text-gb-fg-f hover:text-gb-orange p-1 disabled:opacity-50">
|
||||
<FontAwesomeIcon icon={faLink} className="w-3 h-3" />
|
||||
className="text-gb-fg-f hover:text-gb-orange p-1.5 disabled:opacity-50">
|
||||
<FontAwesomeIcon icon={faLink} className="w-4 h-4" />
|
||||
</button>
|
||||
<button type="button" disabled={disabled}
|
||||
onClick={() => execBlock("h2")} title="Heading"
|
||||
className="text-gb-fg-f hover:text-gb-orange p-1 disabled:opacity-50">
|
||||
<FontAwesomeIcon icon={faHeading} className="w-3 h-3" />
|
||||
className="text-gb-fg-f hover:text-gb-orange p-1.5 disabled:opacity-50">
|
||||
<FontAwesomeIcon icon={faHeading} className="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
<span className="text-gb-bg-t mx-1">│</span>
|
||||
@@ -648,43 +648,43 @@ export const MessageInput = forwardRef<HTMLTextAreaElement, MessageInputProps>(f
|
||||
{/* inline formatting */}
|
||||
<button type="button" disabled={disabled}
|
||||
onClick={() => execInline("**", "**")} title="Bold"
|
||||
className="text-gb-fg-f hover:text-gb-orange p-1 disabled:opacity-50">
|
||||
<FontAwesomeIcon icon={faBold} className="w-3 h-3" />
|
||||
className="text-gb-fg-f hover:text-gb-orange p-1.5 disabled:opacity-50">
|
||||
<FontAwesomeIcon icon={faBold} className="w-4 h-4" />
|
||||
</button>
|
||||
<button type="button" disabled={disabled}
|
||||
onClick={() => execInline("*", "*")} title="Italic"
|
||||
className="text-gb-fg-f hover:text-gb-orange p-1 disabled:opacity-50">
|
||||
<FontAwesomeIcon icon={faItalic} className="w-3 h-3" />
|
||||
className="text-gb-fg-f hover:text-gb-orange p-1.5 disabled:opacity-50">
|
||||
<FontAwesomeIcon icon={faItalic} className="w-4 h-4" />
|
||||
</button>
|
||||
<button type="button" disabled={disabled}
|
||||
onClick={() => execInline("~~", "~~")} title="Strikethrough"
|
||||
className="text-gb-fg-f hover:text-gb-orange p-1 disabled:opacity-50">
|
||||
<FontAwesomeIcon icon={faStrikethrough} className="w-3 h-3" />
|
||||
className="text-gb-fg-f hover:text-gb-orange p-1.5 disabled:opacity-50">
|
||||
<FontAwesomeIcon icon={faStrikethrough} className="w-4 h-4" />
|
||||
</button>
|
||||
<button type="button" disabled={disabled}
|
||||
onClick={() => execInline("`", "`")} title="Code"
|
||||
className="text-gb-fg-f hover:text-gb-orange p-1 disabled:opacity-50">
|
||||
<FontAwesomeIcon icon={faCode} className="w-3 h-3" />
|
||||
className="text-gb-fg-f hover:text-gb-orange p-1.5 disabled:opacity-50">
|
||||
<FontAwesomeIcon icon={faCode} className="w-4 h-4" />
|
||||
</button>
|
||||
<button type="button" disabled={disabled}
|
||||
onClick={() => execInline("||", "||")} title="Spoiler"
|
||||
className="text-gb-fg-f hover:text-gb-orange p-1 disabled:opacity-50">
|
||||
<FontAwesomeIcon icon={faEyeSlash} className="w-3 h-3" />
|
||||
className="text-gb-fg-f hover:text-gb-orange p-1.5 disabled:opacity-50">
|
||||
<FontAwesomeIcon icon={faEyeSlash} className="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
<span className="text-gb-bg-t mx-1">│</span>
|
||||
|
||||
{/* emoji / kaomoji / gif */}
|
||||
<button type="button" disabled={disabled} onClick={toggleEmoji} title="Emoji"
|
||||
className={`p-1 disabled:opacity-50 ${showEmoji ? "text-gb-orange" : "text-gb-fg-f hover:text-gb-orange"}`}>
|
||||
className={`p-1.5 disabled:opacity-50 ${showEmoji ? "text-gb-orange" : "text-gb-fg-f hover:text-gb-orange"}`}>
|
||||
<FontAwesomeIcon icon={faSmile} className="w-4 h-4" />
|
||||
</button>
|
||||
<button type="button" disabled={disabled} onClick={toggleKaomoji} title="Kaomoji"
|
||||
className={`p-1 disabled:opacity-50 ${showKaomoji ? "text-gb-orange" : "text-gb-fg-f hover:text-gb-orange"}`}>
|
||||
className={`p-1.5 disabled:opacity-50 ${showKaomoji ? "text-gb-orange" : "text-gb-fg-f hover:text-gb-orange"}`}>
|
||||
<FontAwesomeIcon icon={faGrin} className="w-4 h-4" />
|
||||
</button>
|
||||
<button type="button" disabled={disabled} onClick={toggleGif} title="GIF"
|
||||
className={`p-1 disabled:opacity-50 ${showGif ? "text-gb-orange" : "text-gb-fg-f hover:text-gb-orange"}`}>
|
||||
className={`p-1.5 disabled:opacity-50 ${showGif ? "text-gb-orange" : "text-gb-fg-f hover:text-gb-orange"}`}>
|
||||
<FontAwesomeIcon icon={faFilm} className="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState, useMemo } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useConversationStore } from "../stores/conversation.ts";
|
||||
import { useMemberStore } from "../stores/member.ts";
|
||||
import { useServerStore } from "../stores/server.ts";
|
||||
@@ -11,6 +12,7 @@ interface NewConversationModalProps {
|
||||
export function NewConversationModal({ onClose }: NewConversationModalProps) {
|
||||
const [query, setQuery] = useState("");
|
||||
const [selected, setSelected] = useState<string[]>([]);
|
||||
const navigate = useNavigate();
|
||||
const createConversation = useConversationStore((s) => s.createConversation);
|
||||
const activeServerId = useServerStore((s) => s.activeServerId);
|
||||
const membersByServer = useMemberStore((s) => s.membersByServer);
|
||||
@@ -48,7 +50,14 @@ export function NewConversationModal({ onClose }: NewConversationModalProps) {
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (selected.length === 0) return;
|
||||
await createConversation(selected);
|
||||
try {
|
||||
const conv = await createConversation(selected);
|
||||
if (conv?.id) {
|
||||
navigate(`/dm/${conv.id}`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to create conversation:", err);
|
||||
}
|
||||
onClose();
|
||||
};
|
||||
|
||||
|
||||
+1
-1
@@ -8,7 +8,7 @@ if (isTauri) {
|
||||
// WebView safe-area fallbacks: Android doesn't support CSS env(), and
|
||||
// Linux WebKitGTK chokes on env() inside var(). Set explicit values via JS.
|
||||
if (navigator.userAgent.includes('Android')) {
|
||||
document.documentElement.style.setProperty('--safe-top', '1.5rem');
|
||||
document.documentElement.style.setProperty('--safe-top', '2rem');
|
||||
document.documentElement.style.setProperty('--safe-bottom', '0.75rem');
|
||||
document.documentElement.style.setProperty('--safe-left', '0px');
|
||||
document.documentElement.style.setProperty('--safe-right', '0px');
|
||||
|
||||
@@ -2,6 +2,13 @@ import { create } from "zustand";
|
||||
import { api } from "../lib/api.ts";
|
||||
import { type Reaction } from "./message.ts";
|
||||
|
||||
function parseDate(iso: string): number {
|
||||
if (!iso) return 0;
|
||||
const normalized = iso.includes("T") ? iso : iso.replace(" ", "T");
|
||||
const t = new Date(normalized).getTime();
|
||||
return isNaN(t) ? 0 : t;
|
||||
}
|
||||
|
||||
export interface ConversationMember {
|
||||
id: string;
|
||||
username: string;
|
||||
@@ -89,17 +96,26 @@ export const useConversationStore = create<ConversationState>((set, get) => ({
|
||||
`/conversations/${conversationId}/messages${params}`,
|
||||
);
|
||||
const list = Array.isArray(messages) ? messages : [];
|
||||
set((state) => ({
|
||||
set((state) => {
|
||||
const existing = state.messagesByConversation[conversationId] || [];
|
||||
const map = new Map<string, ConversationMessage>();
|
||||
existing.forEach((m) => map.set(m.id, m));
|
||||
list.forEach((m) => map.set(m.id, m));
|
||||
const merged = Array.from(map.values()).sort(
|
||||
(a, b) => parseDate(a.created_at) - parseDate(b.created_at)
|
||||
);
|
||||
return {
|
||||
messagesByConversation: {
|
||||
...state.messagesByConversation,
|
||||
[conversationId]: list,
|
||||
[conversationId]: merged,
|
||||
},
|
||||
hasMoreByConversation: {
|
||||
...state.hasMoreByConversation,
|
||||
[conversationId]: list.length >= 50,
|
||||
},
|
||||
isLoading: false,
|
||||
}));
|
||||
};
|
||||
});
|
||||
} catch (error) {
|
||||
set({ isLoading: false, error: error instanceof Error ? error.message : "Failed" });
|
||||
}
|
||||
@@ -118,17 +134,24 @@ export const useConversationStore = create<ConversationState>((set, get) => ({
|
||||
`/conversations/${conversationId}/messages?before=${encodeURIComponent(oldestId)}`,
|
||||
);
|
||||
const list = Array.isArray(older) ? older : [];
|
||||
set((state) => ({
|
||||
set((state) => {
|
||||
const map = new Map<string, ConversationMessage>();
|
||||
[...list, ...existing].forEach((m) => map.set(m.id, m));
|
||||
const merged = Array.from(map.values()).sort(
|
||||
(a, b) => parseDate(a.created_at) - parseDate(b.created_at)
|
||||
);
|
||||
return {
|
||||
messagesByConversation: {
|
||||
...state.messagesByConversation,
|
||||
[conversationId]: [...list, ...existing],
|
||||
[conversationId]: merged,
|
||||
},
|
||||
hasMoreByConversation: {
|
||||
...state.hasMoreByConversation,
|
||||
[conversationId]: list.length >= 50,
|
||||
},
|
||||
isLoadingOlder: false,
|
||||
}));
|
||||
};
|
||||
});
|
||||
} catch {
|
||||
set({ isLoadingOlder: false });
|
||||
}
|
||||
@@ -156,7 +179,7 @@ export const useConversationStore = create<ConversationState>((set, get) => ({
|
||||
messagesByConversation: {
|
||||
...state.messagesByConversation,
|
||||
[message.conversation_id]: [...existing, message]
|
||||
.sort((a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime()),
|
||||
.sort((a, b) => parseDate(a.created_at) - parseDate(b.created_at)),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user