Initial commit

This commit is contained in:
Samuel 2024-12-08 11:27:16 +01:00
commit 28ec24b2c2
26 changed files with 4372 additions and 0 deletions

33
src/App.module.css Normal file
View file

@ -0,0 +1,33 @@
.App {
text-align: center;
}
.logo {
animation: logo-spin infinite 20s linear;
height: 40vmin;
pointer-events: none;
}
.header {
background-color: #282c34;
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
font-size: calc(10px + 2vmin);
color: white;
}
.link {
color: #b318f0;
}
@keyframes logo-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}

20
src/App.tsx Normal file
View file

@ -0,0 +1,20 @@
import { type Component } from "solid-js";
import { Route } from "@solidjs/router";
import { Home, Overview } from "./pages";
const App: Component = () => {
return (
<>
<Route
path="/"
component={Home}
/>
<Route
path="/overview"
component={Overview}
/>
</>
);
};
export default App;

141
src/app.css Normal file
View file

@ -0,0 +1,141 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 240 10% 3.9%;
--muted: 240 4.8% 95.9%;
--muted-foreground: 240 3.8% 46.1%;
--popover: 0 0% 100%;
--popover-foreground: 240 10% 3.9%;
--border: 240 5.9% 90%;
--input: 240 5.9% 90%;
--card: 0 0% 100%;
--card-foreground: 240 10% 3.9%;
--primary: 240 5.9% 10%;
--primary-foreground: 0 0% 98%;
--secondary: 240 4.8% 95.9%;
--secondary-foreground: 240 5.9% 10%;
--accent: 240 4.8% 95.9%;
--accent-foreground: 240 5.9% 10%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 0 0% 98%;
--info: 204 94% 94%;
--info-foreground: 199 89% 48%;
--success: 149 80% 90%;
--success-foreground: 160 84% 39%;
--warning: 48 96% 89%;
--warning-foreground: 25 95% 53%;
--error: 0 93% 94%;
--error-foreground: 0 84% 60%;
--ring: 240 5.9% 10%;
--radius: 0.5rem;
}
.dark,
[data-kb-theme="dark"] {
--background: 240 10% 3.9%;
--foreground: 0 0% 98%;
--muted: 240 3.7% 15.9%;
--muted-foreground: 240 5% 64.9%;
--accent: 240 3.7% 15.9%;
--accent-foreground: 0 0% 98%;
--popover: 240 10% 3.9%;
--popover-foreground: 0 0% 98%;
--border: 240 3.7% 15.9%;
--input: 240 3.7% 15.9%;
--card: 240 10% 3.9%;
--card-foreground: 0 0% 98%;
--primary: 0 0% 98%;
--primary-foreground: 240 5.9% 10%;
--secondary: 240 3.7% 15.9%;
--secondary-foreground: 0 0% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 0 0% 98%;
--info: 204 94% 94%;
--info-foreground: 199 89% 48%;
--success: 149 80% 90%;
--success-foreground: 160 84% 39%;
--warning: 48 96% 89%;
--warning-foreground: 25 95% 53%;
--error: 0 93% 94%;
--error-foreground: 0 84% 60%;
--ring: 240 4.9% 83.9%;
--radius: 0.5rem;
}
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
font-feature-settings:
"rlig" 1,
"calt" 1;
}
}
@layer utilities {
.step {
counter-increment: step;
}
.step:before {
@apply absolute w-9 h-9 bg-muted rounded-full font-mono font-medium text-center text-base inline-flex items-center justify-center -indent-px border-4 border-background;
@apply ml-[-50px] mt-[-4px];
content: counter(step);
}
}
@media (max-width: 640px) {
.container {
@apply px-4;
}
}
::-webkit-scrollbar {
width: 16px;
}
::-webkit-scrollbar-thumb {
border-radius: 9999px;
border: 4px solid transparent;
background-clip: content-box;
@apply bg-accent;
}
::-webkit-scrollbar-corner {
display: none;
}

View file

@ -0,0 +1,292 @@
import type { Component } from "solid-js"
import { createEffect, createSignal, mergeProps, on, onCleanup, onMount } from "solid-js"
import { unwrap } from "solid-js/store"
import type { Ref } from "@solid-primitives/refs"
import { mergeRefs } from "@solid-primitives/refs"
import type {
ChartComponent,
ChartData,
ChartItem,
ChartOptions,
Plugin as ChartPlugin,
ChartType,
ChartTypeRegistry,
TooltipModel
} from "chart.js"
import {
ArcElement,
BarController,
BarElement,
BubbleController,
CategoryScale,
Chart,
Colors,
DoughnutController,
Filler,
Legend,
LinearScale,
LineController,
LineElement,
PieController,
PointElement,
PolarAreaController,
RadarController,
RadialLinearScale,
ScatterController,
Tooltip
} from "chart.js"
type TypedChartProps = {
data: ChartData
options?: ChartOptions
plugins?: ChartPlugin[]
ref?: Ref<HTMLCanvasElement | null>
width?: number | undefined
height?: number | undefined
}
type ChartProps = TypedChartProps & {
type: ChartType
}
type ChartContext = {
chart: Chart
tooltip: TooltipModel<keyof ChartTypeRegistry>
}
const BaseChart: Component<ChartProps> = (rawProps) => {
const [canvasRef, setCanvasRef] = createSignal<HTMLCanvasElement | null>()
const [chart, setChart] = createSignal<Chart>()
const props = mergeProps(
{
width: 512,
height: 512,
options: { responsive: true } as ChartOptions,
plugins: [] as ChartPlugin[]
},
rawProps
)
const init = () => {
const ctx = canvasRef()?.getContext("2d") as ChartItem
const config = unwrap(props)
const chart = new Chart(ctx, {
type: config.type,
data: config.data,
options: config.options,
plugins: config.plugins
})
setChart(chart)
}
onMount(() => init())
createEffect(
on(
() => props.data,
() => {
chart()!.data = props.data
chart()!.update()
},
{ defer: true }
)
)
createEffect(
on(
() => props.options,
() => {
chart()!.options = props.options
chart()!.update()
},
{ defer: true }
)
)
createEffect(
on(
[() => props.width, () => props.height],
() => {
chart()!.resize(props.width, props.height)
},
{ defer: true }
)
)
createEffect(
on(
() => props.type,
() => {
const dimensions = [chart()!.width, chart()!.height]
chart()!.destroy()
init()
chart()!.resize(...dimensions)
},
{ defer: true }
)
)
onCleanup(() => {
chart()?.destroy()
mergeRefs(props.ref, null)
})
Chart.register(Colors, Filler, Legend, Tooltip)
return (
<canvas
ref={mergeRefs(props.ref, (el) => setCanvasRef(el))}
height={props.height}
width={props.width}
/>
)
}
function showTooltip(context: ChartContext) {
let el = document.getElementById("chartjs-tooltip")
if (!el) {
el = document.createElement("div")
el.id = "chartjs-tooltip"
document.body.appendChild(el)
}
const model = context.tooltip
if (model.opacity === 0 || !model.body) {
el.style.opacity = "0"
return
}
el.className = `p-2 bg-card text-card-foreground rounded-lg border shadow-sm text-sm ${
model.yAlign ?? `no-transform`
}`
let content = ""
model.title.forEach((title) => {
content += `<h3 class="font-semibold leading-none tracking-tight">${title}</h3>`
})
content += `<div class="mt-1 text-muted-foreground">`
const body = model.body.flatMap((body) => body.lines)
body.forEach((line, i) => {
const colors = model.labelColors[i]
content += `
<div class="flex items-center">
<span class="inline-block h-2 w-2 mr-1 rounded-full border" style="background: ${colors.backgroundColor}; border-color: ${colors.borderColor}"></span>
${line}
</div>`
})
content += `</div>`
el.innerHTML = content
const pos = context.chart.canvas.getBoundingClientRect()
el.style.opacity = "1"
el.style.position = "absolute"
el.style.left = `${pos.left + window.scrollX + model.caretX}px`
el.style.top = `${pos.top + window.scrollY + model.caretY}px`
el.style.pointerEvents = "none"
}
function createTypedChart(
type: ChartType,
components: ChartComponent[]
): Component<TypedChartProps> {
const chartsWithScales: ChartType[] = ["bar", "line", "scatter"]
const chartsWithLegends: ChartType[] = ["bar", "line"]
const options: ChartOptions = {
responsive: true,
maintainAspectRatio: false,
scales: chartsWithScales.includes(type)
? {
x: {
border: { display: false },
grid: { display: false }
},
y: {
border: {
dash: [3],
dashOffset: 3,
display: false
},
grid: {
color: "hsla(240, 3.8%, 46.1%, 0.4)"
}
}
}
: {},
plugins: {
legend: chartsWithLegends.includes(type)
? {
display: true,
align: "end",
labels: {
usePointStyle: true,
boxWidth: 6,
boxHeight: 6,
color: "hsl(240, 3.8%, 46.1%)",
font: { size: 14 }
}
}
: { display: false },
tooltip: {
enabled: false,
external: (context) => showTooltip(context)
}
}
}
Chart.register(...components)
return (props) => <BaseChart type={type} options={options} {...props} />
}
const BarChart = /* #__PURE__ */ createTypedChart("bar", [
BarController,
BarElement,
CategoryScale,
LinearScale
])
const BubbleChart = /* #__PURE__ */ createTypedChart("bubble", [
BubbleController,
PointElement,
LinearScale
])
const DonutChart = /* #__PURE__ */ createTypedChart("doughnut", [DoughnutController, ArcElement])
const LineChart = /* #__PURE__ */ createTypedChart("line", [
LineController,
LineElement,
PointElement,
CategoryScale,
LinearScale
])
const PieChart = /* #__PURE__ */ createTypedChart("pie", [PieController, ArcElement])
const PolarAreaChart = /* #__PURE__ */ createTypedChart("polarArea", [
PolarAreaController,
ArcElement,
RadialLinearScale
])
const RadarChart = /* #__PURE__ */ createTypedChart("radar", [
RadarController,
LineElement,
PointElement,
RadialLinearScale
])
const ScatterChart = /* #__PURE__ */ createTypedChart("scatter", [
ScatterController,
PointElement,
LinearScale
])
export {
BaseChart as Chart,
BarChart,
BubbleChart,
DonutChart,
LineChart,
PieChart,
PolarAreaChart,
RadarChart,
ScatterChart
}

89
src/db.ts Normal file
View file

@ -0,0 +1,89 @@
import initSqlJS, { type Database } from "sql.js";
import wasmURL from "./assets/sql-wasm.wasm?url";
import dbURL from "./assets/database.sqlite?url";
import { createMemo, createSignal } from "solid-js";
export const SELF_ID = 2;
export const SQL = await initSqlJS({
locateFile: () => wasmURL,
});
const file = await fetch(dbURL).then((res) => res.arrayBuffer());
const testDb = new SQL.Database(new Uint8Array(file));
export const [db, setDb] = createSignal<Database>(testDb);
const createStatement = (sql: string) => {
return createMemo(() => {
return db().prepare(sql);
});
};
export const roomOverviewStmt = createStatement(`
SELECT
thread.recipient_id,
thread.active,
thread.archived,
recipient.profile_joined_name,
recipient.system_joined_name,
groups.title,
message_count,
last_message_date
FROM
thread
LEFT JOIN (
SELECT
thread_id,
COUNT(*) AS message_count
FROM
message
WHERE
message.body IS NOT NULL
AND message.body != ''
GROUP BY
thread_id
) message_counts ON message_counts.thread_id = thread._id
LEFT JOIN (
SELECT
thread_id,
max(date_sent) AS last_message_date
FROM
message
GROUP BY
thread_id
) last_messages ON last_messages.thread_id = thread._id
JOIN recipient ON thread.recipient_id = recipient._id
LEFT JOIN groups ON recipient._id = groups.recipient_id
WHERE
message_count > 0
`);
export type RoomOverviewColumn = {
recipient_id: number;
active: 0 | 1;
archived: 0 | 1;
message_count: number;
last_message_date: number;
} & (
| {
profile_joined_name: string;
system_joined_name: string | null;
title: null;
}
| {
profile_joined_name: null;
system_joined_name: null;
title: string;
}
);
export const overallSentMessagesStmt = createStatement(`
SELECT
COUNT(*) as message_count
FROM
message
WHERE (message.from_recipient_id = :recipient_id AND message.body IS NOT NULL AND message.body != '')
`);

13
src/index.css Normal file
View file

@ -0,0 +1,13 @@
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto',
'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans',
'Helvetica Neue', sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
code {
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
monospace;
}

23
src/index.tsx Normal file
View file

@ -0,0 +1,23 @@
/* @refresh reload */
import { render } from "solid-js/web";
import "./index.css";
import App from "./App";
import { Router } from "@solidjs/router";
const root = document.getElementById("root");
if (import.meta.env.DEV && !(root instanceof HTMLElement)) {
throw new Error(
"Root element not found. Did you forget to add it to your index.html? Or maybe the id attribute got misspelled?"
);
}
render(
() => (
<Router>
<App />
</Router>
),
root!
);

53
src/lib/database.ts Normal file
View file

@ -0,0 +1,53 @@
const TABLES = [
"attachment",
"sqlite_sequence",
"avatar_picker",
"recipient",
"thread",
"message",
"call",
"call_link",
"cds",
"chat_colors",
"chat_folder",
"chat_folder_membership",
"distribution_list",
"distribution_list_member",
"donation_receipt",
"drafts",
"emoji_search",
"groups",
"group_membership",
"group_receipts",
"identities",
"in_app_payment",
"in_app_payment_subscriber",
"kyber_prekey",
"mention",
"message_fts",
"message_fts_data",
"message_fts_idx",
"message_fts_docsize",
"message_fts_config",
"msl_payload",
"msl_message",
"msl_recipient",
"name_collision",
"name_collision_membership",
"notification_profile",
"notification_profile_allowed_members",
"notification_profile_schedule",
"one_time_prekeys",
"payments",
"pending_pni_signature_message",
"pending_retry_receipts",
"reaction",
"remapped_recipients",
"remapped_threads",
"remote_megaphone",
"sender_key_shared",
"sender_keys",
"sessions",
"storage_key",
"story_sends",
];

44
src/lib/sql-queries.ts Normal file
View file

@ -0,0 +1,44 @@
// select messages from one chat / group
`SELECT
message.body,
message.from_recipient_id,
-- message."type" AS message_type,
FROM
message
JOIN thread ON message.thread_id = thread._id
JOIN recipient ON thread.recipient_id = recipient._id
WHERE
recipient._id = 1433
AND
message.body IS NOT NULL`;
// select messages from one chat / group with details of sender
`SELECT
message.body,
message.from_recipient_id,
-- message."type" AS message_type,
sender.system_joined_name,
sender.profile_joined_name
FROM
message
JOIN thread ON message.thread_id = thread._id
JOIN recipient AS r ON thread.recipient_id = r._id
JOIN recipient AS sender ON message.from_recipient_id = sender._id
WHERE
r._id = 4
AND message.body IS NOT NULL
AND message.body != ''`;
// select all chats / groups with details
`SELECT
thread.recipient_id,
thread.active,
recipient.profile_joined_name,
recipient.system_joined_name,
groups.title
FROM
thread
JOIN recipient ON thread.recipient_id = recipient._id
LEFT JOIN groups ON recipient._id = groups.recipient_id
WHERE (SELECT 1 FROM message WHERE message.thread_id = thread._id AND message.body IS NOT NULL)
`;

6
src/lib/utils.ts Normal file
View file

@ -0,0 +1,6 @@
import { type ClassValue, clsx } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}

32
src/pages/home.tsx Normal file
View file

@ -0,0 +1,32 @@
import { redirect, useNavigate, type RouteSectionProps } from "@solidjs/router";
import { type Component, type JSX } from "solid-js";
import { setDb, SQL } from "~/db";
export const Home: Component<RouteSectionProps> = () => {
const navigate = useNavigate();
const onFileChange: JSX.ChangeEventHandler<HTMLInputElement, Event> = (event) => {
const file = event.currentTarget.files![0];
const reader = new FileReader();
reader.addEventListener("load", () => {
const Uints = new Uint8Array(reader.result as ArrayBuffer);
setDb(new SQL.Database(Uints));
navigate("/overview");
});
reader.readAsArrayBuffer(file);
};
return (
<div>
<input
type="file"
accept=".sqlite"
onChange={onFileChange}
></input>
</div>
);
};
export default Home;

5
src/pages/index.tsx Normal file
View file

@ -0,0 +1,5 @@
import { lazy } from "solid-js";
export { Home } from "./home";
export const Overview = lazy(() => import("./overview"));

47
src/pages/overview.tsx Normal file
View file

@ -0,0 +1,47 @@
import type { RouteSectionProps } from "@solidjs/router";
import type { Component } from "solid-js";
import { overallSentMessagesStmt, roomOverviewStmt, SELF_ID, type RoomOverviewColumn } from "~/db";
type RoomOverview = {
recipientId: number;
active: boolean;
archived: boolean;
messageCount: number;
lastMessageDate: number;
title: string;
isGroup: boolean;
}[];
export const Overview: Component<RouteSectionProps> = () => {
const allSelfSentMessagesCount = overallSentMessagesStmt().getAsObject({
":recipient_id": SELF_ID,
});
const roomOverviewRaw: RoomOverviewColumn[] = [];
while (roomOverviewStmt().step()) {
roomOverviewRaw.push(roomOverviewStmt().getAsObject() as RoomOverviewColumn);
}
roomOverviewStmt().free();
const roomOverview: RoomOverview = roomOverviewRaw.map((column) => {
const isGroup = Boolean(column.title);
return {
recipientId: column.recipient_id,
active: Boolean(column.active),
archived: Boolean(column.archived),
messageCount: column.message_count,
lastMessageDate: column.last_message_date,
title: isGroup ? column.title! : (column.system_joined_name ?? column.profile_joined_name)!,
isGroup,
};
});
console.log(roomOverview);
return <p>All messages: {allSelfSentMessagesCount.message_count as number}</p>;
};
export default Overview;