initial code commit

This commit is contained in:
2024-11-22 22:45:46 -05:00
parent 7d11b4ae14
commit 551566350e
37 changed files with 7331 additions and 129 deletions
+6 -129
View File
@@ -1,130 +1,7 @@
# Logs secrets/
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
.pnpm-debug.log*
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
*.lcov
# nyc test coverage
.nyc_output
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/ node_modules/
jspm_packages/ tmp/
cache/
# Snowpack dependency directory (https://snowpack.dev/) logs/
web_modules/ db/
TODO.*
# TypeScript cache
*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional stylelint cache
.stylelintcache
# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
# parcel-bundler cache (https://parceljs.org/)
.cache
.parcel-cache
# Next.js build output
.next
out
# Nuxt.js build / generate output
.nuxt
dist
# Gatsby files
.cache/
# Comment in the public line in if your project uses Gatsby and not Next.js
# https://nextjs.org/blog/next-9-1#public-directory-support
# public
# vuepress build output
.vuepress/dist
# vuepress v2.x temp and cache directory
.temp
.cache
# Docusaurus cache and generated files
.docusaurus
# Serverless directories
.serverless/
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# TernJS port file
.tern-port
# Stores VSCode versions used for testing VSCode extensions
.vscode-test
# yarn v2
.yarn/cache
.yarn/unplugged
.yarn/build-state.yml
.yarn/install-state.gz
.pnp.*
+7
View File
@@ -0,0 +1,7 @@
secrets/
node_modules/
tmp/
cache/
logs/
db/
TODO.*
+320
View File
@@ -0,0 +1,320 @@
/* posts */
/* container for all cards */
.cards-container {
column-width: 280px; /* sets the width of each "column" */
column-gap: 16px; /* horizontal space between columns */
padding: 20px;
margin: auto;
}
.cards-container .post-card {
position: relative;
background-color: #2e003e;
border-radius: 8px;
padding: 16px;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.3);
color: #e6e6ff;
font-family: Arial, sans-serif;
display: inline-block; /* makes each card flow naturally into columns */
margin-bottom: 16px; /* adds space between items vertically */
width: 100%; /* ensures card takes up the column width */
}
.cards-container-relaxed {
display: grid;
gap: 16px;
grid-auto-rows: auto; /* allows each card to be only as tall as its content */
grid-auto-flow: dense; /* fills in gaps with smaller items if available */
padding: 20px;
grid-template-columns: repeat(auto-fill, minmax(500px, 1fr));
display: grid;
}
.cards-container-relaxed .post-card {
position: relative;
background-color: #2e003e;
border-radius: 8px;
padding: 16px;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.3);
color: #e6e6ff;
font-family: Arial, sans-serif;
display: flex;
flex-direction: column;
align-items: center;
width: 100%;
max-width: 100%;
}
/* author section inside card */
.author-section {
display: flex;
align-items: center;
margin-bottom: 8px;
width: 100%;
}
/* avatar image styling */
.card-avatar {
width: 40px;
height: 40px;
border-radius: 50%;
margin-right: 10px;
border: 2px solid #aa00ff;
}
/* author info */
.author-info {
display: flex;
flex-direction: column;
}
/* display name and handle styling */
.author-name {
font-size: 1em;
color: #d4b0ff;
margin: 0;
}
.author-handle {
font-size: 0.8em;
color: #b399ff;
margin: 0;
}
/* text content styling */
.post-text {
overflow: hidden;
max-height: 200px; /* set a limit for text content */
text-overflow: ellipsis;
white-space: normal;
}
/* styling for original post preview */
.original-post {
background-color: #3d1c3d;
border-radius: 4px;
padding: 8px;
margin-top: 8px;
width: 100%;
font-size: 0.8em;
color: #b399ff;
}
.original-author {
font-weight: bold;
margin-bottom: 4px;
color: #d4b0ff;
}
.original-text {
font-style: italic;
color: #e6e6ff;
margin: 4px 0;
}
.original-image {
width: 100%;
height: auto;
border-radius: 4px;
margin-top: 4px;
}
/* interaction counts */
.interaction-counts {
font-size: 0.8em;
color: #b399ff;
margin-top: 12px;
width: 100%;
text-align: right;
}
/* repost section styling */
.repost-section {
display: flex;
align-items: center;
background-color: #3d1c3d;
padding: 8px;
border-radius: 8px 8px 0 0;
width: 100%;
margin-bottom: 10px;
}
/* repost avatar */
.repost-avatar {
width: 30px;
height: 30px;
border-radius: 50%;
margin-right: 8px;
border: 2px solid #ff66ff;
}
/* repost info */
.repost-info .author-name {
font-size: 0.9em;
color: #d4b0ff;
margin: 0;
}
.repost-info .author-handle {
font-size: 0.8em;
color: #b399ff;
margin: 0;
}
/* styling for post image within card */
.post-image {
width: 100%;
height: auto;
border-radius: 4px;
margin-top: 8px;
}
/* pinned label styling */
.pinned-label {
background-color: #ffcc00; /* bright color to make it noticeable */
color: #333;
font-size: 0.8em;
font-weight: bold;
padding: 4px 8px;
border-radius: 4px;
display: inline-block;
margin-bottom: 8px; /* space below the label */
}
/* optional: distinct border for pinned card */
.pinned-card {
border: 2px solid #ffcc00; /* match border with label color */
padding-top: 8px; /* adjust padding for the label */
}
/* container for video within post card */
.video-container {
width: 100%;
max-width: 600px;
margin-top: 10px;
border-radius: 8px;
overflow: hidden;
position: relative;
}
/* video element styling */
.post-video {
width: 100%;
height: auto;
border-radius: 8px;
background-color: #000;
outline: none;
}
/* add a slight shadow to the video container for depth */
.video-container {
box-shadow: 0px 4px 8px rgba(0, 0, 0, 0.3);
}
/* hover effect to add a border around the video for focus */
.video-container:hover {
border: 2px solid #aa00ff;
transition: border 0.3s ease;
}
/* compose button styling with icon */
.compose-button {
position: fixed;
bottom: 20px;
right: 2%;
background-color: #6b006b;
color: #e6e6ff;
padding: 15px;
border: none;
border-radius: 50%;
font-size: 1.5em;
cursor: pointer;
box-shadow: 0px 4px 10px rgba(0, 0, 0, 0.3);
display: flex;
align-items: center;
justify-content: center;
transition: background-color 0.3s ease;
}
.compose-button:hover {
background-color: #aa00ff;
}
/* button container styling */
.button-container {
display: flex;
justify-content: space-between;
margin-top: 10px;
}
/* individual action buttons */
.action-button {
background-color: #2a002e;
color: #e6e6ff;
border: none;
border-radius: 5px;
padding: 8px;
cursor: pointer;
font-size: 1.2em;
display: flex;
align-items: center;
justify-content: center;
transition: background-color 0.3s ease;
}
.action-button:hover {
background-color: #4e004e;
}
/* specific styles for icons */
.like-button {
color: #ff4d4d;
}
.delete-button {
color: #ff4d4d;
}
.dm-button {
color: #66ccff;
}
.copy-link-button {
color: #ffcc00;
}
.pin-button {
color: #ffcc00;
}
/* dropdown menu styling */
.dropdown-menu {
position: absolute;
right: 13px;
background-color: #3d1c3d;
border-radius: 5px;
padding: 5px;
display: flex;
flex-direction: column;
gap: 5px;
box-shadow: 0px 4px 8px rgba(0, 0, 0, 0.3);
z-index: 100;
}
/* hide dropdown menu initially */
.hidden {
display: none;
}
/* dropdown action button styling */
.dropdown-menu .action-button {
width: 100%;
padding: 8px;
text-align: left;
font-size: 0.9em;
}
+160
View File
@@ -0,0 +1,160 @@
/* overlay backdrop */
#overlay {
display: none;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.7); /* darker overlay for dark theme */
z-index: 999;
transition: opacity 0.3s ease;
}
/* popup form styling for dark theme */
#new-post-form {
display: none;
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background-color: #333; /* dark background */
padding: 20px;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5);
z-index: 1000;
max-width: 400px;
width: 100%;
opacity: 0;
transition: opacity 0.3s ease;
color: #f1f1f1; /* light text for contrast */
}
/* show popup and overlay */
#new-post-form.show, #overlay.show {
display: block;
opacity: 1;
}
/* form elements styling */
#postContent, #postTitle, #postFile, #postGif, #postAudio {
width: 100%;
padding: 10px;
margin-top: 10px;
background-color: #444; /* dark background for inputs */
border: 1px solid #666;
border-radius: 4px;
color: #f1f1f1; /* light text */
resize: vertical;
}
button[type="submit"] {
display: inline-block;
width: 100%;
margin-top: 10px;
padding: 10px;
font-size: 16px;
background-color: #0077ff;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
transition: background-color 0.3s ease;
}
button[type="submit"]:hover {
background-color: #005bb5;
}
/* close button */
#closeButton {
position: absolute;
top: 8px;
right: 8px;
background: none;
border: none;
font-size: 18px;
color: #f1f1f1; /* light color for dark background */
cursor: pointer;
}
/* button styling */
button#fileButton, button#gifButton, button#audioButton {
background: #444; /* dark background for dark theme */
border: none;
border-radius: 4px;
padding: 8px;
cursor: pointer;
margin: 5px 0;
transition: background-color 0.3s ease;
}
button#fileButton:hover, button#gifButton:hover, button#audioButton:hover {
background-color: #555; /* slightly lighter on hover */
}
/* icon styling */
button#fileButton img, button#gifButton img, button#audioButton img {
width: 20px;
height: 20px;
vertical-align: middle;
}
/* hide actual input fields */
#postFile, #postGif, #postAudio {
display: none;
}
#new-post-form button {
color: white;
}
/* Updated Embed widget styling for dark theme */
.embed-widget {
display: none; /* Hide by default */
border: 1px solid #666;
border-radius: 8px;
padding: 16px;
margin-top: 20px;
background-color: #333; /* Dark background */
color: #f1f1f1; /* Light text for contrast */
max-width: 400px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5);
}
/* Input styling inside the widget for dark theme */
.embed-widget input {
width: 100%;
padding: 8px;
margin-top: 6px;
margin-bottom: 12px;
background-color: #444; /* Dark background for inputs */
border: 1px solid #666;
border-radius: 4px;
color: #f1f1f1; /* Light text */
}
/* Label styling */
.embed-widget label {
font-size: 14px;
font-weight: bold;
color: #f1f1f1; /* Light color for dark theme */
}
/* Button styling for adding an external embed */
#showEmbedButton {
margin-top: 10px;
padding: 10px 16px;
font-size: 16px;
background-color: #007acc;
color: #fff;
border: none;
border-radius: 4px;
cursor: pointer;
transition: background-color 0.3s ease;
}
#showEmbedButton:hover {
background-color: #005bb5;
}
+53
View File
@@ -0,0 +1,53 @@
/* DROPDOWN */
/* dropdown container */
.dropdown {
position: relative;
display: inline-block;
margin: 10px 0;
}
/* dropdown button styling */
.dropdown-button {
background-color: #6b006b;
color: #e6e6ff;
padding: 10px 20px;
font-weight: bold;
border-radius: 5px;
cursor: pointer;
border: none;
font-size: 1em;
}
.dropdown-button:hover {
background-color: #aa00ff;
}
/* dropdown content */
.dropdown-content {
display: none;
position: absolute;
background-color: #2e003e;
min-width: 160px;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.3);
border-radius: 5px;
z-index: 1;
overflow: hidden;
}
/* links inside the dropdown */
.dropdown-content a {
color: #e6e6ff;
padding: 10px;
text-decoration: none;
display: block;
}
.dropdown-content a:hover {
background-color: #1a001a;
color: #d4b0ff;
}
/* show dropdown on button click */
.dropdown:hover .dropdown-content {
display: block;
}
+68
View File
@@ -0,0 +1,68 @@
/* global styles */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
background-color: #1a001a;
color: #e6e6ff;
font-family: Arial, sans-serif;
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
}
.noscroll {
overflow: hidden;
height: 100%;
}
img {
cursor: pointer;
}
.hidden {
display: none;
}
.convcont {
text-align: center;
color: #b399ff; /* light purple color for visual separation */
margin: 8px 0; /* vertical space around the separator */
letter-spacing: 2px; /* spacing for the dots */
opacity: 0.9;
font-size: 1em;
}
#eof {
text-align: center;
}
/* styling for user links, e.g., @username */
.user-link {
color: #d4b0ff; /* matches the light purple in your theme */
font-weight: bold;
text-decoration: none;
cursor: pointer;
}
.user-link:hover {
color: #b399ff; /* a slightly darker shade on hover */
text-decoration: underline;
}
/* styling for external links */
.external-link {
color: #ff66ff; /* a vibrant color for external links */
font-weight: bold;
text-decoration: none;
}
.external-link:hover {
color: #aa00ff; /* darker shade on hover */
text-decoration: underline;
}
+27
View File
@@ -0,0 +1,27 @@
/* container for the loading image */
#loading {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background-color: #333; /* keep background color */
z-index: 9999999999999999;
}
/* loading image styling */
#loading img {
max-width: 50%;
height: auto;
/* responsive resizing */
}
/* for small screens, adjust the loading image size */
@media (max-width: 600px) {
#loading img {
max-width: 60%;
}
}
+193
View File
@@ -0,0 +1,193 @@
.dmselpopup {
display: none;
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 80%;
max-width: 600px;
padding: 20px;
background-color: #2c2540;
border: 1px solid #3d3452;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.4);
z-index: 1000;
overflow-y: auto;
max-height: 80vh;
border-radius: 8px;
}
.dmselpopup.active {
display: block;
}
.dmselpopup h2 {
margin-top: 0;
color: #b39ddb;
}
.dm-item {
display: flex;
align-items: center;
padding: 10px;
border-bottom: 1px solid #3d3452;
cursor: pointer;
}
.dm-item img {
border-radius: 50%;
width: 50px;
height: 50px;
margin-right: 15px;
border: 2px solid #6a5c96;
}
.dm-item .info {
display: flex;
flex-direction: column;
}
.dm-item .info .name {
font-weight: bold;
color: #e0e0ff;
}
.dm-item .info .handle {
color: #b39ddb;
}
.dm-item.disabled {
opacity: 0.5;
color: #72678f;
cursor: not-allowed;
}
.close-btn {
position: absolute;
top: 10px;
right: 10px;
cursor: pointer;
background: transparent;
border: none;
font-size: 18px;
color: #e0e0ff;
}
button {
background-color: #6a5c96;
color: #e0e0ff;
border: none;
padding: 8px 12px;
cursor: pointer;
border-radius: 4px;
}
button:hover {
background-color: #836aa8;
}
button:focus {
outline: none;
box-shadow: 0 0 0 2px #9e7dc7;
}
.dm-item.success {
background-color: #2e4d2e;
border: 1px solid #4caf50;
color: #a5d6a7;
}
.dm-item.success .info .name,
.dm-item.success .info .handle {
color: #a5d6a7;
}
.dm-item.error {
background-color: #4d2e2e;
border: 1px solid #f44336;
color: #ef9a9a;
}
.dm-item.error .info .name,
.dm-item.error .info .handle {
color: #ef9a9a;
}
/* message send success */
.checkmark {
width: 50px;
height: 50px;
border-radius: 50%;
background-color: #4caf50;
display: flex;
align-items: center;
justify-content: center;
position: relative;
}
.checkmark::after {
content: '';
width: 12px;
height: 24px;
border: solid white;
border-width: 0 4px 4px 0;
transform: rotate(45deg);
animation: checkmark 0.3s ease-in-out forwards;
margin-top: -5px;
}
@keyframes checkmark {
0% {
opacity: 0;
transform: scale(0) rotate(45deg);
}
100% {
opacity: 1;
transform: scale(1) rotate(45deg);
}
}
/* message send failure */
.crossmark {
width: 50px;
height: 50px;
border-radius: 50%;
background-color: #f44336;
/* Red background for error */
display: flex;
align-items: center;
justify-content: center;
position: relative;
}
.crossmark::before,
.crossmark::after {
content: '';
position: absolute;
width: 24px;
height: 4px;
background-color: white;
border-radius: 2px;
animation: crossmark 0.3s ease-in-out forwards;
}
/* .crossmark::before {
transform: rotate(45deg);
}
.crossmark::after {
transform: rotate(-45deg);
} */
@keyframes crossmark {
0% {
opacity: 0;
transform: scale(0);
}
100% {
opacity: 1;
transform: scale(1);
}
}
+61
View File
@@ -0,0 +1,61 @@
.profile-container {
width: 98%;
margin: auto;
/* max-width: 600px; */
background-color: #2e003e;
border-radius: 8px;
overflow: hidden;
box-shadow: 0px 0px 15px rgba(0, 0, 0, 0.5);
}
/* banner section */
.banner img {
width: 100%;
height: 150px;
object-fit: cover;
}
/* profile content */
.profile-content {
padding: 20px;
text-align: center;
}
.avatar {
width: 100px;
height: 100px;
border-radius: 50%;
border: 3px solid #aa00ff;
margin-top: -50px;
}
h1 {
font-size: 1.8em;
margin: 10px 0;
color: #d4b0ff;
}
p {
margin: 5px 0;
}
.counts {
margin: 15px 0;
font-size: 0.9em;
color: #b399ff;
}
.link {
color: #e6e6ff;
background-color: #6b006b;
padding: 10px 20px;
border-radius: 5px;
text-decoration: none;
font-weight: bold;
display: inline-block;
margin-top: 10px;
}
.link:hover {
background-color: #aa00ff;
}
+71
View File
@@ -0,0 +1,71 @@
/* container for each reply and its parent post, if any */
.reply-card-container {
background-color: #2a002e;
border-radius: 8px;
margin-bottom: 16px;
padding: 10px;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.2);
color: #e6e6ff;
font-family: Arial, sans-serif;
}
/* preview of parent post (smaller font size for less emphasis) */
.parent-post-preview, .grandparent-post-preview {
background-color: #3d1c3d; /* distinct color for preview */
border-radius: 4px;
padding: 8px;
margin-bottom: 6px;
}
/* reply card styling */
.reply-card {
background-color: #2e003e;
border-radius: 8px;
padding: 10px;
}
/* author section in reply */
.reply-card .author-section {
display: flex;
align-items: center;
margin-bottom: 6px;
}
/* avatar styling */
.reply-card .card-avatar {
width: 35px;
height: 35px;
border-radius: 50%;
margin-right: 8px;
border: 2px solid #aa00ff;
}
/* author info */
.reply-card .author-info .author-name {
font-size: 0.9em;
color: #d4b0ff;
font-weight: bold;
}
/* reply text */
.reply-card .reply-text {
font-size: 0.9em;
line-height: 1.3;
color: #e6e6ff;
margin-top: 6px;
margin-bottom: 6px;
}
/* embedded media */
.reply-card .media-container {
margin-top: 8px;
display: flex;
justify-content: center;
}
.reply-card .media-image {
max-width: 80%;
border-radius: 6px;
border: 1px solid #aa00ff;
margin-top: 6px;
}
+36
View File
@@ -0,0 +1,36 @@
/* styles for the tab buttons */
.tab-buttons {
display: flex;
justify-content: space-around;
background-color: #1da1f2;
padding: 10px 0;
}
.tab-buttons button {
border: none;
background: none;
color: white;
font-size: 16px;
cursor: pointer;
padding: 10px;
}
.tab-buttons button.active {
border-bottom: 3px solid white;
}
/* section styling */
.content {
display: none;
padding: 20px;
background-color: #2e003e;
}
.content.active {
display: block;
}
/* Hide the full-screen button from videos */
/* video::-webkit-media-controls-fullscreen-button {
display: none;
} */
+195
View File
@@ -0,0 +1,195 @@
/* basic styles for the video container and controls */
.video-container {
position: relative;
width: 640px;
max-width: 100%;
background-color: black;
}
.video-element {
width: 100%;
height: auto;
}
.controls {
position: absolute;
bottom: 0;
width: 100%;
display: flex;
align-items: center;
gap: 10px;
padding: 3px;
/* background: rgba(0, 0, 0, 0.7); */
z-index: 10;
}
.progress-bar {
width: 100%;
}
button,
input[type="range"] {
cursor: pointer;
}
/* style for the play/pause button */
.play-pause {
width: 40px;
height: 40px;
background-color: transparent;
border: none;
cursor: pointer;
position: relative;
display: flex;
align-items: center;
justify-content: center;
}
/* triangle icon for "Play" */
.play-pause.play::before {
content: '';
display: inline-block;
width: 0;
height: 0;
border-left: 12px solid white;
border-top: 8px solid transparent;
border-bottom: 8px solid transparent;
}
/* double bars for "Pause" */
.play-pause.pause::before,
.play-pause.pause::after {
content: '';
width: 4px;
height: 16px;
background-color: white;
display: inline-block;
margin: 0 2px;
}
/* fullscreen button styling */
.fullscreen {
font-size: 120%;
color: white;
width: 40px;
height: 40px;
background-color: transparent;
border: none;
cursor: pointer;
position: relative;
display: flex;
align-items: center;
justify-content: center;
}
/* enter fullscreen icon */
.fullscreen.enter::before {
display: inline-block;
width: 14px;
height: 14px;
border: 2px solid white;
border-top: none;
border-left: none;
position: absolute;
top: 4px;
left: 4px;
}
/* exit fullscreen icon */
.fullscreen.exit::before {
display: inline-block;
width: 14px;
height: 14px;
border: 2px solid white;
border-bottom: none;
border-right: none;
position: absolute;
top: 4px;
left: 4px;
}
.play-icon {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
font-size: 50px;
color: white;
pointer-events: none;
opacity: 0.7;
}
input[type="range"] {
/* palegoldenrod */
accent-color: rgb(170, 10, 117);
/* background: linear-gradient(90deg, #a200ff var(--progress), #1e9e04 var(--progress));
-webkit-appearance: none;
border: solid 1px #82CFD0;
border-radius: 8px;
transition: background 450ms ease-in; */
}
.video-container {
position: relative;
width: 640px;
max-width: 100%;
background-color: black;
}
.video-element {
width: 100%;
height: auto;
}
.controls {
position: absolute;
bottom: 0;
width: 100%;
display: flex;
align-items: center;
gap: 10px;
padding: 3px;
z-index: 10;
}
.progress-bar {
width: 100%;
}
button,
input[type="range"] {
cursor: pointer;
}
.play-pause {
width: 40px;
height: 40px;
background-color: transparent;
border: none;
cursor: pointer;
}
.fullscreen {
font-size: 120%;
color: white;
width: 40px;
height: 40px;
background-color: transparent;
border: none;
cursor: pointer;
}
.volume-container {
position: relative;
}
.volume-button {
font-size: 18px;
background-color: transparent;
border: none;
cursor: pointer;
color: white;
width: 40px;
height: 40px;
}
+63
View File
@@ -0,0 +1,63 @@
/* zoom container styles */
.zoom-container {
top: 5vh;
position: fixed;
display: inline-block;
overflow: hidden;
cursor: pointer;
max-height: 90vh;
max-width: 90vw;
cursor: pointer;
z-index: 9999;
}
/* initial image styling */
.zoom-video, .zoom-image {
max-height: 88vh;
max-width: 88vw;
transition: transform 0.5s ease, opacity 0.3s ease;
cursor: pointer;
border-radius: 8px;
border: solid 2px lightblue;
}
/* styles for full-screen zoomed view */
.zoom-container.zoomed .zoom-image, .zoom-container.zoomed .zoom-video {
position: absolute;
top: 5%;
left: 0;
width: auto;
border-radius: 0; /* remove rounded corners when zoomed */
box-shadow: 0px 8px 20px rgba(0, 0, 0, 0.6); /* shadow for zoom effect */
z-index: 999999999;
transform: scale(0.85);
}
/* darken background when image is zoomed */
.zoom-container.zoomed::before {
content: "";
position: fixed;
top: 0;
left: 0;
/* width: 100vw;
height: 100vh; */
background: rgba(0, 0, 0, 0.6);
z-index: 999;
transition: opacity 0.3s ease;
opacity: 1;
transform: scale(0.85);
}
.overlay {
background-color: rgba(0, 0, 0, 0.336);
width: 100%;
height: 100%;
overflow: hidden;
position: fixed;
top: 0;
left: 0;
margin: 0;
padding: 0;
z-index: 998;
}
+157
View File
@@ -0,0 +1,157 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Profile Display</title>
<link rel="stylesheet" href="../CSS/global.css">
<link rel="stylesheet" href="../CSS/loading.css">
<link rel="stylesheet" href="../CSS/profile.css">
<link rel="stylesheet" href="../CSS/tabs.css">
<link rel="stylesheet" href="../CSS/cards.css">
<link rel="stylesheet" href="../CSS/dropdown.css">
<link rel="stylesheet" href="../CSS/zoom.css">
<link rel="stylesheet" href="../CSS/replies.css">
<link rel="stylesheet" href="../CSS/video.css">
<link rel="stylesheet" href="../CSS/compose.css">
<link rel="stylesheet" href="../CSS/post.css">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0-beta3/css/all.min.css">
<script src="../JS/posts.cjs"></script>
<script src="../JS/script.js"></script>
<script src="../JS/compose.cjs"></script>
<script src="../src/renderer.cjs"></script>
</head>
<body>
<div id="loading">
<!-- sourced from https://xaydungso.vn/blog/20-mau-loading-gif-cute-dang-yeu-nhat-cho-website-cua-ban-en-in.html -->
<img src="../assets/loading-transparent.gif">
</div>
<div id="imgzoom" class="zoom-container zoomed">
<img src="" alt="Zoomable Image" class="zoom-image" />
<video src="" alt="Zoomable VIdeo" class="zoom-video" controls></video>
</div>
<div class="overlay hidden"></div>
<div class="profile-container">
<div class="banner">
<img id="banner-img" src="" alt="Profile Banner">
</div>
<div class="profile-content">
<img id="avatar-img" class="avatar" src="" alt="Avatar">
<h1 id="handle">User Handle</h1>
<p id="description">User description goes here.</p>
<div class="counts">
<span id="followersCount">100</span> Followers |
<span id="followsCount">150</span> Following |
<span id="postsCount">50</span> Posts
</div>
</div>
<!-- Toggle Buttons for Sections -->
<div class="tab-buttons">
<button id="postsBtn" class="active" onclick="showSection('posts')">Posts</button>
<button id="repliesBtn" onclick="showSection('replies')">Replies</button>
<button id="mediaBtn" onclick="showSection('media')">Media</button>
<button id="likesBtn" onclick="showSection('likes')">Likes</button>
<button id="bookmarksBtn" onclick="showSection('bookmarks')">Bookmarks</button>
</div>
<!-- Section Content -->
<div id="posts" class="content active">
<p class="placeholder">nothing here...</p>
<div class="dropdown">
<button class="dropdown-button">Layout: Compact</button>
<div class="dropdown-content" id="layoutDropdown">
<a href="#" data-value="large">Large</a>
<a href="#" data-value="relaxed">Relaxed</a>
<a href="#" data-value="compact" class="selected">Compact</a>
</div>
</div>
<button id="composebtn" class="compose-button">
<i class="fa-solid fa-pen"></i>
</button>
<div id="overlay"></div>
<div id="new-post-form" aria-labelledby="new-post-label">
<form id="postForm">
<label id="new-post-label" for="postContent">New Post:</label>
<button type="button" id="closeButton" aria-label="Close form"></button>
<!-- main post content -->
<textarea id="postContent" placeholder="What's on your mind?" aria-label="Post content"></textarea>
<!-- file upload button -->
<button type="button" id="fileButton" aria-label="Upload a file">
<i class="fa-regular fa-image"></i>
</button>
<input type="file" id="postFile" aria-label="Upload a file" style="display: none;" />
<!-- gif upload button -->
<button type="button" id="gifButton" aria-label="Add a GIF">
GIF
</button>
<!-- audio upload button -->
<button type="button" id="audioButton" aria-label="Upload audio">
<i class="fa-solid fa-microphone"></i>
</button>
<input type="file" id="postAudio" accept="audio/*" aria-label="Upload an audio file"
style="display: none;" />
<input type="text" id="postGif" placeholder="GIF URL" aria-label="Enter GIF URL"
style="display: none;" />
<button id="showEmbedButton" type="button"><i class="fas fa-link"></i></button>
<div id="embedWidget" class="embed-widget">
<label for="embedUri">URI:</label>
<input type="url" id="embedUri" placeholder="https://example.com">
<label for="embedTitle">Title:</label>
<input type="text" id="embedTitle" placeholder="Enter title">
<label for="embedDescription">Description:</label>
<input type="text" id="embedDescription" placeholder="Enter description">
<button type="button" aria-label="Upload a file"
onclick="document.querySelector('#embedImage').click()">
<i class="fa-regular fa-image"></i>
</button>
<input type="file" id="embedImage" aria-label="Upload a file" style="display: none;" />
</div>
<div id="uploadStatus"></div>
<button type="submit">Post</button>
</form>
<p id="statusMessage" aria-live="polite"></p>
</div>
</div>
<div id="replies" class="content">
<p class="placeholder">nothing here...</p>
</div>
<div id="media" class="content">
<p class="placeholder">nothing here...</p>
</div>
<div id="likes" class="content">
<p class="placeholder">nothing here...</p>
</div>
<div id="bookmarks" class="content">
<p class="placeholder">nothing here...</p>
</div>
</div>
<div class="dmselpopup" id="dmPopup">
<button class="close-btn" onclick="document.querySelector('#dmPopup').classList.remove('active')"></button>
<h2>Select a DM</h2>
<div id="dmList"></div>
</div>
</body>
</html>
+58
View File
@@ -0,0 +1,58 @@
const { formatStr } = require('../src/renderer.cjs');
/**
* @param {Profile} profileData
*/
function populateProfile(profileData) {
const bannerImg = document.querySelector('#banner-img');
const avatarImg = document.querySelector('#avatar-img');
const handleElement = document.querySelector('#handle');
const descriptionElement = document.querySelector('#description');
const followersCountElement = document.querySelector('#followersCount');
const followsCountElement = document.querySelector('#followsCount');
const postsCountElement = document.querySelector('#postsCount');
console.info(profileData);
// set element content
bannerImg.src = profileData.banner;
avatarImg.src = profileData.avatar;
handleElement.textContent = profileData.handle;
descriptionElement.innerHTML = formatStr(profileData.description);
followersCountElement.textContent = profileData.followersCount;
followsCountElement.textContent = profileData.followsCount;
postsCountElement.textContent = profileData.postsCount;
}
class Profile {
constructor(data) {
if (!data) throw "DATA NOT FOUND!";
this.did = data.did,
this.handle = data.handle,
this.displayName = data.displayName,
this.avatar = data.avatar,
this.associated = data.associated,
this.viewer = data.viewer,
this.labels = data.labels,
this.createdAt = data.createdAt,
this.description = formatStr(data.description),
this.indexedAt = data.indexedAt,
this.banner = data.banner,
this.followersCount = data.followersCount,
this.followsCount = data.followsCount,
this.postsCount = data.postsCount,
this.pinnedPost = data.pinnedPost
}
getProfileSummary() {
return `${this.handle} (${this.did}): ${this.description}`;
}
getPinnedPostUri() {
return this.pinnedPost.uri;
}
}
module.exports = { Profile, populateProfile }
+115
View File
@@ -0,0 +1,115 @@
// set up the overlay and form toggling
async function setup() {
const composeButton = document.querySelector('#composebtn');
const newPostForm = document.querySelector('#new-post-form');
const overlay = document.createElement('div');
overlay.id = 'overlay';
document.body.appendChild(overlay);
function openForm() {
newPostForm.classList.add("show");
overlay.classList.add("show");
}
composeButton.addEventListener('click', openForm);
}
document.addEventListener('DOMContentLoaded', setup);
async function renderCompose(ipcRenderer) {
const overlay = document.querySelector("#overlay"),
postForm = document.querySelector("#postForm"),
statusMessage = document.querySelector("#statusMessage"),
closeButton = document.querySelector("#closeButton");
const newPostForm = document.querySelector("#new-post-form");
// button and hidden input elements
const fileButton = document.querySelector("#fileButton"),
gifButton = document.querySelector("#gifButton"),
audioButton = document.querySelector("#audioButton"),
postFile = document.querySelector("#postFile"),
postGif = document.querySelector("#postGif"),
postAudio = document.querySelector("#postAudio"),
postEmbed = document.getElementById('showEmbedButton'),
embedWidget = document.querySelector('#embedWidget');
function closeForm() {
newPostForm.classList.remove("show");
overlay.classList.remove("show");
}
closeButton.addEventListener("click", closeForm);
overlay.addEventListener("click", closeForm);
// trigger corresponding inputs when button is clicked
fileButton.addEventListener("click", () => postFile.click());
gifButton.addEventListener("click", () => {
postGif.style.display = 'block';
postGif.focus();
});
audioButton.addEventListener("click", () => postAudio.click());
postEmbed.addEventListener('click', () => {
embedWidget.style.display = embedWidget.style.display === 'none' || embedWidget.style.display === '' ? 'block' : 'none';
});
postForm.addEventListener('submit', async (event) => {
event.preventDefault();
const postContent = document.querySelector('#postContent').value.trim(),
gifUrl = document.querySelector('#postGif').value.trim(),
statusMessage = document.querySelector('#statusMessage'),
embedData = Array.from(embedWidget.querySelectorAll('input')).map(o => {
const id = o.id.replace('embed', '')
if (o.type === 'file') return [id, o.files[0].name];
else return [id, o.value];
});
// files from hidden inputs
const file = postFile.files[0];
const audio = postAudio.files[0];
statusMessage.textContent = "Posting...";
try {
ipcRenderer.invoke('new-post', JSON.stringify({ text: postContent, embed: Object.fromEntries(embedData) }));
statusMessage.textContent = "Posted successfully!";
postForm.reset();
postGif.style.display = 'none'; // hide gif input after submission
} catch (error) {
console.error('Error posting:', error);
statusMessage.textContent = 'Failed to post. Please try again.';
}
});
}
function displayUploadStatus(files) {
const statusContainer = document.querySelector('#uploadStatus'); // element to display results
// Clear previous status
statusContainer.innerHTML = '';
// Iterate through files and display status
for (const [fileName, fileStatus] of Object.entries(files)) {
const statusMessage = document.createElement('p');
if (fileStatus) {
// Upload succeeded
statusMessage.textContent = `File "${fileName}" uploaded successfully!`;
statusMessage.style.color = 'green';
} else {
// Upload failed
statusMessage.textContent = `File "${fileName}" failed to upload!`;
statusMessage.style.color = 'red';
}
// Append status message to the container
statusContainer.appendChild(statusMessage);
}
}
module.exports = { renderCompose, displayUploadStatus }
+42
View File
@@ -0,0 +1,42 @@
async function renderLikes(data, ipcRenderer) {
let container;
/** @type {Map<String, Element>} */
let a;
if (document.querySelector('#likescontainer')) {
container = document.querySelector('#likescontainer');
a = new Map(Array.from(container.querySelectorAll('.post-card')).map(o => ([o.dataset.bskyid, o])));
}
else {
container = document.createElement('div');
container.classList.add('cards-container');
container.id = 'likescontainer';
a = new Map();
}
// setupWorker();
// renderLikesFeed(posts.map(p => (p?.reply?.root?.uri || p.post.uri)?.trim()), likes, ipcRenderer);
for (const post of data) renderPostSingle(post, a, likes.map(o => ({ posturi: o.post.uri, likeuri: o.post.viewer?.like })), ipcRenderer, container);
const postids = Array.from(document.querySelectorAll('[data-like-bskyid]')).map(o => o.dataset.bskyid);
console.log(postids);
// "force" garbage collection
// delete a;
a.clear();
a = null;
// append container to the document body or a specific section
const postdiv = document.querySelector('#posts');
postdiv.querySelector('.placeholder')?.remove();
if (!document.querySelector('#likescontainer')) {
postdiv.appendChild(container);
document.addEventListener('click', (e) => {
if (e.target.tagName === "VIDEO") zoomvid(e);
else if (e.target.tagName === "IMG" || e.target.classList.contains('overlay')) zoomimg(e);
});
}
}
module.exports = renderLikes;
+638
View File
@@ -0,0 +1,638 @@
const { formatStr } = require("../src/renderer.cjs");
function zoomimg(e) {
const { src } = e.target,
overlayEl = document.querySelector('.overlay'),
zoomContainer = document.querySelector('#imgzoom');
zoomContainer.querySelector('video').style.display = 'none';
zoomContainer.querySelector('img').style.display = '';
zoomContainer.querySelector('img').src = src;
zoomContainer.classList.toggle('zoomed');
overlayEl.classList.toggle('hidden');
document.querySelector('.profile-container').classList.toggle('.noscroll');
}
function zoomvid(e) {
const { src } = e.target;
if (src.endsWith('/assets/video-loading.mp4')) return;
const zoomContainer = document.querySelector('#imgzoom');
if (zoomContainer.contains(e.target)) return;
e.target.pause();
zoomContainer.querySelector('video').style.display = '';
zoomContainer.querySelector('img').style.display = 'none';
zoomContainer.querySelector('video').src = src;
zoomContainer.classList.toggle('zoomed');
document.querySelector('.overlay').classList.toggle('hidden');
document.querySelector('.profile-container').classList.toggle('.noscroll');
const isZoomed = zoomContainer.classList.contains('zoomed');
e.target.parentElement.querySelector('.controls').style.display = (isZoomed) ? 'none !important' : ''
}
// video stuff
function createVideoEl() {
// create main video container
const videoContainer = document.createElement('div');
videoContainer.classList.add('video-container');
videoContainer.classList.add('zoom-video');
// create video element
const videoElement = document.createElement('video');
videoElement.classList.add('video-element');
videoElement.src = ''; // specify the video source here
videoElement.controls = false; // disable default controls
videoElement.setAttribute('playsinline', ''); // ensures mobile compatibility for inline playback
// create controls container
const controls = document.createElement('div');
controls.classList.add('controls');
// create play/pause button
const playPauseButton = document.createElement('button');
playPauseButton.classList.add('play-pause');
playPauseButton.textContent = '';
// create fullscreen button
const fullscreenButton = document.createElement('button');
fullscreenButton.classList.add('fullscreen');
fullscreenButton.textContent = '⛶';
// create progress bar
const progressBar = document.createElement('input');
progressBar.classList.add('progress-bar');
progressBar.type = 'range';
progressBar.min = '0';
progressBar.value = '0';
progressBar.step = '0.1';
// create volume button with slider container
const volumeContainer = document.createElement('div');
volumeContainer.classList.add('volume-container');
const volumeButton = document.createElement('button');
volumeButton.classList.add('volume-button');
volumeButton.textContent = '🔊';
// append volume control to volume container
volumeContainer.appendChild(volumeButton);
// append controls to controls container
controls.appendChild(playPauseButton);
controls.appendChild(progressBar);
controls.appendChild(volumeContainer); // add volume container to the interface
controls.appendChild(fullscreenButton);
controls.style.display = 'none';
// append video and controls to video container
videoContainer.appendChild(videoElement);
videoContainer.appendChild(controls);
// JavaScript functionality for controls
// update play/pause functionality
playPauseButton.addEventListener('click', () => {
videoContainer.querySelector('.play-icon')?.remove();
if (videoElement.paused || videoElement.ended) {
videoElement.play();
playPauseButton.classList.remove('play');
playPauseButton.classList.add('pause');
} else {
videoElement.pause();
playPauseButton.classList.remove('pause');
playPauseButton.classList.add('play');
}
});
fullscreenButton.onclick = (_) => videoElement.click();
// set initial icon state to "play"
playPauseButton.classList.add('play');
// update progress bar as video plays
videoElement.addEventListener('timeupdate', () => {
progressBar.value = (videoElement.currentTime / videoElement.duration) * 100;
});
videoElement.addEventListener('pause', () => {
playPauseButton.classList.remove('pause');
playPauseButton.classList.add('play');
});
videoElement.addEventListener('play', () => {
playPauseButton.classList.remove('play');
playPauseButton.classList.add('pause');
});
// allow user to skip within video by dragging progress bar
progressBar.addEventListener('input', () => {
videoElement.currentTime = (progressBar.value / 100) * videoElement.duration;
});
// adjust video volume with volume control
let preVol;
volumeButton.addEventListener('click', () => {
if (videoElement.volume) {
preVol = videoElement.volume;
videoElement.volume = '0';
volumeButton.textContent = '🔇';
}
else {
videoElement.volume = preVol || '1';
volumeButton.textContent = '🔊';
}
});
return videoContainer;
}
function sendSuccess(dmItem, failed = false) {
const image = dmItem.querySelector('img');
if (image) {
// Create the checkmark div
const checkmark = document.createElement('div');
checkmark.className = (failed) ? 'crossmark' : 'checkmark';
checkmark.style.marginRight = '10px';
// Replace the image with the checkmark
image.replaceWith(checkmark);
setTimeout(() => {
document.querySelector('#dmPopup').querySelector('.close-btn').click();
checkmark.replaceWith(image);
checkmark.remove();
}, 1500);
}
else console.log('no pfp found!');
dmItem.classList.add((failed) ? 'error' : 'success');
}
function renderDMs(posturi, follows, ipcRenderer) {
const dmList = document.getElementById('dmList');
dmList.innerHTML = '';
follows.forEach(follow => {
const dmItem = document.createElement('div');
dmItem.className = 'dm-item' + (follow.dm ? '' : ' disabled');
dmItem.innerHTML = `
<img src="${follow.avatar}" alt="${follow.displayName}">
<div class="info">
<div class="name">${follow.displayName || follow.handle}</div>
<div class="handle">@${follow.handle}</div>
</div>
`;
dmItem.addEventListener('click', async (e) => {
e.preventDefault();
if (!follow.dm) return alert('not allowed!');
const r = await ipcRenderer.invoke('send-post', posturi, follow.handle, follow.dm.id);
if (!r) sendSuccess(dmItem, true);
else sendSuccess(dmItem);
});
dmList.appendChild(dmItem);
});
}
function createButtonEls(card, ipcRenderer, idkey = 'bskyid', containerid = 'cards') {
// Add this to your function after creating the main card content
const buttonContainer = document.createElement('div');
buttonContainer.classList.add('button-container');
// Like button
const likeButton = document.createElement('button');
likeButton.classList.add('action-button', 'like-button');
likeButton.innerHTML = `<i class="fa-${card.dataset.likeuri ? 'solid' : 'regular'} fa-heart"></i>`;
likeButton.title = 'Like';
// Like button
const repostButton = document.createElement('button');
repostButton.className = 'action-button';
repostButton.style.color = card.dataset.reposturi ? '#10c200' : 'grey';
repostButton.innerHTML = `<i class="fa-solid fa-retweet"></i>`;
repostButton.title = 'Repost';
// Delete button
const deleteButton = document.createElement('button');
deleteButton.classList.add('action-button', 'delete-button');
deleteButton.innerHTML = '<i class="fas fa-trash-alt"></i>';
deleteButton.title = 'Delete';
// Send via DM button
const sendDMButton = document.createElement('button');
sendDMButton.classList.add('action-button', 'dm-button');
sendDMButton.innerHTML = '<i class="fas fa-paper-plane"></i>';
sendDMButton.title = 'Send via DM';
// Copy link button
const copyLinkButton = document.createElement('button');
copyLinkButton.classList.add('action-button', 'copy-link-button');
copyLinkButton.innerHTML = '<i class="fas fa-link"></i>';
copyLinkButton.title = 'Copy Link';
// Pin/Unpin toggle button
const pinButton = document.createElement('button');
pinButton.classList.add('action-button', 'pin-button');
pinButton.innerHTML = '<i class="fas fa-thumbtack"></i>';
pinButton.title = 'Pin/Unpin';
// Three-dot menu button to toggle dropdown
const menuButton = document.createElement('button');
menuButton.classList.add('action-button', 'menu-button');
menuButton.innerHTML = '<i class="fas fa-ellipsis-h"></i>';
menuButton.title = 'More options';
// Dropdown menu container for collapsible options
const dropdownMenu = document.createElement('div');
dropdownMenu.classList.add('dropdown-menu');
dropdownMenu.classList.add('hidden'); // initially hidden
dropdownMenu.append(pinButton, copyLinkButton)
// Append buttons to the container
buttonContainer.appendChild(likeButton);
buttonContainer.appendChild(repostButton);
buttonContainer.appendChild(deleteButton);
buttonContainer.appendChild(sendDMButton);
buttonContainer.appendChild(menuButton);
buttonContainer.appendChild(dropdownMenu);
// Like button functionality
likeButton.addEventListener('click', async () => {
const lurl = card.dataset.likeuri,
r = await ipcRenderer.invoke('post-action', 'like', card.dataset[`${idkey}`], lurl);
// if there is a url then it should return nothing
if (!r && !lurl) return alert("ERROR!");
else if (lurl) delete card.dataset.likeuri;
else if (r) card.dataset.likeuri = r.uri
likeButton.innerHTML = `<i class="fa-${lurl ? 'regular' : 'solid'} fa-heart"></i>`
});
// Delete button functionality
deleteButton.addEventListener('click', async () => {
if (confirm('Are you sure you want to delete this post?')) {
const r = await ipcRenderer.invoke('post-action', 'delete', card.dataset[`${idkey}`]);
if (!r) return alert("ERROR!");
card.remove();
// alert('Post deleted.');
}
});
// Send DM functionality
sendDMButton.addEventListener('click', async () => {
const r = await ipcRenderer.invoke('get-connections');
document.getElementById('dmPopup').classList.add('active');
renderDMs(card.dataset[`${idkey}`], r.follows, ipcRenderer);
});
// Copy link functionality
copyLinkButton.addEventListener('click', async () => {
const r = await ipcRenderer.invoke('post-action', 'link', card.dataset[`${idkey}`]);
if (!r) return alert("ERROR!");
navigator.clipboard.writeText(r).then(() => {
alert('Link copied to clipboard!');
});
});
// Repost functionality
repostButton.addEventListener('click', async () => {
const r = await ipcRenderer.invoke('post-action', 'repost', card.dataset[`${idkey}`], card.dataset.reposturi);
if (!r) alert('ERROR!');
else if (card.dataset.reposturi) {
// TODO: move the card back to it's original position or refresh the page?
if (!card.dataset.ismypost) card.remove();
delete card.dataset.reposturi;
repostButton.style.color = 'grey';
}
else {
const pinnedEl = document.querySelector(`#${containerid}container`).querySelector('.pinned-card');
if (pinnedEl) pinnedEl.after(card);
else document.querySelector(`#${containerid}container`).prepend(card);
card.dataset.reposturi = r;
repostButton.style.color = '#10c200';
}
});
// Pin/Unpin functionality
let isPinned = false;
pinButton.addEventListener('click', async () => {
return alert("TODO");
// const r = await ipcRenderer.invoke('post-action', 'pin', card.dataset.bskyid);
// return alert(r);
isPinned = !isPinned;
pinButton.title = isPinned ? 'Unpin' : 'Pin';
pinButton.classList.toggle('pinned', isPinned);
alert(isPinned ? 'Pinned!' : 'Unpinned!');
// Add logic for pinning/unpinning the post
});
menuButton.addEventListener('click', (e) => {
e.stopPropagation(); // prevent event bubbling
dropdownMenu.classList.toggle('hidden'); // show/hide menu
});
// Hide dropdown menu when clicking outside
document.addEventListener('click', (e) => {
if (!buttonContainer.contains(e.target)) {
dropdownMenu.classList.add('hidden'); // hide menu
}
});
return buttonContainer;
}
function setupWorker(name = 'bktemp') {
if (typeof (Worker) === "undefined") return console.error('workers not supported!\nswitching to manual...');
const w = new Worker("../JS/worker.js", { name });
w.postMessage('PING');
w.onmessage = function (event) {
console.log(event.data);
};
function stopWorker() {
w.terminate();
w = undefined;
}
}
/**
* @param {*} post
* @param {*} a
* @param {{posturi: string, likeuri: string}[]} likes
* @param {*} ipcRenderer
* @param {*} container
*/
function renderPostSingle(post, a, likes, ipcRenderer, container, idkey = 'bskyid', containerid) {
/** @type {Element} */
let card;
const cardId = (post?.reply?.root?.uri || post.post.uri)?.trim();
// if the card exists, use it; otherwise, create a new card
if (a.has(cardId)) card = a.get(cardId);
else {
card = document.createElement('div');
card.classList.add('post-card');
card.dataset[`${idkey}`] = cardId;
}
// handle repost reason if it hasn't been added to this card
if (post.reason && post.reason.by && !card.querySelector('.repost-section')) {
const aname = post.reason.by.displayName || formatStr(post.reason.by.handle);
const repostSection = document.createElement('div');
repostSection.classList.add('repost-section');
const repostAvatar = document.createElement('img');
repostAvatar.src = post.reason.by.avatar;
repostAvatar.alt = `${aname}'s avatar`;
repostAvatar.classList.add('card-avatar', 'repost-avatar');
repostAvatar.loading = 'lazy';
const repostInfo = document.createElement('div');
repostInfo.classList.add('repost-info');
const repostName = document.createElement('h2');
repostName.innerHTML = aname;
repostName.classList.add('author-name');
const repostHandle = document.createElement('p');
repostHandle.innerHTML = `${formatStr('@' + post.reason.by.handle)} reposted`;
repostHandle.classList.add('author-handle');
repostInfo.appendChild(repostName);
repostInfo.appendChild(repostHandle);
repostSection.appendChild(repostAvatar);
repostSection.appendChild(repostInfo);
card.dataset.ismypost = post.post.author.did === post.reason.by.did;
card.dataset.reposturi = post.post.viewer.repost;
card.prepend(repostSection);
}
// handle reply if it hasn't been added to this card
if (post.reply && post.reply.root && !card.querySelector('.original-post')) {
const originalPost = document.createElement('div');
originalPost.classList.add('original-post');
const repaname = post.reply.root.author.displayName || formatStr(post.reply.root.author?.handle);
// add the original author and text
const originalAuthor = document.createElement('p');
originalAuthor.innerHTML = `Replying to ${repaname}`;
originalAuthor.classList.add('original-author');
originalPost.appendChild(originalAuthor);
const originalText = document.createElement('p');
originalText.innerHTML = formatStr(post.reply.root.record.text) || 'Image reply';
originalText.classList.add('original-text');
originalPost.appendChild(originalText);
// add the original image if present
if (post.reply.root.embed?.images?.length > 0) {
const originalImage = document.createElement('img');
originalImage.loading = 'lazy';
originalImage.src = post.reply.root.embed.images[0].thumb;
originalImage.alt = post.reply.root.embed.images[0].alt || 'Original post image';
originalImage.classList.add('original-image');
originalPost.appendChild(originalImage);
}
card.appendChild(originalPost);
// add the section for "what I was replying to" if grandparentAuthor exists
if (post.reply.grandparentAuthor) {
const parentPost = document.createElement('div');
parentPost.classList.add('parent-post');
const spacingEl = document.createElement('p');
spacingEl.className = 'convcont';
spacingEl.textContent = '.....';
const parentAuthor = document.createElement('p');
const paraname = post.reply.parent.author.displayName || formatStr(post.reply.parent.author.handle);
parentAuthor.innerHTML = `${paraname}`;
parentAuthor.classList.add('original-author');
parentPost.append(spacingEl, parentAuthor);
const parentText = document.createElement('p');
parentText.innerHTML = formatStr(post.reply.parent.record.text) || 'Image reply';
parentText.classList.add('original-text');
parentPost.appendChild(parentText);
if (post.reply.parent.embed && post.reply.parent.embed.images && post.reply.parent.embed.images.length > 0) {
const parentImage = document.createElement('img');
parentImage.loading = 'lazy';
parentImage.src = post.reply.parent.embed.images[0].thumb;
parentImage.alt = post.reply.parent.embed.images[0].alt || 'Parent post image';
parentImage.classList.add('original-image');
parentPost.appendChild(parentImage);
}
card.appendChild(parentPost);
}
}
// author info section for the main post
if (!card.querySelector('.author-section')) {
const aname = post.post.author.displayName || formatStr(post.post.author.handle);
const authorSection = document.createElement('div');
authorSection.classList.add('author-section');
const avatar = document.createElement('img');
avatar.src = post.post.author.avatar;
avatar.alt = `${aname}'s avatar`;
avatar.classList.add('card-avatar');
avatar.loading = 'lazy';
const authorInfo = document.createElement('div');
authorInfo.classList.add('author-info');
const displayName = document.createElement('h2');
displayName.innerHTML = aname;
displayName.classList.add('author-name');
const handle = document.createElement('p');
handle.innerHTML = `${formatStr('@' + post.post.author.handle)}`;
handle.classList.add('author-handle');
authorInfo.appendChild(displayName);
authorInfo.appendChild(handle);
authorSection.appendChild(avatar);
authorSection.appendChild(authorInfo);
card.appendChild(authorSection);
}
// main post text content
if (!card.querySelector('.post-text')) {
const textContent = document.createElement('p');
textContent.innerHTML = formatStr(post.post.record.text);
textContent.classList.add('post-text');
card.appendChild(textContent);
}
// handle video embed (if present and not added yet)
if (post.post.embed && post.post.embed.$type === 'app.bsky.embed.video#view' && !card.querySelector('.post-video')) {
const videoContainer = createVideoEl(), videoPlayer = videoContainer.querySelector('video');
videoPlayer.classList.add('post-video');
videoPlayer.width = post.post.embed.aspectRatio?.width;
videoPlayer.height = post.post.embed.aspectRatio?.height;
videoContainer.appendChild(videoPlayer);
card.appendChild(videoContainer);
videoPlayer.src = '../assets/video-loading.mp4';
videoPlayer.dataset.src = post.post.embed.playlist;
videoPlayer.poster = post.post.embed.thumbnail;
}
// main post image (if it exists and hasn't been added)
if (post.post.embed && post.post.embed.images && post.post.embed.images.length > 0 && !card.querySelector('.post-image')) {
const repeatimg = card.querySelector('.original-image')?.src === post.post.embed.images[0].thumb;
if (!repeatimg) {
const imageContainer = document.createElement('div');
imageContainer.classList.add('image-container');
const postImage = document.createElement('img');
postImage.src = post.post.embed.images[0].thumb;
postImage.alt = post.post.embed.images[0].alt || 'Embedded image';
postImage.classList.add('post-image');
postImage.loading = 'lazy';
imageContainer.appendChild(postImage);
card.appendChild(imageContainer);
}
}
// interaction counts
if (!card.querySelector('.interaction-counts')) {
const counts = document.createElement('div');
counts.classList.add('interaction-counts');
counts.textContent = `💬 ${post.post.replyCount} 🔄 ${post.post.repostCount} ❤️ ${post.post.likeCount}`;
card.appendChild(counts);
}
// add the card to the container if it's a new card
if (!a.has(cardId)) {
const lurl = likes.find(o => (o.posturi === cardId));
if (lurl) card.dataset.likeuri = lurl.likeuri;
card.appendChild(createButtonEls(card, ipcRenderer, idkey, containerid));
container.appendChild(card);
a.set(cardId, card);
}
}
/**
* @param {*} posts
* @param {*} likes
* @param {*} pinnedPost
* @param {*} ipcRenderer
*/
module.exports = function renderPosts(posts, likes, pinnedPost, ipcRenderer, idkey = 'bskyid', containerid = 'posts') {
console.log(`${idkey}, ${containerid}container, ${posts.length}`);
let container;
/** @type {Map<String, Element>} */
let a;
if (document.querySelector(`#${containerid}container`)) {
container = document.querySelector(`#${containerid}container`);
a = new Map(Array.from(container.querySelectorAll('.post-card')).map(o => ([o.dataset[`${idkey}`], o])));
}
else {
container = document.createElement('div');
container.classList.add('cards-container');
container.id = containerid;
a = new Map();
}
// setupWorker();
// renderLikesFeed(posts.map(p => (p?.reply?.root?.uri || p.post.uri)?.trim()), likes, ipcRenderer);
for (const post of posts) renderPostSingle(post, a, likes.map(o => ({ posturi: o.post.uri, likeuri: o.post.viewer?.like })), ipcRenderer, container, idkey, containerid);
const postids = Array.from(document.querySelectorAll(`[data-${idkey}]`)).map(o => o.dataset[`${idkey} `]);
console.log(postids);
// "force" garbage collection
// delete a;
a.clear();
a = null;
if (pinnedPost) {
const pinnedcard = container.querySelector(`[data-${idkey}="${pinnedPost.uri}"]`);
if (pinnedcard) {
const pinnedLabel = document.createElement('div');
pinnedLabel.classList.add('pinned-label');
pinnedLabel.textContent = '📌 Pinned'; // optionally add an emoji/icon
pinnedcard?.prepend(pinnedLabel); // place it at the top of the pinned card
pinnedcard?.classList.add('pinned-card');
container.prepend(pinnedcard); // move it to be first
}
}
// append container to the document body or a specific section
const postdiv = document.getElementById(containerid);
postdiv.querySelector('.placeholder')?.remove();
if (!document.querySelector(`#${containerid}container`)) {
postdiv.appendChild(container);
document.addEventListener('click', (e) => {
if (e.target.tagName === "VIDEO") zoomvid(e);
else if (e.target.tagName === "IMG" || e.target.classList.contains('overlay')) zoomimg(e);
});
}
}
+102
View File
@@ -0,0 +1,102 @@
// function to create a single reply card, with an optional parent post shown above it
function createReplyCard(replyData) {
const replyCardContainer = document.createElement('div');
replyCardContainer.classList.add('reply-card-container');
// add parent post if exists, along with dots if it's not the root
if (replyData.reply.parent) {
const parentPost = createPostPreview(replyData.reply.parent, true);
replyCardContainer.appendChild(parentPost);
// add dots if there's a grandparent (to indicate more context above)
if (replyData.reply.grandparentAuthor) {
const dots = document.createElement('p');
dots.classList.add('convcont');
dots.textContent = '.....';
replyCardContainer.appendChild(dots);
}
}
// main reply card
const replyCard = document.createElement('div');
replyCard.classList.add('reply-card');
// author section
const authorSection = document.createElement('div');
authorSection.classList.add('author-section');
const avatar = document.createElement('img');
avatar.src = replyData.post.author.avatar;
avatar.alt = `${replyData.post.author.handle}'s avatar`;
avatar.classList.add('card-avatar');
authorSection.appendChild(avatar);
const authorInfo = document.createElement('div');
authorInfo.classList.add('author-info');
const authorName = document.createElement('p');
authorName.textContent = replyData.post.author.displayName || replyData.post.author.handle;
authorName.classList.add('author-name');
authorInfo.appendChild(authorName);
authorSection.appendChild(authorInfo);
replyCard.appendChild(authorSection);
// reply text
const replyText = document.createElement('p');
replyText.textContent = replyData.post.record.text || 'Image reply';
replyText.classList.add('reply-text');
replyCard.appendChild(replyText);
// embedded media if available
if (replyData.post.embed && replyData.post.embed.external) {
const mediaContainer = document.createElement('div');
mediaContainer.classList.add('media-container');
const mediaImage = document.createElement('img');
mediaImage.src = replyData.post.embed.external.thumb;
mediaImage.alt = replyData.post.embed.external.description;
mediaImage.classList.add('media-image');
mediaImage.loading = 'lazy';
mediaContainer.appendChild(mediaImage);
replyCard.appendChild(mediaContainer);
}
replyCardContainer.appendChild(replyCard);
return replyCardContainer;
}
// helper function to create a condensed preview of the parent post
function createPostPreview(post, isParent = false) {
const postPreview = document.createElement('div');
postPreview.classList.add(isParent ? 'parent-post-preview' : 'grandparent-post-preview');
// author and content preview
const author = document.createElement('p');
author.textContent = `${post.author.displayName || post.author.handle}`;
author.classList.add('original-author');
postPreview.appendChild(author);
const content = document.createElement('p');
content.textContent = post.record.text || 'Image reply';
content.classList.add('original-text');
postPreview.appendChild(content);
return postPreview;
}
module.exports = function renderReplies(replyObj) {
const repliesContainer = document.querySelector('#replies');
repliesContainer.querySelector('.placeholder')?.remove();
const { cursor, replies } = replyObj;
if (cursor) sessionStorage.setItem('repliescursor', cursor);
else sessionStorage.removeItem('repliescursor');
replies.forEach(reply => {
const replyCard = createReplyCard(reply);
repliesContainer.appendChild(replyCard);
});
}
+58
View File
@@ -0,0 +1,58 @@
document.addEventListener('scroll', () => bottomscrolled(document.querySelector('#posts')));
async function bottomscrolled(targetDiv) {
const scrollPosition = window.scrollY + window.innerHeight,
pageHeight = document.documentElement.scrollHeight,
atbottom = (scrollPosition >= pageHeight);
if (!atbottom) return;
window.electronAPI.getnewposts();
}
// function to toggle the active section
function showSection(sectionId) {
// hide all sections
document.querySelectorAll('.content').forEach((content) => {
content.classList.remove('active');
});
// remove active class from all buttons
document.querySelectorAll('.tab-buttons button').forEach((button) => {
button.classList.remove('active');
});
// show the selected section and activate the button
document.getElementById(sectionId).classList.add('active');
document.getElementById(sectionId + 'Btn').classList.add('active');
}
// function to handle layout selection
function togglerelaxed(e) {
e.preventDefault(); // prevent the default anchor behavior
const value = e.target.getAttribute('data-value');
const container = document.querySelector(`#${sessionStorage.getItem('currenttab') || 'cardscontainer'}`);
console.log(value)
if (!container) return console.warn('container not found!');
if (value === 'compact') {
container.classList.remove('cards-container-relaxed');
container.classList.add('cards-container');
} else {
container.style.gridTemplateColumns = (value !== 'large') ? 'repeat(auto-fill, minmax(280px, 1fr))' : '';
container.classList.remove('cards-container');
container.classList.add('cards-container-relaxed');
}
// update dropdown button text to reflect current selection
document.querySelector('.dropdown-button').textContent = `Layout: ${value.charAt(0).toUpperCase() + value.slice(1)}`;
}
document.addEventListener('DOMContentLoaded', () => {
// event listener for dropdown items
document.querySelectorAll('#layoutDropdown a').forEach(item => {
item.addEventListener('click', togglerelaxed);
});
});
+9
View File
@@ -0,0 +1,9 @@
// listen for messages from the main thread
self.onmessage = function (event) {
const data = event.data;
self.postMessage(document.querySelector('#posts')?.firstChild)
// send the result back to the main thread
self.postMessage(result);
};
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.
+130
View File
@@ -0,0 +1,130 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
.pnpm-debug.log*
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
*.lcov
# nyc test coverage
.nyc_output
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# Snowpack dependency directory (https://snowpack.dev/)
web_modules/
# TypeScript cache
*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional stylelint cache
.stylelintcache
# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
# parcel-bundler cache (https://parceljs.org/)
.cache
.parcel-cache
# Next.js build output
.next
out
# Nuxt.js build / generate output
.nuxt
dist
# Gatsby files
.cache/
# Comment in the public line in if your project uses Gatsby and not Next.js
# https://nextjs.org/blog/next-9-1#public-directory-support
# public
# vuepress build output
.vuepress/dist
# vuepress v2.x temp and cache directory
.temp
.cache
# Docusaurus cache and generated files
.docusaurus
# Serverless directories
.serverless/
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# TernJS port file
.tern-port
# Stores VSCode versions used for testing VSCode extensions
.vscode-test
# yarn v2
.yarn/cache
.yarn/unplugged
.yarn/build-state.yml
.yarn/install-state.gz
.pnp.*
+65
View File
@@ -0,0 +1,65 @@
import { ChatBskyConvoDefs } from "@atproto/api";
import { convoHeader, getDID, initSession } from "./main.js";
export async function getDMs(cursor = undefined, limit = undefined) {
try {
const agent = await initSession(),
convos = await agent.chat.bsky.convo.listConvos({ cursor, limit });
return convos.data;
}
catch (err) {
console.error(err);
return null;
}
}
export async function sendPost(e, posturi, utag, dmid = undefined) {
try {
const agent = await initSession(),
[, did, collection, rkey] = posturi.match(/^at:\/\/([^\/]+)\/([^\/]+)\/(.+)$/),
post = await agent.getPost({ repo: did, rkey, collection }),
/** @type {ChatBskyConvoDefs.MessageInput} */
msg = {
embed: {
$type: 'app.bsky.embed.record',
record: {
cid: post.cid,
uri: post.uri
}
},
text: ''
}
return await sendMessage(utag, msg, dmid);
}
catch (err) {
console.error(err);
return null;
}
}
export async function sendMessage(utag, message, convoIdInp = undefined) {
try {
const agent = await initSession();
let convoId;
if (convoIdInp) convoId = convoIdInp;
else {
const did = await getDID(utag);
if (!did) return null;
const convos = (await getDMs()).convos;
convoId = await agent.chat.bsky.convo.getConvo({ convoId: convos.find(o => o.members.find(o => o.did === did)) });
}
return (await agent.chat.bsky.convo.sendMessage({ convoId, message }, { headers: convoHeader })).data;
}
catch (err) {
console.error(err);
return null;
}
}
+120
View File
@@ -0,0 +1,120 @@
import { dialog, ipcMain } from "electron";
import { PassThrough, Readable } from 'stream';
import { initSession } from "./main.js";
import sharp from "sharp";
import ffmpeg from 'fluent-ffmpeg';
import fs from 'fs';
const fnamesToPosts = {};
// Store data in main process only
ipcMain.handle('set-sensitive-data', (event, key, value) => {
fnamesToPosts[key] = value;
});
ipcMain.handle('get-files', (event, key) => {
return JSON.stringify(fnamesToPosts);
});
export const clearPostFiles = () => {
for (const key in fnamesToPosts) {
delete fnamesToPosts[key];
}
}
export const getPostFiles = () => Object.entries(fnamesToPosts);
export const popPostFile = (fname) => {
if (!fname || !(fname in fnamesToPosts)) return;
const o = fnamesToPosts[fname];
delete fnamesToPosts[fname];
return o;
}
ipcMain.handle('clear-sensitive-data', clearPostFiles);
export const isAnimated = (mimeType) => (mimeType.startsWith('video/') || mimeType.endsWith('/gif'));
function resizeVideo(data, type) {
// normal video, do not convert
if (!type.endsWith('/gif')) {
const readableStream = new Readable();
readableStream.push(data);
readableStream.push(null);
return readableStream;
}
const passThroughStream = new PassThrough();
ffmpeg(data)
// .size(`${width}x?`) // Resize to specified width, keeping aspect ratio
.outputOptions('-c:v libx264', '-crf 28') // Set codec and compression level
.format('mp4') // Set format to MP4
.on('error', (err) => {
console.error('Error processing video:', err);
passThroughStream.destroy(err);
})
.on('end', () => {
console.log('Video processing completed.');
})
.pipe(passThroughStream, { end: true });
return passThroughStream;
}
function resizePhoto(data, type) {
const outputStream = new PassThrough();
sharp(data)
.resize({ width: 800 }) // adjust width to reduce size REMOVEME?
.toFormat(type)
.pipe(outputStream);
return outputStream;
}
export async function uploadFile(fobj) {
// bluesky does not support gifs, so EVERYTHING needs to be an mp4
const agent = await initSession(),
anim = isAnimated(fobj.type),
type = anim ? 'mp4' : fobj.type.split('/')?.at(1),
typefull = anim ? 'video/mp4' : fobj.type;
let outputStream;
if (anim) outputStream = resizeVideo(fobj.data, fobj.type);
else outputStream = resizePhoto(fobj.data, type);
// not reading through a stream ffs
// if (compressedBuffer.length > 976 * 1024) {
// console.error("File is still too large after compression. Try further resizing.");
// return;
// }
// upload the stream as a blob
const { data } = await agent.uploadBlob(outputStream, { encoding: typefull });
return data;
}
/**
* @param {...{name: string, type: string, size: number, data: Uint8Array}} fobjs
*/
export async function handleFileOpen(event, ...fobjs) {
const o = {};
for (const fobj of fobjs) {
try {
const blobData = await uploadFile(fobj);
o[fobj.name] = blobData.blob.ref;
fnamesToPosts[fobj.name] = blobData.blob;
}
catch (err) {
console.error(err);
o[fobj.name] = false;
}
}
return JSON.stringify(o);
}
+242
View File
@@ -0,0 +1,242 @@
import { Agent, AppBskyFeedGenerator, CredentialSession } from '@atproto/api';
import fs from 'fs';
import * as crypto from 'crypto';
import json from '../secrets/config.json' with { type: 'json' }
import { ipcMain } from 'electron';
import logger from '../logger.js';
import { getHistory } from '../src/db.js';
import convertAndServe, { clearCache } from './video.js';
import { handleFileOpen } from './files.js';
import post, { handlePostAction } from './post.js';
import { sendPost } from './convoManager.js';
const { uname, upass } = json.bluesky;
const sessionFilePath = './secrets/session.json'; // path to your session file
export const convoHeader = { "Atproto-Proxy": "did:web:api.bsky.chat#bsky_chat" };
// function to load session data from the file
function loadSession() {
try {
if (fs.existsSync(sessionFilePath)) {
const data = fs.readFileSync(sessionFilePath, 'utf-8');
logger.info('session loaded successfully');
return JSON.parse(data);
}
logger.info('no existing session found');
return null;
} catch (error) {
logger.error('failed to load session:', error);
return null;
}
}
// create a Bluesky session and agent
const session = new CredentialSession(new URL('https://bsky.social'));
const agent = new Agent(session);
// function to save session data to a file, with validation
function saveSession(data) {
if (!data) {
logger.error('No session data to save.');
return;
}
try {
fs.writeFileSync(sessionFilePath, JSON.stringify(data), 'utf-8');
logger.info('session saved successfully');
} catch (error) {
logger.error('failed to save session:', error);
}
}
// initialize and resume session if possible
export async function initSession() {
if (session?.session?.active) return agent;
const savedSession = loadSession();
if (savedSession) {
try {
await session.resumeSession(savedSession);
logger.info('session resumed successfully');
} catch (resumeError) {
logger.warn('failed to resume session, attempting to refresh:', resumeError);
try {
await session.refreshSession();
if (session.session) {
saveSession(session.session); // ensure session data exists before saving
logger.info('session refreshed and saved successfully');
} else {
logger.error('refresh failed to retrieve valid session data.');
await loginAndSaveSession();
}
} catch (refreshError) {
logger.error('session refresh failed, logging in again:', refreshError);
await loginAndSaveSession();
}
}
} else {
await loginAndSaveSession();
}
// await agent.deleteRepost('at://did:plc:amhzdnxsvkcqjgwdh5kqmhk7/app.bsky.feed.post/3l6tg2zdph62n');
return agent;
}
// helper function to login and save the session
async function loginAndSaveSession() {
try {
await session.login({ identifier: uname, password: upass });
if (session.session) {
saveSession(session.session);
logger.info('logged in and session saved');
} else {
logger.error('login succeeded but no session data available to save');
}
} catch (loginError) {
logger.error('login failed:', loginError);
}
}
export const getDID = async (utag) => {
try {
let did;
if (!utag || utag === '@me') {
did = session.did || session?.session?.did;
} else {
const resolved = await agent.resolveHandle({ handle: utag });
did = resolved.data.did;
}
return did;
}
catch (err) {
console.error(err);
return null;
}
}
async function getUserData(utag, allData = false) {
try {
await initSession(); // ensure session is fully initialized before proceeding
const did = await getDID(utag);
if (!did) return { err: 'DID not found!' };
const { data } = await agent.getProfile({ actor: did });
const output = { profile: data };
if (allData) {
const { data: { feed, cursor } } = await agent.getAuthorFeed({ actor: output.profile.did, limit: 20, includePins: true }),
{ data: { feed: likes, cursor: likesCursor } } = await agent.getActorLikes({ actor: did });
output.likes = likes;
output.likesCursor = likesCursor;
output.posts = feed;
output.postcursor = cursor;
output.replies = await getReplies(utag);
}
return output;
} catch (err) {
logger.error('failed to fetch user data:', err);
return {};
}
}
export const getPosts = async (utag, cursor = undefined, likesCursor = undefined) => {
try {
const did = await getDID(utag);
if (!did) return { err: 'DID not found!' };
const likes = await agent.getActorLikes({ actor: did, cursor: likesCursor }),
posts = await agent.getAuthorFeed({ actor: did, limit: 20, includePins: true, cursor });
return { posts, likes };
}
catch (err) {
console.error(err);
return { err: 'internal server error!' }
}
}
export const getReplies = async (utag, cursorInit, limit = 20) => {
const did = await getDID(utag);
let posts = await agent.getAuthorFeed({ actor: did, limit: limit, includePins: true, cursor: cursorInit });
let cursor = posts.data.cursor;
const replies = posts.data.feed.filter(o => o.reply);
while (cursor && replies.length < limit) {
posts = await agent.getAuthorFeed({ actor: did, limit: limit, includePins: true, cursor });
replies.push(...posts.data.feed.filter(o => o.reply));
cursor = posts.data.cursor;
}
return { replies, cursor };
}
export const getMedia = async (utag, cursorInit, limit = 20) => {
const did = await getDID(utag);
let posts = await agent.getAuthorFeed({ actor: did, limit: limit, includePins: true, cursor: cursorInit });
let cursor = posts.data.cursor;
const replies = posts.data.feed.filter(o => o.reply);
while (cursor && replies.length < limit) {
posts = await agent.getAuthorFeed({ actor: did, limit, includePins: true, cursor });
replies.push(...posts.data.feed.filter(o => o.reply));
cursor = posts.data.cursor;
}
return { replies, cursor };
}
export const getConnections = async (e, utag, cursor, limit = 20) => {
const did = await getDID(utag),
{ data: { follows: followsRaw, cursor: followsCursor } } = await agent.getFollows({ actor: did, cursor, limit }),
{ data: { followers: followersRaw, cursor: followersCursor } } = await agent.getFollowers({ actor: did, cursor, limit }),
{ data: { convos } } = await agent.chat.bsky.convo.listConvos({}, { headers: convoHeader }),
follows = followsRaw?.length ? (await agent.getProfiles({ actors: followsRaw.map(o => o.did) })).data.profiles : [],
followers = followersRaw?.length ? (await agent.getProfiles({ actors: followersRaw.map(o => o.did) })).data.profiles : [];
return {
follows: follows.map(f => {
f.dm = convos.find(o => o.members.find(m => m.did === f.did));
return f;
}), followsCursor, followers, followersCursor
};
}
// export IPC setup function
export async function setupIPC() {
ipcMain.handle('getdata', async (event, utag, all = false) => {
const data = await getUserData(utag, all);
event.sender.send('udata', JSON.stringify(data));
});
ipcMain.handle('getposts', async (e, utag, cursor, likescursor) => {
if (!cursor) return e.sender.send(404);
const data = await getPosts(utag, cursor, likescursor);
if (data.code) return e.sender.send(data.code);
e.sender.send('posts', JSON.stringify(data));
clearCache();
});
ipcMain.handle('get-connections', getConnections);
ipcMain.handle('gethistory', (e, limit, offset) => e.sender.send('history', JSON.stringify(getHistory(limit, offset))));
ipcMain.handle('getreplies', async (e, limit, offset) => e.sender.send('replies', JSON.stringify(await getReplies(limit, offset))));
ipcMain.handle('getvideo', async (e, oldurl) => {
const newURL = await convertAndServe(`${crypto.randomUUID()}.mp4`, oldurl);
e.sender.send('video', oldurl, newURL);
});
ipcMain.handle('send-post', sendPost);
ipcMain.handle('new-post', post);
ipcMain.handle('upload-file', handleFileOpen);
ipcMain.handle('post-action', async (e, action, id, condition) => await handlePostAction(e, action, id, agent, condition));
}
+101
View File
@@ -0,0 +1,101 @@
import { Agent, RichText } from "@atproto/api";
import { getPostFiles, isAnimated, popPostFile, uploadFile } from "./files.js";
import { initSession } from "./main.js";
async function createEmbed(imgs, vid, embed) {
let embdData;
if (vid) {
embdData = {
$type: 'app.bsky.embed.video',
video: vid[1]
}
}
else if (vid) {
embdData = {
$type: 'app.bsky.embed.images',
images: imgs.map(f => ({ alt: 'image!', image: f[1] }))
}
}
else if (embed) {
const img = popPostFile(embed.Image);
return {
$type: 'app.bsky.embed.external',
external: {
description: embed.Description,
uri: embed.Uri,
title: embed.Title,
thumb: img
}
}
}
}
export default async function post(e, postData) {
try {
const { text, embed } = JSON.parse(postData);
const agent = await initSession(),
files = getPostFiles(),
imgs = files.filter(o => !isAnimated(o[1].mimeType)),
vid = files.find(o => isAnimated(o[1].mimeType)),
rt = new RichText({ text });
agent.post({
text: rt.text,
facets: rt.facets,
langs: ["en-US"],
createdAt: new Date().toISOString(),
embed: await createEmbed(imgs, vid, embed)
});
}
catch (err) {
console.error(err);
return false;
}
}
/**
* @param {*} e
* @param {*} action
* @param {*} postid
* @param {Agent} agent
* @returns
*/
export async function handlePostAction(e, action, postid, agent, condition = false) {
try {
if (!postid) return 404;
let r;
const [, did, collection, rkey] = postid.match(/^at:\/\/([^\/]+)\/([^\/]+)\/(.+)$/),
post = await agent.getPost({ repo: did, rkey, collection });
if (action === 'delete') {
await agent.deletePost(post.uri);
return postid;
}
else if (action === 'like') return await condition ? agent.deleteLike(condition) : agent.like(post.uri, post.cid);
else if (action === 'link') return `https://bsky.app/profile/${did}/post/${rkey}`;
else if (action === 'repost' && condition) {
await agent.deleteRepost(condition);
return postid;
}
else if (action === 'repost') {
const uri = (await agent.repost(post.uri, post.cid)).uri;
// const author = await agent.app.bsky.feed.searchPosts({ url: post.uri });
// console.log(author)
return uri;
}
else if (action === 'pin') return null; //agent.app.bsky.feed.sendInteractions({interactions: [{event: ''}]})
return JSON.stringify(r);
}
catch (err) {
console.error(err);
return false;
}
}
+38
View File
@@ -0,0 +1,38 @@
import ffmpeg from 'fluent-ffmpeg';
import path from 'path';
import fs from 'fs';
const baseVideoCachePath = path.resolve('cache', 'videos');
if (!fs.existsSync(baseVideoCachePath)) fs.mkdirSync(baseVideoCachePath, { recursive: true });
// function to convert .m3u8 to .mp4
function convertM3U8ToMP4(inpurl, outputPath) {
return new Promise((resolve, reject) => {
ffmpeg(inpurl)
.outputOptions('-c copy') // copies the codec without re-encoding for faster processing
.output(outputPath)
.on('end', resolve)
.on('error', reject)
.run();
});
}
// clean up the cache
export async function clearCache(...vids) {
const arr = (vids?.length) ? vids : fs.readdirSync(baseVideoCachePath);
await Promise.all(arr.map((p) => new Promise(resolve => fs.rm(path.resolve(baseVideoCachePath, p), resolve))));
}
export default async function convertAndServe(fname, m3u8url) {
try {
const newPath = path.resolve(baseVideoCachePath, fname);
await convertM3U8ToMP4(m3u8url, newPath);
return fname;
}
catch (err) {
console.error(err);
return null;
}
}
+25
View File
@@ -0,0 +1,25 @@
import { createLogger, format, transports } from 'winston';
// configure log file paths
const logFilePath = './logs/app.log';
const errorLogFilePath = './logs/error.log';
// create a custom format for log messages
const logFormat = format.combine(
format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }),
format.printf(({ level, message, timestamp }) => `${timestamp} [${level}]: ${message}`)
);
// initialize the logger
const logger = createLogger({
level: 'info', // default logging level
format: logFormat,
transports: [
new transports.Console(), // log to console
new transports.File({ filename: logFilePath }), // log to a general log file
new transports.File({ filename: errorLogFilePath, level: 'error' }) // log errors separately
],
exitOnError: false, // prevent exit on handled exceptions
});
export default logger
+76
View File
@@ -0,0 +1,76 @@
import { app, BrowserWindow, shell } from 'electron'
import path from 'path'
import { setupIPC } from './bluesky/main.js';
import { insertHistory } from './src/db.js';
import logger from './logger.js';
import { isURL } from './src/renderer.cjs';
/** @type {BrowserWindow?} */
let mainWindow;
const quitApp = async () => {
try {
app.quit();
logger.info("clearing cache...");
await (await import('./bluesky/video.js')).clearCache();
logger.info('cache cleared!');
}
catch (err) {
logger.error(err);
}
finally {
process.exit('SIGINT');
}
}
// function to create the main application window
const createMainWindow = () => {
mainWindow = new BrowserWindow({
width: 800,
height: 600,
webPreferences: {
preload: path.join(import.meta.dirname, 'src/user_page_preload.cjs'), // enable preload for secure communication
contextIsolation: true,
enableRemoteModule: false,
nodeIntegration: true,
}
});
mainWindow.webContents.setWindowOpenHandler((details) => {
mainWindow.webContents.executeJavaScript(`localStorage.getItem('allowlinks') ? true : confirm('allow redicrection to ${details.url}?')`)
.then(r => r ? shell.openExternal(details.url) : null);
return { action: "deny" };
});
mainWindow.loadFile('HTML/index.html'); // load the main HTML file
mainWindow.title = 'User Page';
mainWindow.on('closed', () => {
mainWindow = null // dereference the window object when closed
});
mainWindow.webContents.on('did-navigate', (_, url) => {
mainWindow.webContents.executeJavaScript('document.title')
.then(title => insertHistory(url, title));
});
}
app.on('ready', () => {
setupIPC();
process.on('SIGINT', quitApp);
createMainWindow();
});
// quit the app on all windows closed, except on macOS
app.on('window-all-closed', () => {
quitApp();
if (process.platform !== 'darwin') app.quit();
});
// recreate window if app is re-activated (macOS behavior)
app.on('activate', () => {
if (mainWindow === null) createMainWindow();
});
+3828
View File
File diff suppressed because it is too large Load Diff
+27
View File
@@ -0,0 +1,27 @@
{
"name": "bluesky-client",
"version": "1.0.0",
"main": "main.js",
"scripts": {
"start": "electron .",
"rebuild": "electron-rebuild -f -w better-sqlite3"
},
"keywords": [],
"author": "",
"license": "ISC",
"description": "",
"devDependencies": {
"@electron/rebuild": "^3.7.0",
"electron": "^33.1.0"
},
"type": "module",
"dependencies": {
"@atproto/api": "^0.13.14",
"axios": "^1.7.7",
"better-sqlite3": "^11.5.0",
"cron": "^3.1.9",
"fluent-ffmpeg": "^2.1.3",
"sharp": "^0.33.5",
"winston": "^3.16.0"
}
}
+41
View File
@@ -0,0 +1,41 @@
import Database from 'better-sqlite3';
// initialize the database
const db = new Database('db/history.db');
// create the table if it doesn't exist
db.prepare(`CREATE TABLE IF NOT EXISTS history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
url TEXT,
title TEXT,
timestamp TEXT
)`).run();
export function insertHistory(url, title) {
const latest = getHistory(1),
timestamp = new Date().toISOString();
if (latest?.at(0)?.url === url) return;
const stmt = db.prepare(`INSERT INTO history (url, title, timestamp) VALUES (?, ?, ?)`);
stmt.run(url, title, timestamp);
}
export function deleteHistory(id) {
const stmt = db.prepare(`DELETE FROM history WHERE id = ?`);
stmt.run(id);
}
export function editHistory(id, newUrl, newTitle) {
const stmt = db.prepare(`UPDATE history SET url = ?, title = ? WHERE id = ?`);
stmt.run(newUrl, newTitle, id);
}
export function getHistory(limit = 50, offset = 0) {
const stmt = db.prepare(`SELECT * FROM history ORDER BY timestamp DESC LIMIT ? OFFSET ?`);
return stmt.all(limit, offset);
}
+34
View File
@@ -0,0 +1,34 @@
const isURL = (ustr) => {
try { return new URL(ustr) }
catch (err) { return null }
}
/**
* formats a string by converting URLs and mentions to HTML links
* @param {string} str - the input string to format
* @returns {string} - the formatted HTML string
*/
function formatStr(str) {
if (typeof str !== 'string') return ''; // validate input
const newStr = str.split(/\s+/).map((c) => {
// check if string is a URL
if (isURL(c)) {
return `<a class="inline external-link" href="${c}" target="_blank" rel="noopener noreferrer">${c}</a>`;
}
// check if string is a mention
if (c.startsWith('@')) {
const profileLink = c.replace('@', '');
return `<a class="inline user-link" href="index.html?profile=${encodeURIComponent(profileLink)}" rel="noopener noreferrer">${c}</a>`;
}
// return the word as-is if it's neither
return c;
});
return newStr.join(' ');
}
module.exports = { formatStr, isURL };
+165
View File
@@ -0,0 +1,165 @@
const { contextBridge, ipcRenderer } = require('electron');
const { populateProfile, Profile } = require('../JS/Profile.cjs');
const renderPosts = require('../JS/posts.cjs');
const renderReplies = require('../JS/replies.cjs');
const renderLikes = require('../JS/likes.cjs');
const { displayUploadStatus, renderCompose } = require('../JS/compose.cjs');
async function handleFileDialogue(e) {
try {
const file = e.target.files[0];
if (file) {
try {
// read file data as a buffer
const fileBuffer = await file.arrayBuffer();
// send file data to main process
const files = await ipcRenderer.invoke('upload-file', {
name: file.name,
type: file.type,
size: file.size,
data: Buffer.from(fileBuffer)
});
e.target.dataset.fname = file.name;
displayUploadStatus(JSON.parse(files));
} catch (error) {
console.error("Failed to upload file:", error);
}
}
console.log(e.target.files);
// console.log('savePath: ', savePath);
} catch (e) {
console.log('Error:', e);
}
}
window.addEventListener('DOMContentLoaded', () => {
renderCompose(ipcRenderer);
const query = new URLSearchParams(window.location.search);
const utag = query.get('profile') || '@me';
ipcRenderer.on('udata', (e, dataRaw) => {
setupMutationObserver();
const data = JSON.parse(dataRaw),
pObj = new Profile(data.profile);
if (data.err) throw data.err;
console.log(data);
if (data.postcursor) sessionStorage.setItem('postcursor', data.postcursor);
if (data.likesCursor) sessionStorage.setItem('likescursor', data.likesCursor);
document.querySelector('#loading')?.remove();
populateProfile(pObj);
if (data.posts) renderPosts(data.posts, data.likes || [], pObj?.pinnedPost, ipcRenderer);
if (data.replies) renderReplies(data.replies);
if (data.likes) renderPosts(data.likes, data.likes, null, ipcRenderer, 'bskylikeid', 'likes');
});
contextBridge.exposeInMainWorld('electronAPI', {
getnewposts: () => {
const cursor = sessionStorage.getItem('postcursor'),
likescursor = sessionStorage.getItem('likescursor');
if (cursor) ipcRenderer.invoke('getposts', utag, cursor, likescursor);
else if (document.querySelector('#eof')) return;
else {
const d = document.createElement('div');
d.id = 'eof';
d.innerHTML = '<h2>Reached end of feed!</h2>';
document.querySelector('#posts')?.appendChild(d);
}
},
getnewlikes: () => {
return alert("TODO (check TODO.txt)");
const cursor = sessionStorage.getItem('postcursor');
if (cursor) ipcRenderer.invoke('getposts', utag, cursor, likescursor);
else if (document.querySelector('#eof')) return;
else {
const d = document.createElement('div');
d.id = 'eof';
d.innerHTML = '<h2>Reached end of feed!</h2>';
document.querySelector('#posts')?.appendChild(d);
}
},
getHistory: (cursor = undefined) => ipcRenderer.invoke('gethistory', cursor),
getReplies: (cursor = undefined) => ipcRenderer.invoke('getreplies', cursor),
getVideo: (src) => ipcRenderer.invoke('getvideo', src),
});
ipcRenderer.on('posts', (e, rawData) => {
const data = JSON.parse(rawData);
console.log(data);
// reset all videos because the cache was cleared
document.querySelectorAll('.post-card video').forEach(video => {
video.src = '../assets/video-loading.mp4';
video.pause();
video.currentTime = 0;
});
if (data.err) return alert(data.err);
if (data.feed) renderPosts(data.posts, data.likes?.map(o => ({ posturi: o.post.uri, likeuri: o.post.viewer?.like })), null, ipcRenderer);
if (data.cursor) sessionStorage.setItem('postcursor', data.cursor);
else sessionStorage.removeItem('postcursor');
});
ipcRenderer.on('history', (e, data) => {
const hist = JSON.parse(data);
console.log(hist);
});
ipcRenderer.on('video', (e, oldurl, newurl) => {
const vel = document.querySelector(`video[data-src="${oldurl}"]`);
if (!vel) return console.error(`video with url ${oldurl} not found!`);
vel.parentElement.querySelector('.controls').style.display = '';
vel.src = `../cache/videos/${newurl}`;
vel.loop = false;
vel.removeEventListener('play', vidplaylistenerfunc);
vel.play();
});
ipcRenderer.on('replies', (e, data) => renderReplies(JSON.parse(data)));
ipcRenderer.invoke('getdata', utag, true);
});
const vidplaylistenerfunc = (e) => {
const video = e.target;
video.controls = false;
video.loop = true;
video.parentElement.querySelector('.play-pause').click();
ipcRenderer.invoke('getvideo', video.dataset.src);
}
const handleNewVideo = (video) => {
const playicon = document.createElement('div');
playicon.className = 'play-icon';
playicon.innerHTML = '&#9658;';
video.parentElement.appendChild(playicon);
video.addEventListener('click', vidplaylistenerfunc);
}
const setupMutationObserver = () => {
new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
mutation.addedNodes.forEach((node) => {
if (node.tagName === 'VIDEO') handleNewVideo(node);
else if (node.tagName === 'INPUT' && node.type == "file") node.addEventListener('change', handleFileDialogue);
else {
node.querySelectorAll?.('video').forEach(handleNewVideo);
document.querySelectorAll('input[type="file"]')?.forEach(el => el.addEventListener('change', handleFileDialogue));
}
});
});
}).observe(document.body, { childList: true, subtree: true });
}