286 lines
6.9 KiB
TypeScript
286 lines
6.9 KiB
TypeScript
import { computed, reactive, readonly, ref } from "vue";
|
|
|
|
export interface Person {
|
|
id: string;
|
|
name: string;
|
|
handle: string;
|
|
color: string;
|
|
online: boolean;
|
|
bio: string;
|
|
}
|
|
|
|
export interface Message {
|
|
id: string;
|
|
personId: string;
|
|
body: string;
|
|
sentAt: number;
|
|
mine: boolean;
|
|
status: "sent" | "delivered" | "failed";
|
|
}
|
|
|
|
export interface Conversation {
|
|
id: string;
|
|
personId: string;
|
|
unread: number;
|
|
pinned?: boolean;
|
|
messages: Message[];
|
|
}
|
|
|
|
const people: Person[] = [
|
|
{
|
|
id: "maya",
|
|
name: "Maya Chen",
|
|
handle: "@mayac",
|
|
color: "#ff7a8a",
|
|
online: true,
|
|
bio: "Product designer · Sydney to everywhere.",
|
|
},
|
|
{
|
|
id: "noah",
|
|
name: "Noah Williams",
|
|
handle: "@noahw",
|
|
color: "#4f9cff",
|
|
online: true,
|
|
bio: "Film, tiny cameras, and very long walks.",
|
|
},
|
|
{
|
|
id: "sofia",
|
|
name: "Sofia Rossi",
|
|
handle: "@sofiar",
|
|
color: "#9d67ff",
|
|
online: false,
|
|
bio: "Making typefaces and better pasta.",
|
|
},
|
|
{
|
|
id: "liam",
|
|
name: "Liam Park",
|
|
handle: "@liamp",
|
|
color: "#35c7a0",
|
|
online: true,
|
|
bio: "Engineer. Climber. Questionable DJ.",
|
|
},
|
|
{
|
|
id: "amara",
|
|
name: "Amara Okafor",
|
|
handle: "@amarao",
|
|
color: "#f4ad42",
|
|
online: false,
|
|
bio: "Architecture and cities after dark.",
|
|
},
|
|
];
|
|
|
|
const timestamp = Date.now();
|
|
const seedConversations: Conversation[] = [
|
|
{
|
|
id: "maya",
|
|
personId: "maya",
|
|
unread: 2,
|
|
pinned: true,
|
|
messages: [
|
|
{
|
|
id: "m1",
|
|
personId: "maya",
|
|
body: "That transition feels ridiculously smooth ✨",
|
|
sentAt: timestamp - 840_000,
|
|
mine: false,
|
|
status: "delivered",
|
|
},
|
|
{
|
|
id: "m2",
|
|
personId: "maya",
|
|
body: "Try holding it halfway, then let go slowly.",
|
|
sentAt: timestamp - 780_000,
|
|
mine: false,
|
|
status: "delivered",
|
|
},
|
|
],
|
|
},
|
|
{
|
|
id: "noah",
|
|
personId: "noah",
|
|
unread: 0,
|
|
messages: [
|
|
{
|
|
id: "n1",
|
|
personId: "noah",
|
|
body: "Uploaded the photos from yesterday.",
|
|
sentAt: timestamp - 4_200_000,
|
|
mine: false,
|
|
status: "delivered",
|
|
},
|
|
{
|
|
id: "n2",
|
|
personId: "noah",
|
|
body: "The grain is perfect.",
|
|
sentAt: timestamp - 4_000_000,
|
|
mine: true,
|
|
status: "delivered",
|
|
},
|
|
],
|
|
},
|
|
{
|
|
id: "sofia",
|
|
personId: "sofia",
|
|
unread: 1,
|
|
messages: [
|
|
{
|
|
id: "s1",
|
|
personId: "sofia",
|
|
body: "Coffee at the new place tomorrow?",
|
|
sentAt: timestamp - 18_000_000,
|
|
mine: false,
|
|
status: "delivered",
|
|
},
|
|
],
|
|
},
|
|
{
|
|
id: "liam",
|
|
personId: "liam",
|
|
unread: 0,
|
|
messages: [
|
|
{
|
|
id: "l1",
|
|
personId: "liam",
|
|
body: "The build is green. Ship it.",
|
|
sentAt: timestamp - 86_000_000,
|
|
mine: false,
|
|
status: "delivered",
|
|
},
|
|
],
|
|
},
|
|
{
|
|
id: "amara",
|
|
personId: "amara",
|
|
unread: 0,
|
|
messages: [
|
|
{
|
|
id: "a1",
|
|
personId: "amara",
|
|
body: "This city never really goes quiet.",
|
|
sentAt: timestamp - 172_000_000,
|
|
mine: false,
|
|
status: "delivered",
|
|
},
|
|
],
|
|
},
|
|
];
|
|
|
|
const conversations = ref<Conversation[]>(structuredClone(seedConversations));
|
|
const ready = ref(false);
|
|
const offline = ref(!navigator.onLine);
|
|
const settings = reactive({ simulatedLatency: 180, simulateFailures: false });
|
|
|
|
function openDatabase() {
|
|
return new Promise<IDBDatabase>((resolve, reject) => {
|
|
const request = indexedDB.open("native-vue-messenger", 1);
|
|
request.onupgradeneeded = () => request.result.createObjectStore("state");
|
|
request.onsuccess = () => resolve(request.result);
|
|
request.onerror = () => reject(request.error);
|
|
});
|
|
}
|
|
|
|
async function readStored() {
|
|
const database = await openDatabase();
|
|
return await new Promise<Conversation[] | undefined>((resolve, reject) => {
|
|
const transaction = database.transaction("state", "readonly");
|
|
const request = transaction.objectStore("state").get("conversations");
|
|
request.onsuccess = () =>
|
|
resolve(request.result as Conversation[] | undefined);
|
|
request.onerror = () => reject(request.error);
|
|
}).finally(() => database.close());
|
|
}
|
|
|
|
async function persist() {
|
|
try {
|
|
const database = await openDatabase();
|
|
await new Promise<void>((resolve, reject) => {
|
|
const transaction = database.transaction("state", "readwrite");
|
|
transaction
|
|
.objectStore("state")
|
|
.put(structuredClone(conversations.value), "conversations");
|
|
transaction.oncomplete = () => resolve();
|
|
transaction.onerror = () => reject(transaction.error);
|
|
});
|
|
database.close();
|
|
} catch {
|
|
// Private browsing and locked-down webviews can reject IndexedDB.
|
|
}
|
|
}
|
|
|
|
async function initialize() {
|
|
try {
|
|
const stored = await readStored();
|
|
if (stored?.length) conversations.value = stored;
|
|
else await persist();
|
|
} finally {
|
|
ready.value = true;
|
|
}
|
|
}
|
|
|
|
window.addEventListener("online", () => {
|
|
offline.value = false;
|
|
});
|
|
window.addEventListener("offline", () => {
|
|
offline.value = true;
|
|
});
|
|
void initialize();
|
|
|
|
export function useDemoStore() {
|
|
const conversationFor = (id: string) =>
|
|
computed(() =>
|
|
conversations.value.find((conversation) => conversation.id === id),
|
|
);
|
|
const personFor = (id: string) => people.find((person) => person.id === id);
|
|
const markRead = (id: string) => {
|
|
const conversation = conversations.value.find((item) => item.id === id);
|
|
if (conversation) conversation.unread = 0;
|
|
void persist();
|
|
};
|
|
const sendMessage = async (id: string, body: string) => {
|
|
const conversation = conversations.value.find((item) => item.id === id);
|
|
if (!conversation || !body.trim()) return false;
|
|
const message: Message = {
|
|
id: crypto.randomUUID(),
|
|
personId: id,
|
|
body: body.trim(),
|
|
sentAt: Date.now(),
|
|
mine: true,
|
|
status: "sent",
|
|
};
|
|
conversation.messages.push(message);
|
|
await persist();
|
|
await new Promise((resolve) =>
|
|
window.setTimeout(resolve, settings.simulatedLatency),
|
|
);
|
|
message.status =
|
|
settings.simulateFailures || offline.value ? "failed" : "delivered";
|
|
await persist();
|
|
return message.status === "delivered";
|
|
};
|
|
const reset = async () => {
|
|
conversations.value = structuredClone(seedConversations);
|
|
await persist();
|
|
};
|
|
return {
|
|
people: readonly(ref(people)),
|
|
conversations: readonly(conversations),
|
|
ready: readonly(ready),
|
|
offline: readonly(offline),
|
|
settings,
|
|
conversationFor,
|
|
personFor,
|
|
markRead,
|
|
sendMessage,
|
|
reset,
|
|
};
|
|
}
|
|
|
|
export function relativeTime(value: number) {
|
|
const minutes = Math.floor((Date.now() - value) / 60_000);
|
|
if (minutes < 1) return "now";
|
|
if (minutes < 60) return `${minutes}m`;
|
|
const hours = Math.floor(minutes / 60);
|
|
if (hours < 24) return `${hours}h`;
|
|
return `${Math.floor(hours / 24)}d`;
|
|
}
|