Compare commits
	
		
			27 Commits
		
	
	
		
			v2.0.0+2
			...
			e36fc53df8
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| e36fc53df8 | |||
| 280a180d9e | |||
| 509d433959 | |||
| 169b5c0209 | |||
| 276c4f5dfe | |||
| 18d70382ff | |||
| 09154f1359 | |||
| fc1aef6eb7 | |||
| 5e2c6e6c3b | |||
| ff9f9b574b | |||
| 86b7fd85af | |||
| e0995b312c | |||
| e9e80bdeb5 | |||
| a8119e8366 | |||
| 43aad8c2d2 | |||
| c3bfb2069c | |||
| fbf45dab57 | |||
| 73b1e376a3 | |||
| 012a02751c | |||
| 634fedf17c | |||
| a5efec89f2 | |||
| 8bb9816cd0 | |||
| 05e8782557 | |||
| e986ff8c5f | |||
| c616214c3b | |||
| f552cdcf74 | |||
| d187ca0a88 | 
							
								
								
									
										2
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						| @@ -29,3 +29,5 @@ coverage | ||||
|  | ||||
| *.tsbuildinfo | ||||
| *.lockb | ||||
|  | ||||
| *dist | ||||
| @@ -9,6 +9,7 @@ android { | ||||
|  | ||||
| apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle" | ||||
| dependencies { | ||||
|     implementation project(':capacitor-local-notifications') | ||||
|     implementation project(':capacitor-preferences') | ||||
|  | ||||
| } | ||||
|   | ||||
| @@ -1,46 +1,48 @@ | ||||
| <?xml version="1.0" encoding="utf-8"?> | ||||
| <manifest xmlns:android="http://schemas.android.com/apk/res/android"> | ||||
|  | ||||
|   <application | ||||
|     android:allowBackup="true" | ||||
|     android:icon="@mipmap/ic_launcher" | ||||
|     android:label="@string/app_name" | ||||
|     android:roundIcon="@mipmap/ic_launcher_round" | ||||
|     android:supportsRtl="true" | ||||
|     android:theme="@style/AppTheme"> | ||||
|     <application | ||||
|         android:allowBackup="true" | ||||
|         android:label="Solian" | ||||
|         android:icon="@mipmap/ic_launcher" | ||||
|         android:roundIcon="@mipmap/ic_launcher_round" | ||||
|         android:supportsRtl="true" | ||||
|         android:theme="@style/AppTheme"> | ||||
|  | ||||
|     <activity | ||||
|       android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|smallestScreenSize|screenLayout|uiMode" | ||||
|       android:name=".MainActivity" | ||||
|       android:label="@string/title_activity_main" | ||||
|       android:theme="@style/AppTheme.NoActionBarLaunch" | ||||
|       android:launchMode="singleTask" | ||||
|       android:exported="true"> | ||||
|         <activity | ||||
|             android:name=".MainActivity" | ||||
|             android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|smallestScreenSize|screenLayout|uiMode" | ||||
|             android:exported="true" | ||||
|             android:launchMode="singleTask" | ||||
|             android:theme="@style/AppTheme.NoActionBarLaunch"> | ||||
|  | ||||
|       <intent-filter> | ||||
|         <action android:name="android.intent.action.MAIN" /> | ||||
|         <category android:name="android.intent.category.LAUNCHER" /> | ||||
|       </intent-filter> | ||||
|             <intent-filter> | ||||
|                 <action android:name="android.intent.action.MAIN" /> | ||||
|                 <category android:name="android.intent.category.LAUNCHER" /> | ||||
|             </intent-filter> | ||||
|  | ||||
|     </activity> | ||||
|         </activity> | ||||
|  | ||||
|     <provider | ||||
|       android:name="androidx.core.content.FileProvider" | ||||
|       android:authorities="${applicationId}.fileprovider" | ||||
|       android:exported="false" | ||||
|       android:grantUriPermissions="true"> | ||||
|       <meta-data | ||||
|         android:name="android.support.FILE_PROVIDER_PATHS" | ||||
|         android:resource="@xml/file_paths"></meta-data> | ||||
|     </provider> | ||||
|   </application> | ||||
|         <provider | ||||
|             android:name="androidx.core.content.FileProvider" | ||||
|             android:authorities="${applicationId}.fileprovider" | ||||
|             android:exported="false" | ||||
|             android:grantUriPermissions="true"> | ||||
|             <meta-data | ||||
|                 android:name="android.support.FILE_PROVIDER_PATHS" | ||||
|                 android:resource="@xml/file_paths" /> | ||||
|         </provider> | ||||
|     </application> | ||||
|  | ||||
|   <!-- Permissions --> | ||||
|     <!-- Permissions --> | ||||
|  | ||||
|   <uses-permission android:name="android.permission.INTERNET" /> | ||||
|   <uses-permission android:name="android.permission.READ_MEDIA_IMAGES" /> | ||||
|   <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" | ||||
|     android:maxSdkVersion="32" /> | ||||
|   <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" | ||||
|     android:maxSdkVersion="29" /> | ||||
|     <uses-permission android:name="android.permission.INTERNET" /> | ||||
|     <uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM" /> | ||||
|     <uses-permission android:name="android.permission.READ_MEDIA_IMAGES" /> | ||||
|     <uses-permission | ||||
|         android:name="android.permission.READ_EXTERNAL_STORAGE" | ||||
|         android:maxSdkVersion="32" /> | ||||
|     <uses-permission | ||||
|         android:name="android.permission.WRITE_EXTERNAL_STORAGE" | ||||
|         android:maxSdkVersion="29" /> | ||||
| </manifest> | ||||
|   | ||||
							
								
								
									
										
											BIN
										
									
								
								android/app/src/main/ic_launcher-playstore.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 31 KiB | 
| Before Width: | Height: | Size: 7.5 KiB | 
| Before Width: | Height: | Size: 3.9 KiB | 
| Before Width: | Height: | Size: 9.0 KiB | 
| Before Width: | Height: | Size: 14 KiB | 
| Before Width: | Height: | Size: 17 KiB | 
| Before Width: | Height: | Size: 7.7 KiB | 
| Before Width: | Height: | Size: 4.0 KiB | 
| Before Width: | Height: | Size: 9.6 KiB | 
| Before Width: | Height: | Size: 13 KiB | 
| Before Width: | Height: | Size: 17 KiB | 
| @@ -1,34 +0,0 @@ | ||||
| <vector xmlns:android="http://schemas.android.com/apk/res/android" | ||||
|     xmlns:aapt="http://schemas.android.com/aapt" | ||||
|     android:width="108dp" | ||||
|     android:height="108dp" | ||||
|     android:viewportHeight="108" | ||||
|     android:viewportWidth="108"> | ||||
|     <path | ||||
|         android:fillType="evenOdd" | ||||
|         android:pathData="M32,64C32,64 38.39,52.99 44.13,50.95C51.37,48.37 70.14,49.57 70.14,49.57L108.26,87.69L108,109.01L75.97,107.97L32,64Z" | ||||
|         android:strokeColor="#00000000" | ||||
|         android:strokeWidth="1"> | ||||
|         <aapt:attr name="android:fillColor"> | ||||
|             <gradient | ||||
|                 android:endX="78.5885" | ||||
|                 android:endY="90.9159" | ||||
|                 android:startX="48.7653" | ||||
|                 android:startY="61.0927" | ||||
|                 android:type="linear"> | ||||
|                 <item | ||||
|                     android:color="#44000000" | ||||
|                     android:offset="0.0" /> | ||||
|                 <item | ||||
|                     android:color="#00000000" | ||||
|                     android:offset="1.0" /> | ||||
|             </gradient> | ||||
|         </aapt:attr> | ||||
|     </path> | ||||
|     <path | ||||
|         android:fillColor="#FFFFFF" | ||||
|         android:fillType="nonZero" | ||||
|         android:pathData="M66.94,46.02L66.94,46.02C72.44,50.07 76,56.61 76,64L32,64C32,56.61 35.56,50.11 40.98,46.06L36.18,41.19C35.45,40.45 35.45,39.3 36.18,38.56C36.91,37.81 38.05,37.81 38.78,38.56L44.25,44.05C47.18,42.57 50.48,41.71 54,41.71C57.48,41.71 60.78,42.57 63.68,44.05L69.11,38.56C69.84,37.81 70.98,37.81 71.71,38.56C72.44,39.3 72.44,40.45 71.71,41.19L66.94,46.02ZM62.94,56.92C64.08,56.92 65,56.01 65,54.88C65,53.76 64.08,52.85 62.94,52.85C61.8,52.85 60.88,53.76 60.88,54.88C60.88,56.01 61.8,56.92 62.94,56.92ZM45.06,56.92C46.2,56.92 47.13,56.01 47.13,54.88C47.13,53.76 46.2,52.85 45.06,52.85C43.92,52.85 43,53.76 43,54.88C43,56.01 43.92,56.92 45.06,56.92Z" | ||||
|         android:strokeColor="#00000000" | ||||
|         android:strokeWidth="1" /> | ||||
| </vector> | ||||
| @@ -5,166 +5,6 @@ | ||||
|     android:viewportHeight="108" | ||||
|     android:viewportWidth="108"> | ||||
|     <path | ||||
|         android:fillColor="#26A69A" | ||||
|         android:fillColor="#FFFFFF" | ||||
|         android:pathData="M0,0h108v108h-108z" /> | ||||
|     <path | ||||
|         android:fillColor="#00000000" | ||||
|         android:pathData="M9,0L9,108" | ||||
|         android:strokeColor="#33FFFFFF" | ||||
|         android:strokeWidth="0.8" /> | ||||
|     <path | ||||
|         android:fillColor="#00000000" | ||||
|         android:pathData="M19,0L19,108" | ||||
|         android:strokeColor="#33FFFFFF" | ||||
|         android:strokeWidth="0.8" /> | ||||
|     <path | ||||
|         android:fillColor="#00000000" | ||||
|         android:pathData="M29,0L29,108" | ||||
|         android:strokeColor="#33FFFFFF" | ||||
|         android:strokeWidth="0.8" /> | ||||
|     <path | ||||
|         android:fillColor="#00000000" | ||||
|         android:pathData="M39,0L39,108" | ||||
|         android:strokeColor="#33FFFFFF" | ||||
|         android:strokeWidth="0.8" /> | ||||
|     <path | ||||
|         android:fillColor="#00000000" | ||||
|         android:pathData="M49,0L49,108" | ||||
|         android:strokeColor="#33FFFFFF" | ||||
|         android:strokeWidth="0.8" /> | ||||
|     <path | ||||
|         android:fillColor="#00000000" | ||||
|         android:pathData="M59,0L59,108" | ||||
|         android:strokeColor="#33FFFFFF" | ||||
|         android:strokeWidth="0.8" /> | ||||
|     <path | ||||
|         android:fillColor="#00000000" | ||||
|         android:pathData="M69,0L69,108" | ||||
|         android:strokeColor="#33FFFFFF" | ||||
|         android:strokeWidth="0.8" /> | ||||
|     <path | ||||
|         android:fillColor="#00000000" | ||||
|         android:pathData="M79,0L79,108" | ||||
|         android:strokeColor="#33FFFFFF" | ||||
|         android:strokeWidth="0.8" /> | ||||
|     <path | ||||
|         android:fillColor="#00000000" | ||||
|         android:pathData="M89,0L89,108" | ||||
|         android:strokeColor="#33FFFFFF" | ||||
|         android:strokeWidth="0.8" /> | ||||
|     <path | ||||
|         android:fillColor="#00000000" | ||||
|         android:pathData="M99,0L99,108" | ||||
|         android:strokeColor="#33FFFFFF" | ||||
|         android:strokeWidth="0.8" /> | ||||
|     <path | ||||
|         android:fillColor="#00000000" | ||||
|         android:pathData="M0,9L108,9" | ||||
|         android:strokeColor="#33FFFFFF" | ||||
|         android:strokeWidth="0.8" /> | ||||
|     <path | ||||
|         android:fillColor="#00000000" | ||||
|         android:pathData="M0,19L108,19" | ||||
|         android:strokeColor="#33FFFFFF" | ||||
|         android:strokeWidth="0.8" /> | ||||
|     <path | ||||
|         android:fillColor="#00000000" | ||||
|         android:pathData="M0,29L108,29" | ||||
|         android:strokeColor="#33FFFFFF" | ||||
|         android:strokeWidth="0.8" /> | ||||
|     <path | ||||
|         android:fillColor="#00000000" | ||||
|         android:pathData="M0,39L108,39" | ||||
|         android:strokeColor="#33FFFFFF" | ||||
|         android:strokeWidth="0.8" /> | ||||
|     <path | ||||
|         android:fillColor="#00000000" | ||||
|         android:pathData="M0,49L108,49" | ||||
|         android:strokeColor="#33FFFFFF" | ||||
|         android:strokeWidth="0.8" /> | ||||
|     <path | ||||
|         android:fillColor="#00000000" | ||||
|         android:pathData="M0,59L108,59" | ||||
|         android:strokeColor="#33FFFFFF" | ||||
|         android:strokeWidth="0.8" /> | ||||
|     <path | ||||
|         android:fillColor="#00000000" | ||||
|         android:pathData="M0,69L108,69" | ||||
|         android:strokeColor="#33FFFFFF" | ||||
|         android:strokeWidth="0.8" /> | ||||
|     <path | ||||
|         android:fillColor="#00000000" | ||||
|         android:pathData="M0,79L108,79" | ||||
|         android:strokeColor="#33FFFFFF" | ||||
|         android:strokeWidth="0.8" /> | ||||
|     <path | ||||
|         android:fillColor="#00000000" | ||||
|         android:pathData="M0,89L108,89" | ||||
|         android:strokeColor="#33FFFFFF" | ||||
|         android:strokeWidth="0.8" /> | ||||
|     <path | ||||
|         android:fillColor="#00000000" | ||||
|         android:pathData="M0,99L108,99" | ||||
|         android:strokeColor="#33FFFFFF" | ||||
|         android:strokeWidth="0.8" /> | ||||
|     <path | ||||
|         android:fillColor="#00000000" | ||||
|         android:pathData="M19,29L89,29" | ||||
|         android:strokeColor="#33FFFFFF" | ||||
|         android:strokeWidth="0.8" /> | ||||
|     <path | ||||
|         android:fillColor="#00000000" | ||||
|         android:pathData="M19,39L89,39" | ||||
|         android:strokeColor="#33FFFFFF" | ||||
|         android:strokeWidth="0.8" /> | ||||
|     <path | ||||
|         android:fillColor="#00000000" | ||||
|         android:pathData="M19,49L89,49" | ||||
|         android:strokeColor="#33FFFFFF" | ||||
|         android:strokeWidth="0.8" /> | ||||
|     <path | ||||
|         android:fillColor="#00000000" | ||||
|         android:pathData="M19,59L89,59" | ||||
|         android:strokeColor="#33FFFFFF" | ||||
|         android:strokeWidth="0.8" /> | ||||
|     <path | ||||
|         android:fillColor="#00000000" | ||||
|         android:pathData="M19,69L89,69" | ||||
|         android:strokeColor="#33FFFFFF" | ||||
|         android:strokeWidth="0.8" /> | ||||
|     <path | ||||
|         android:fillColor="#00000000" | ||||
|         android:pathData="M19,79L89,79" | ||||
|         android:strokeColor="#33FFFFFF" | ||||
|         android:strokeWidth="0.8" /> | ||||
|     <path | ||||
|         android:fillColor="#00000000" | ||||
|         android:pathData="M29,19L29,89" | ||||
|         android:strokeColor="#33FFFFFF" | ||||
|         android:strokeWidth="0.8" /> | ||||
|     <path | ||||
|         android:fillColor="#00000000" | ||||
|         android:pathData="M39,19L39,89" | ||||
|         android:strokeColor="#33FFFFFF" | ||||
|         android:strokeWidth="0.8" /> | ||||
|     <path | ||||
|         android:fillColor="#00000000" | ||||
|         android:pathData="M49,19L49,89" | ||||
|         android:strokeColor="#33FFFFFF" | ||||
|         android:strokeWidth="0.8" /> | ||||
|     <path | ||||
|         android:fillColor="#00000000" | ||||
|         android:pathData="M59,19L59,89" | ||||
|         android:strokeColor="#33FFFFFF" | ||||
|         android:strokeWidth="0.8" /> | ||||
|     <path | ||||
|         android:fillColor="#00000000" | ||||
|         android:pathData="M69,19L69,89" | ||||
|         android:strokeColor="#33FFFFFF" | ||||
|         android:strokeWidth="0.8" /> | ||||
|     <path | ||||
|         android:fillColor="#00000000" | ||||
|         android:pathData="M79,19L79,89" | ||||
|         android:strokeColor="#33FFFFFF" | ||||
|         android:strokeWidth="0.8" /> | ||||
| </vector> | ||||
|   | ||||
							
								
								
									
										
											BIN
										
									
								
								android/app/src/main/res/drawable/icon_foreground.png
									
									
									
									
									
										Executable file
									
								
							
							
						
						| After Width: | Height: | Size: 70 KiB | 
| Before Width: | Height: | Size: 3.9 KiB | 
| @@ -1,5 +1,5 @@ | ||||
| <?xml version="1.0" encoding="utf-8"?> | ||||
| <adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android"> | ||||
|     <background android:drawable="@color/ic_launcher_background"/> | ||||
|     <background android:drawable="@drawable/ic_launcher_background"/> | ||||
|     <foreground android:drawable="@mipmap/ic_launcher_foreground"/> | ||||
| </adaptive-icon> | ||||
| @@ -1,5 +1,5 @@ | ||||
| <?xml version="1.0" encoding="utf-8"?> | ||||
| <adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android"> | ||||
|     <background android:drawable="@color/ic_launcher_background"/> | ||||
|     <background android:drawable="@drawable/ic_launcher_background"/> | ||||
|     <foreground android:drawable="@mipmap/ic_launcher_foreground"/> | ||||
| </adaptive-icon> | ||||
| Before Width: | Height: | Size: 2.7 KiB | 
							
								
								
									
										
											BIN
										
									
								
								android/app/src/main/res/mipmap-hdpi/ic_launcher.webp
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 1.2 KiB | 
| Before Width: | Height: | Size: 3.4 KiB | 
							
								
								
									
										
											BIN
										
									
								
								android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 1.8 KiB | 
| Before Width: | Height: | Size: 3.4 KiB | 
							
								
								
									
										
											BIN
										
									
								
								android/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 2.5 KiB | 
| Before Width: | Height: | Size: 845 B | 
| Before Width: | Height: | Size: 1.6 KiB | 
| Before Width: | Height: | Size: 1.4 KiB | 
							
								
								
									
										
											BIN
										
									
								
								android/app/src/main/res/mipmap-mdpi/ic_launcher.webp
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 820 B | 
| Before Width: | Height: | Size: 2.1 KiB | 
							
								
								
									
										
											BIN
										
									
								
								android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 1.1 KiB | 
| Before Width: | Height: | Size: 2.1 KiB | 
							
								
								
									
										
											BIN
										
									
								
								android/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 1.6 KiB | 
| Before Width: | Height: | Size: 4.1 KiB | 
							
								
								
									
										
											BIN
										
									
								
								android/app/src/main/res/mipmap-xhdpi/ic_launcher.webp
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 1.7 KiB | 
| Before Width: | Height: | Size: 4.9 KiB | 
| After Width: | Height: | Size: 2.4 KiB | 
| Before Width: | Height: | Size: 4.5 KiB | 
							
								
								
									
										
											BIN
										
									
								
								android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 3.7 KiB | 
| Before Width: | Height: | Size: 6.9 KiB | 
							
								
								
									
										
											BIN
										
									
								
								android/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 2.7 KiB | 
| Before Width: | Height: | Size: 9.6 KiB | 
| After Width: | Height: | Size: 3.7 KiB | 
| Before Width: | Height: | Size: 6.9 KiB | 
							
								
								
									
										
											BIN
										
									
								
								android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 5.9 KiB | 
| Before Width: | Height: | Size: 10 KiB | 
							
								
								
									
										
											BIN
										
									
								
								android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 3.8 KiB | 
| Before Width: | Height: | Size: 15 KiB | 
| After Width: | Height: | Size: 5.1 KiB | 
| Before Width: | Height: | Size: 9.7 KiB | 
							
								
								
									
										
											BIN
										
									
								
								android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 8.6 KiB | 
| @@ -17,6 +17,5 @@ | ||||
|  | ||||
|  | ||||
|     <style name="AppTheme.NoActionBarLaunch" parent="Theme.SplashScreen"> | ||||
|         <item name="android:background">@drawable/splash</item> | ||||
|     </style> | ||||
| </resources> | ||||
| @@ -2,5 +2,8 @@ | ||||
| include ':capacitor-android' | ||||
| project(':capacitor-android').projectDir = new File('../node_modules/@capacitor/android/capacitor') | ||||
|  | ||||
| include ':capacitor-local-notifications' | ||||
| project(':capacitor-local-notifications').projectDir = new File('../node_modules/@capacitor/local-notifications/android') | ||||
|  | ||||
| include ':capacitor-preferences' | ||||
| project(':capacitor-preferences').projectDir = new File('../node_modules/@capacitor/preferences/android') | ||||
|   | ||||
							
								
								
									
										21
									
								
								index.html
									
									
									
									
									
								
							
							
						
						| @@ -1,13 +1,14 @@ | ||||
| <!doctype html> | ||||
| <html lang="en"> | ||||
|   <head> | ||||
|     <meta charset="UTF-8" /> | ||||
|     <link rel="icon" type="image/xml+svg" href="/favicon.png" /> | ||||
|     <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" /> | ||||
|     <title>Solian</title> | ||||
|   </head> | ||||
|   <body> | ||||
|     <div id="app"></div> | ||||
|     <script type="module" src="/src/main.ts"></script> | ||||
|   </body> | ||||
| <head> | ||||
|   <meta charset="UTF-8" /> | ||||
|   <link rel="icon" type="image/png" href="/favicon.png" /> | ||||
|   <link rel="apple-touch-icon" type="image/png" href="/apple-touch-icon.png" sizes="1024x1024"> | ||||
|   <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" /> | ||||
|   <title>Solian</title> | ||||
| </head> | ||||
| <body> | ||||
| <div id="app"></div> | ||||
| <script type="module" src="/src/main.ts"></script> | ||||
| </body> | ||||
| </html> | ||||
|   | ||||
| @@ -27,6 +27,7 @@ | ||||
| 		504EC3111FED79650016851F /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; }; | ||||
| 		504EC3131FED79650016851F /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; }; | ||||
| 		50B271D01FEDC1A000F3C39B /* public */ = {isa = PBXFileReference; lastKnownFileType = folder; path = public; sourceTree = "<group>"; }; | ||||
| 		730477372BB91A4200A78988 /* App.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = App.entitlements; sourceTree = "<group>"; }; | ||||
| 		AF277DCFFFF123FFC6DF26C7 /* Pods_App.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_App.framework; sourceTree = BUILT_PRODUCTS_DIR; }; | ||||
| 		AF51FD2D460BCFE21FA515B2 /* Pods-App.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App.release.xcconfig"; path = "Pods/Target Support Files/Pods-App/Pods-App.release.xcconfig"; sourceTree = "<group>"; }; | ||||
| 		FC68EB0AF532CFC21C3344DD /* Pods-App.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App.debug.xcconfig"; path = "Pods/Target Support Files/Pods-App/Pods-App.debug.xcconfig"; sourceTree = "<group>"; }; | ||||
| @@ -73,6 +74,7 @@ | ||||
| 		504EC3061FED79650016851F /* App */ = { | ||||
| 			isa = PBXGroup; | ||||
| 			children = ( | ||||
| 				730477372BB91A4200A78988 /* App.entitlements */, | ||||
| 				50379B222058CBB4000EE86E /* capacitor.config.json */, | ||||
| 				504EC3071FED79650016851F /* AppDelegate.swift */, | ||||
| 				504EC30B1FED79650016851F /* Main.storyboard */, | ||||
| @@ -345,6 +347,7 @@ | ||||
| 			baseConfigurationReference = FC68EB0AF532CFC21C3344DD /* Pods-App.debug.xcconfig */; | ||||
| 			buildSettings = { | ||||
| 				ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; | ||||
| 				CODE_SIGN_ENTITLEMENTS = App/App.entitlements; | ||||
| 				CODE_SIGN_STYLE = Automatic; | ||||
| 				CURRENT_PROJECT_VERSION = 1; | ||||
| 				DEVELOPMENT_TEAM = W7HPZ53V6B; | ||||
| @@ -367,6 +370,7 @@ | ||||
| 			baseConfigurationReference = AF51FD2D460BCFE21FA515B2 /* Pods-App.release.xcconfig */; | ||||
| 			buildSettings = { | ||||
| 				ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; | ||||
| 				CODE_SIGN_ENTITLEMENTS = App/App.entitlements; | ||||
| 				CODE_SIGN_STYLE = Automatic; | ||||
| 				CURRENT_PROJECT_VERSION = 1; | ||||
| 				DEVELOPMENT_TEAM = W7HPZ53V6B; | ||||
|   | ||||
							
								
								
									
										12
									
								
								ios/App/App/App.entitlements
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,12 @@ | ||||
| <?xml version="1.0" encoding="UTF-8"?> | ||||
| <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> | ||||
| <plist version="1.0"> | ||||
| <dict> | ||||
| 	<key>aps-environment</key> | ||||
| 	<string>development</string> | ||||
| 	<key>com.apple.developer.associated-domains</key> | ||||
| 	<array> | ||||
| 		<string>webcredentials:solsynth.dev</string> | ||||
| 	</array> | ||||
| </dict> | ||||
| </plist> | ||||
| @@ -20,8 +20,21 @@ | ||||
| 	<string>$(MARKETING_VERSION)</string> | ||||
| 	<key>CFBundleVersion</key> | ||||
| 	<string>$(CURRENT_PROJECT_VERSION)</string> | ||||
| 	<key>LSApplicationCategoryType</key> | ||||
| 	<string></string> | ||||
| 	<key>LSRequiresIPhoneOS</key> | ||||
| 	<true/> | ||||
| 	<key>NSCameraUsageDescription</key> | ||||
| 	<string>Allow Solian use your camera so that you can take photo for your post.</string> | ||||
| 	<key>NSPhotoLibraryAddUsageDescription</key> | ||||
| 	<string>Allow Solian full access your photo library so that you can share photos more easily.</string> | ||||
| 	<key>NSPhotoLibraryUsageDescription</key> | ||||
| 	<string>Allow Solian access your photo library so that you can share photos.</string> | ||||
| 	<key>UIBackgroundModes</key> | ||||
| 	<array> | ||||
| 		<string>fetch</string> | ||||
| 		<string>remote-notification</string> | ||||
| 	</array> | ||||
| 	<key>UILaunchStoryboardName</key> | ||||
| 	<string>LaunchScreen</string> | ||||
| 	<key>UIMainStoryboardFile</key> | ||||
| @@ -43,15 +56,7 @@ | ||||
| 		<string>UIInterfaceOrientationLandscapeLeft</string> | ||||
| 		<string>UIInterfaceOrientationLandscapeRight</string> | ||||
| 	</array> | ||||
| 	<key>LSApplicationCategoryType</key> | ||||
| 	<string></string> | ||||
| 	<key>UIViewControllerBasedStatusBarAppearance</key> | ||||
| 	<false/> | ||||
| 	<key>NSPhotoLibraryAddUsageDescription</key> | ||||
| 	<string>Allow Solian full access your photo library so that you can share photos more easily.</string> | ||||
| 	<key>NSPhotoLibraryUsageDescription</key> | ||||
| 	<string>Allow Solian access your photo library so that you can share photos.</string> | ||||
| 	<key>NSCameraUsageDescription</key> | ||||
| 	<string>Allow Solian use your camera so that you can take photo for your post.</string> | ||||
| </dict> | ||||
| </plist> | ||||
|   | ||||
| @@ -11,6 +11,7 @@ install! 'cocoapods', :disable_input_output_paths => true | ||||
| def capacitor_pods | ||||
|   pod 'Capacitor', :path => '../../node_modules/@capacitor/ios' | ||||
|   pod 'CapacitorCordova', :path => '../../node_modules/@capacitor/ios' | ||||
|   pod 'CapacitorLocalNotifications', :path => '../../node_modules/@capacitor/local-notifications' | ||||
|   pod 'CapacitorPreferences', :path => '../../node_modules/@capacitor/preferences' | ||||
| end | ||||
|  | ||||
|   | ||||
| @@ -2,12 +2,15 @@ PODS: | ||||
|   - Capacitor (5.7.4): | ||||
|     - CapacitorCordova | ||||
|   - CapacitorCordova (5.7.4) | ||||
|   - CapacitorLocalNotifications (5.0.7): | ||||
|     - Capacitor | ||||
|   - CapacitorPreferences (5.0.7): | ||||
|     - Capacitor | ||||
|  | ||||
| DEPENDENCIES: | ||||
|   - "Capacitor (from `../../node_modules/@capacitor/ios`)" | ||||
|   - "CapacitorCordova (from `../../node_modules/@capacitor/ios`)" | ||||
|   - "CapacitorLocalNotifications (from `../../node_modules/@capacitor/local-notifications`)" | ||||
|   - "CapacitorPreferences (from `../../node_modules/@capacitor/preferences`)" | ||||
|  | ||||
| EXTERNAL SOURCES: | ||||
| @@ -15,14 +18,17 @@ EXTERNAL SOURCES: | ||||
|     :path: "../../node_modules/@capacitor/ios" | ||||
|   CapacitorCordova: | ||||
|     :path: "../../node_modules/@capacitor/ios" | ||||
|   CapacitorLocalNotifications: | ||||
|     :path: "../../node_modules/@capacitor/local-notifications" | ||||
|   CapacitorPreferences: | ||||
|     :path: "../../node_modules/@capacitor/preferences" | ||||
|  | ||||
| SPEC CHECKSUMS: | ||||
|   Capacitor: 4fe9adf012caceb4c71ffea2f1f4d005cdcbeea7 | ||||
|   CapacitorCordova: a6e87fccc0307dee7aec1560ec9398485f2b0ce7 | ||||
|   CapacitorLocalNotifications: c58afadd159f6bc540ef9b3cbdbc82510a2bf112 | ||||
|   CapacitorPreferences: 77ac427e98db83bace772455f8ba447430382c4c | ||||
|  | ||||
| PODFILE CHECKSUM: 769e120bf4dfe4ef1095b83775e36bafeeeb3cdd | ||||
| PODFILE CHECKSUM: 19c3106e1cb0c8c0ae26243bfb70b974f8cfaaf5 | ||||
|  | ||||
| COCOAPODS: 1.15.1 | ||||
|   | ||||
| @@ -16,11 +16,13 @@ | ||||
|     "@capacitor/android": "^5.7.4", | ||||
|     "@capacitor/core": "^5.7.4", | ||||
|     "@capacitor/ios": "^5.7.4", | ||||
|     "@capacitor/local-notifications": "^5.0.7", | ||||
|     "@capacitor/preferences": "^5.0.7", | ||||
|     "@fontsource/roboto": "^5.0.12", | ||||
|     "@mdi/font": "^7.4.47", | ||||
|     "dompurify": "^3.0.11", | ||||
|     "marked": "^12.0.1", | ||||
|     "nprogress": "^0.2.0", | ||||
|     "pinia": "^2.1.7", | ||||
|     "universal-cookie": "^7.1.0", | ||||
|     "vue": "^3.4.21", | ||||
| @@ -35,6 +37,7 @@ | ||||
|     "@tsconfig/node20": "^20.1.2", | ||||
|     "@types/dompurify": "^3.0.5", | ||||
|     "@types/node": "^20.11.28", | ||||
|     "@types/nprogress": "^0.2.3", | ||||
|     "@unocss/reset": "^0.58.7", | ||||
|     "@vitejs/plugin-vue": "^5.0.4", | ||||
|     "@vitejs/plugin-vue-jsx": "^3.1.0", | ||||
|   | ||||
							
								
								
									
										
											BIN
										
									
								
								public/apple-touch-icon.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 69 KiB | 
							
								
								
									
										
											BIN
										
									
								
								public/icon.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 85 KiB | 
| @@ -1,46 +0,0 @@ | ||||
| { | ||||
|   "icons": [ | ||||
|     { | ||||
|       "src": "../icons/icon-48.webp", | ||||
|       "type": "image/png", | ||||
|       "sizes": "48x48", | ||||
|       "purpose": "any maskable" | ||||
|     }, | ||||
|     { | ||||
|       "src": "../icons/icon-72.webp", | ||||
|       "type": "image/png", | ||||
|       "sizes": "72x72", | ||||
|       "purpose": "any maskable" | ||||
|     }, | ||||
|     { | ||||
|       "src": "../icons/icon-96.webp", | ||||
|       "type": "image/png", | ||||
|       "sizes": "96x96", | ||||
|       "purpose": "any maskable" | ||||
|     }, | ||||
|     { | ||||
|       "src": "../icons/icon-128.webp", | ||||
|       "type": "image/png", | ||||
|       "sizes": "128x128", | ||||
|       "purpose": "any maskable" | ||||
|     }, | ||||
|     { | ||||
|       "src": "../icons/icon-192.webp", | ||||
|       "type": "image/png", | ||||
|       "sizes": "192x192", | ||||
|       "purpose": "any maskable" | ||||
|     }, | ||||
|     { | ||||
|       "src": "../icons/icon-256.webp", | ||||
|       "type": "image/png", | ||||
|       "sizes": "256x256", | ||||
|       "purpose": "any maskable" | ||||
|     }, | ||||
|     { | ||||
|       "src": "../icons/icon-512.webp", | ||||
|       "type": "image/png", | ||||
|       "sizes": "512x512", | ||||
|       "purpose": "any maskable" | ||||
|     } | ||||
|   ] | ||||
| } | ||||
							
								
								
									
										
											BIN
										
									
								
								public/maskable-icon.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 75 KiB | 
							
								
								
									
										2
									
								
								public/robots.txt
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,2 @@ | ||||
| User-agent: * | ||||
| Allow: / | ||||
| @@ -2,7 +2,6 @@ html, | ||||
| body, | ||||
| #app, | ||||
| .v-application { | ||||
|   overflow: auto !important; | ||||
|   font-family: "Roboto Sans", ui-sans-serif, system-ui, sans-serif; | ||||
| } | ||||
|  | ||||
| @@ -13,3 +12,11 @@ body, | ||||
| .no-scrollbar::-webkit-scrollbar { | ||||
|   width: 0; | ||||
| } | ||||
|  | ||||
| html, body { | ||||
|   scroll-behavior: smooth; | ||||
| } | ||||
|  | ||||
| #nprogress .bar { | ||||
|   background: #ffffff !important; | ||||
| } | ||||
| @@ -1,8 +1,8 @@ | ||||
| <template> | ||||
|   <v-menu eager :close-on-content-click="false"> | ||||
|     <template #activator="{ props }"> | ||||
|       <v-btn v-bind="props" stacked rounded="circle" size="small" variant="text" :loading="loading"> | ||||
|         <v-badge v-if="pagination.total > 0" color="error" :content="pagination.total"> | ||||
|       <v-btn v-bind="props" icon size="small" variant="text" :loading="loading"> | ||||
|         <v-badge v-if="notify.total > 0" color="error" :content="notify.total"> | ||||
|           <v-icon icon="mdi-bell" /> | ||||
|         </v-badge> | ||||
|  | ||||
| @@ -10,20 +10,19 @@ | ||||
|       </v-btn> | ||||
|     </template> | ||||
|  | ||||
|     <v-list v-if="notifications.length <= 0" class="w-[380px]" density="compact"> | ||||
|     <v-list v-if="notify.notifications.length <= 0" class="w-[380px]" density="compact"> | ||||
|       <v-list-item> | ||||
|         <v-alert class="text-sm" variant="tonal" type="info">You are done! There is no unread notifications for | ||||
|           you.</v-alert> | ||||
|         <v-alert class="text-sm" variant="tonal" type="info">You are done! There is no unread notifications for you.</v-alert> | ||||
|       </v-list-item> | ||||
|     </v-list> | ||||
|  | ||||
|     <v-list v-else class="w-[380px]" density="compact" lines="three"> | ||||
|       <v-list-item v-for="item in notifications"> | ||||
|       <v-list-item v-for="(item, idx) in notify.notifications"> | ||||
|         <template #title>{{ item.subject }}</template> | ||||
|         <template #subtitle>{{ item.content }}</template> | ||||
|  | ||||
|         <template #append> | ||||
|           <v-btn icon="mdi-check" size="x-small" variant="text" :disabled="loading" @click="markAsRead(item)" /> | ||||
|           <v-btn icon="mdi-check" size="x-small" variant="text" :disabled="loading" @click="markAsRead(item, idx)" /> | ||||
|         </template> | ||||
|  | ||||
|         <div class="flex text-xs gap-1"> | ||||
| @@ -40,50 +39,32 @@ | ||||
| <script setup lang="ts"> | ||||
| import { request } from "@/scripts/request" | ||||
| import { getAtk } from "@/stores/userinfo" | ||||
| import { reactive, ref } from "vue" | ||||
| import { computed, onMounted, onUnmounted, ref } from "vue"; | ||||
| import { useNotifications } from "@/stores/notifications"; | ||||
|  | ||||
| const loading = ref(false) | ||||
| const notify = useNotifications() | ||||
|  | ||||
| const error = ref<string | null>(null) | ||||
| const submitting = ref(false) | ||||
| const loading = computed(() => notify.loading || submitting.value) | ||||
|  | ||||
| const notifications = ref<any[]>([]) | ||||
| const pagination = reactive({ page: 1, pageSize: 25, total: 0 }) | ||||
|  | ||||
| async function readNotifications() { | ||||
|   loading.value = true | ||||
|   const res = await request( | ||||
|     "identity", | ||||
|     "/api/notifications?" + | ||||
|     new URLSearchParams({ | ||||
|       take: pagination.pageSize.toString(), | ||||
|       offset: ((pagination.page - 1) * pagination.pageSize).toString() | ||||
|     }), | ||||
|     { | ||||
|       headers: { Authorization: `Bearer ${await getAtk()}` } | ||||
|     } | ||||
|   ) | ||||
|   if (res.status === 200) { | ||||
|     const data = await res.json() | ||||
|     notifications.value = data["data"] | ||||
|     pagination.total = data["count"] | ||||
|   } | ||||
|   loading.value = false | ||||
| } | ||||
|  | ||||
| readNotifications() | ||||
|  | ||||
| async function markAsRead(item: any) { | ||||
|   loading.value = true | ||||
| async function markAsRead(item: any, idx: number) { | ||||
|   submitting.value = true | ||||
|   const res = await request("identity", `/api/notifications/${item.id}/read`, { | ||||
|     method: "PUT", | ||||
|     headers: { Authorization: `Bearer ${await getAtk()}` } | ||||
|     headers: { Authorization: `Bearer ${getAtk()}` }, | ||||
|   }) | ||||
|   if (res.status !== 200) { | ||||
|     error.value = await res.text() | ||||
|   } else { | ||||
|     await readNotifications() | ||||
|     notify.remove(idx) | ||||
|     error.value = null | ||||
|   } | ||||
|   loading.value = false | ||||
|   submitting.value = false | ||||
| } | ||||
|  | ||||
| notify.list() | ||||
|  | ||||
| onMounted(() => notify.connect()) | ||||
| onUnmounted(() => notify.disconnect()) | ||||
| </script> | ||||
|   | ||||
							
								
								
									
										212
									
								
								src/components/chat/ChatEditor.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,212 @@ | ||||
| <template> | ||||
|   <v-form class="flex-grow-1" ref="chat" @submit.prevent="sendMessage"> | ||||
|     <v-expand-transition> | ||||
|       <v-alert | ||||
|         v-show="channels.related?.messages?.reply_to" | ||||
|         class="mb-3 text-sm" | ||||
|         variant="tonal" | ||||
|         density="compact" | ||||
|         type="info" | ||||
|       > | ||||
|         You are about replying a message #{{ channels.related?.messages?.reply_to?.id }} | ||||
|  | ||||
|         <template #prepend> | ||||
|           <div class="h-[30px] flex items-center justify-center"> | ||||
|             <v-icon icon="mdi-reply" size="small" /> | ||||
|           </div> | ||||
|         </template> | ||||
|  | ||||
|         <template #append> | ||||
|           <v-btn | ||||
|             icon="mdi-close" | ||||
|             size="x-small" | ||||
|             color="info" | ||||
|             variant="text" | ||||
|             @click="channels.related.messages.reply_to = null" | ||||
|           /> | ||||
|         </template> | ||||
|       </v-alert> | ||||
|     </v-expand-transition> | ||||
|  | ||||
|     <v-expand-transition> | ||||
|       <v-alert | ||||
|         v-show="channels.related?.messages?.edit_to" | ||||
|         class="mb-3 text-sm" | ||||
|         variant="tonal" | ||||
|         density="compact" | ||||
|         type="info" | ||||
|       > | ||||
|         You are about editing a message #{{ channels.related?.messages?.edit_to?.id }} | ||||
|  | ||||
|         <template #prepend> | ||||
|           <div class="h-[30px] flex items-center justify-center"> | ||||
|             <v-icon icon="mdi-pencil" size="small" /> | ||||
|           </div> | ||||
|         </template> | ||||
|  | ||||
|         <template #append> | ||||
|           <v-btn | ||||
|             icon="mdi-close" | ||||
|             size="x-small" | ||||
|             color="info" | ||||
|             variant="text" | ||||
|             @click="channels.related.messages.edit_to = null" | ||||
|           /> | ||||
|         </template> | ||||
|       </v-alert> | ||||
|     </v-expand-transition> | ||||
|  | ||||
|     <v-textarea | ||||
|       auto-grow | ||||
|       hide-details | ||||
|       class="w-full" | ||||
|       variant="outlined" | ||||
|       density="compact" | ||||
|       placeholder="Enter some messages..." | ||||
|       :rows="1" | ||||
|       :max-rows="6" | ||||
|       :loading="loading" | ||||
|       v-model="data.content" | ||||
|       @keyup.ctrl.enter="sendMessage" | ||||
|       @keyup.meta.enter="sendMessage" | ||||
|       @paste="pasteMedia" | ||||
|     > | ||||
|       <template #append> | ||||
|         <v-btn | ||||
|           icon | ||||
|           type="button" | ||||
|           color="teal" | ||||
|           size="small" | ||||
|           variant="text" | ||||
|           :disabled="loading" | ||||
|           @click="dialogs.attachments = true" | ||||
|         > | ||||
|           <v-badge v-if="data.attachments.length > 0" :content="data.attachments.length"> | ||||
|             <v-icon icon="mdi-paperclip" /> | ||||
|           </v-badge> | ||||
|  | ||||
|           <v-icon v-else icon="mdi-paperclip" /> | ||||
|         </v-btn> | ||||
|  | ||||
|         <v-btn type="submit" icon="mdi-send" size="small" variant="text" :disabled="loading" /> | ||||
|       </template> | ||||
|     </v-textarea> | ||||
|  | ||||
|     <Attachments | ||||
|       ref="attachments" | ||||
|       v-model:show="dialogs.attachments" | ||||
|       v-model:uploading="uploading" | ||||
|       v-model:value="data.attachments" | ||||
|     /> | ||||
|  | ||||
|     <v-snackbar v-model="uploading" :timeout="-1"> | ||||
|       Uploading your media, please stand by... | ||||
|       <v-progress-linear class="snackbar-progress" indeterminate /> | ||||
|     </v-snackbar> | ||||
|  | ||||
|     <!-- @vue-ignore --> | ||||
|     <v-snackbar v-model="error" :timeout="5000">Something went wrong... {{ error }}</v-snackbar> | ||||
|   </v-form> | ||||
| </template> | ||||
|  | ||||
| <script setup lang="ts"> | ||||
| import { reactive, ref, watch } from "vue" | ||||
| import { request } from "@/scripts/request" | ||||
| import { getAtk } from "@/stores/userinfo" | ||||
| import { useChannels } from "@/stores/channels" | ||||
| import Attachments from "@/components/chat/parts/ChatAttachments.vue" | ||||
| import Media from "@/components/publish/parts/PublishMedia.vue" | ||||
|  | ||||
| const emits = defineEmits(["sent"]) | ||||
|  | ||||
| const chat = ref<HTMLFormElement>() | ||||
| const channels = useChannels() | ||||
|  | ||||
| const error = ref<string | null>(null) | ||||
| const uploading = ref(false) | ||||
| const loading = ref(false) | ||||
|  | ||||
| const attachments = ref<any>() | ||||
|  | ||||
| const dialogs = reactive({ | ||||
|   attachments: false | ||||
| }) | ||||
|  | ||||
| const data = ref<any>({ | ||||
|   content: "", | ||||
|   reply_id: null, | ||||
|   attachments: [] | ||||
| }) | ||||
|  | ||||
| async function sendMessage() { | ||||
|   if (!data.value.content) return | ||||
|  | ||||
|   const url = channels.related.messages.edit_to | ||||
|     ? `/api/channels/${channels.current.alias}/messages/${channels.related.messages.edit_to?.id}` | ||||
|     : `/api/channels/${channels.current.alias}/messages` | ||||
|   const method = channels.related.messages.edit_to ? "PUT" : "POST" | ||||
|  | ||||
|   const payload = data.value | ||||
|   payload.reply_to = payload.reply_id | ||||
|  | ||||
|   loading.value = true | ||||
|   const res = await request("messaging", url, { | ||||
|     method: method, | ||||
|     headers: { Authorization: `Bearer ${await getAtk()}`, "Content-Type": "application/json" }, | ||||
|     body: JSON.stringify(payload) | ||||
|   }) | ||||
|   if (res.status !== 200) { | ||||
|     error.value = await res.text() | ||||
|   } else { | ||||
|     emits("sent") | ||||
|     resetEditor() | ||||
|     error.value = null | ||||
|   } | ||||
|   loading.value = false | ||||
| } | ||||
|  | ||||
| watch( | ||||
|   () => channels.related.messages.reply_to, | ||||
|   (val) => { | ||||
|     if (val) { | ||||
|       data.value.reply_id = val.id | ||||
|     } | ||||
|   } | ||||
| ) | ||||
|  | ||||
| watch( | ||||
|   () => channels.related.messages.edit_to, | ||||
|   (val) => { | ||||
|     if (val) { | ||||
|       data.value = val | ||||
|     } | ||||
|   } | ||||
| ) | ||||
|  | ||||
| function resetEditor() { | ||||
|   chat.value?.reset() | ||||
|   channels.related.messages.reply_to = null | ||||
|   channels.related.messages.edit_to = null | ||||
|   channels.related.messages.delete_to = null | ||||
|   data.value = { | ||||
|     content: "", | ||||
|     attachments: [] | ||||
|   } | ||||
| } | ||||
|  | ||||
| function pasteMedia(evt: ClipboardEvent) { | ||||
|   const files = evt.clipboardData?.files | ||||
|   if (files) { | ||||
|     Array.from(files).forEach((item) => { | ||||
|       attachments.value.upload(item) | ||||
|     }) | ||||
|   } | ||||
| } | ||||
| </script> | ||||
|  | ||||
| <style> | ||||
| .snackbar-progress { | ||||
|   margin: 12px -16px -14px; | ||||
|   width: calc(100% + 64px); | ||||
| } | ||||
| </style> | ||||
							
								
								
									
										20
									
								
								src/components/chat/ChatList.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,20 @@ | ||||
| <template> | ||||
|   <v-infinite-scroll | ||||
|     class="mt-[-16px] overflow-hidden" | ||||
|     :onLoad="props.loader" | ||||
|   > | ||||
|     <template v-for="item in props.messages" :key="item.id"> | ||||
|       <chat-message class="px-6 py-2" :id="`m${item.id}`" :item="item" /> | ||||
|     </template> | ||||
|  | ||||
|     <template #empty> | ||||
|       <div class="flex-grow-1"></div> | ||||
|     </template> | ||||
|   </v-infinite-scroll> | ||||
| </template> | ||||
|  | ||||
| <script setup lang="ts"> | ||||
| import ChatMessage from "@/components/chat/ChatMessage.vue" | ||||
|  | ||||
| const props = defineProps<{ loader: (opts: any) => Promise<any>, messages: any[] }>() | ||||
| </script> | ||||
							
								
								
									
										94
									
								
								src/components/chat/ChatMessage.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,94 @@ | ||||
| <template> | ||||
|   <div class="relative transition-colors transition-300 message-item"> | ||||
|     <a v-if="props.item?.reply_to" :href="`#m${props.item?.reply_to.id}`"> | ||||
|       <div class="pl-2 mb-0.5 text-sm opacity-80 flex items-center"> | ||||
|         <v-icon icon="mdi-reply" class="me-2" /> | ||||
|         <span class="me-1 text-xs overflow-hidden ws-nowarp text-ellipsis">{{ props.item?.reply_to?.content }}</span> | ||||
|         <span class="text-xs overflow-hidden ws-nowarp text-ellipsis"> | ||||
|           from {{ props.item?.reply_to?.sender.account.name }} | ||||
|         </span> | ||||
|       </div> | ||||
|     </a> | ||||
|  | ||||
|     <div class="flex gap-2"> | ||||
|       <div> | ||||
|         <v-avatar | ||||
|           color="grey-lighten-2" | ||||
|           icon="mdi-account-circle" | ||||
|           class="rounded-card" | ||||
|           :image="props.item?.sender.account.avatar" | ||||
|         /> | ||||
|       </div> | ||||
|  | ||||
|       <div class="flex-grow-1"> | ||||
|         <div class="font-bold text-sm">{{ props.item?.sender.account.nick }}</div> | ||||
|         <div>{{ props.item?.content }}</div> | ||||
|  | ||||
|         <message-attachment | ||||
|           v-if="props.item?.attachments && props.item?.attachments.length > 0" | ||||
|           class="mt-1" | ||||
|           :attachments="props.item?.attachments" | ||||
|         /> | ||||
|       </div> | ||||
|  | ||||
|       <div class="transition-opacity transition-300 message-action"> | ||||
|         <v-card> | ||||
|           <div class="flex px-2 py-0.5"> | ||||
|             <v-btn icon="mdi-reply" size="x-small" variant="text" @click="replyMessage" /> | ||||
|             <v-btn v-if="isOwned" icon="mdi-pencil" size="x-small" variant="text" color="warning" @click="editMessage" /> | ||||
|             <v-btn v-if="isOwned" icon="mdi-delete" size="x-small" variant="text" color="error" @click="deleteMessage" /> | ||||
|           </div> | ||||
|         </v-card> | ||||
|       </div> | ||||
|     </div> | ||||
|   </div> | ||||
| </template> | ||||
|  | ||||
| <script setup lang="ts"> | ||||
| import { useChannels } from "@/stores/channels" | ||||
| import { useUserinfo } from "@/stores/userinfo" | ||||
| import { computed } from "vue" | ||||
| import MessageAttachment from "@/components/chat/renderer/MessageAttachment.vue" | ||||
|  | ||||
| const id = useUserinfo() | ||||
| const channels = useChannels() | ||||
|  | ||||
| const props = defineProps<{ item: any }>() | ||||
|  | ||||
| const isOwned = computed(() => props.item?.sender?.id === id.userinfo.idSet.messaging) | ||||
|  | ||||
| function replyMessage() { | ||||
|   channels.related.messages.reply_to = JSON.parse(JSON.stringify(props.item)) | ||||
| } | ||||
|  | ||||
| function editMessage() { | ||||
|   channels.related.messages.edit_to = JSON.parse(JSON.stringify(props.item)) | ||||
| } | ||||
|  | ||||
| function deleteMessage() { | ||||
|   channels.related.messages.delete_to = JSON.parse(JSON.stringify(props.item)) | ||||
|   channels.related.messages.delete_to.channel = channels.current | ||||
|   channels.show.messages.delete = true | ||||
| } | ||||
| </script> | ||||
|  | ||||
| <style scoped> | ||||
| .rounded-card { | ||||
|   border-radius: 8px; | ||||
| } | ||||
|  | ||||
| .message-action { | ||||
|   position: absolute; | ||||
|   right: 8px; | ||||
|   top: -25%; | ||||
|   opacity: 0; | ||||
| } | ||||
|  | ||||
| .message-item:hover { | ||||
|   background-color: rgba(0, 0, 0, .15); | ||||
| } | ||||
|  | ||||
| .message-item:hover .message-action { | ||||
|   opacity: 100%; | ||||
| } | ||||
| </style> | ||||
							
								
								
									
										52
									
								
								src/components/chat/MessageDeletion.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,52 @@ | ||||
| <template> | ||||
|   <v-card title="Delete a message" class="min-h-[540px]" :loading="loading"> | ||||
|     <template #text> | ||||
|       You are deleting a message | ||||
|       <b>#{{ channels.related?.messages?.delete_to?.id }}</b> <br /> | ||||
|       This message will gone and never appear again. But the replies won't affected. Are you confirm? | ||||
|     </template> | ||||
|     <template #actions> | ||||
|       <div class="w-full flex justify-end"> | ||||
|         <v-btn color="grey-darken-3" @click="channels.show.messages.delete = false">Not really</v-btn> | ||||
|         <v-btn color="error" :disabled="loading" @click="deleteMessage">Yes</v-btn> | ||||
|       </div> | ||||
|     </template> | ||||
|   </v-card> | ||||
|  | ||||
|   <v-snackbar v-model="success" :timeout="3000">The realm has been deleted.</v-snackbar> | ||||
|  | ||||
|   <!-- @vue-ignore --> | ||||
|   <v-snackbar v-model="error" :timeout="5000">Something went wrong... {{ error }}</v-snackbar> | ||||
| </template> | ||||
|  | ||||
| <script setup lang="ts"> | ||||
| import { request } from "@/scripts/request" | ||||
| import { getAtk } from "@/stores/userinfo" | ||||
| import { useChannels } from "@/stores/channels" | ||||
| import { ref } from "vue" | ||||
|  | ||||
| const channels = useChannels() | ||||
|  | ||||
| const error = ref<string | null>(null) | ||||
| const success = ref(false) | ||||
| const loading = ref(false) | ||||
|  | ||||
| async function deleteMessage() { | ||||
|   const target = channels.related.messages.delete_to | ||||
|   const url = `/api/channels/${target.channel.alias}/messages/${target.id}` | ||||
|  | ||||
|   loading.value = true | ||||
|   const res = await request("messaging", url, { | ||||
|     method: "DELETE", | ||||
|     headers: { Authorization: `Bearer ${await getAtk()}` } | ||||
|   }) | ||||
|   if (res.status !== 200) { | ||||
|     error.value = await res.text() | ||||
|   } else { | ||||
|     success.value = true | ||||
|     channels.show.messages.delete = false | ||||
|     channels.related.messages.delete_to = null | ||||
|   } | ||||
|   loading.value = false | ||||
| } | ||||
| </script> | ||||
							
								
								
									
										42
									
								
								src/components/chat/channels/ChannelAction.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,42 @@ | ||||
| <template> | ||||
|   <v-menu> | ||||
|     <template #activator="{ props }"> | ||||
|       <v-btn v-bind="props" icon="mdi-cog" variant="text" /> | ||||
|     </template> | ||||
|  | ||||
|     <v-list density="compact" lines="one"> | ||||
|       <v-list-item disabled append-icon="mdi-flag" title="Report" /> | ||||
|       <v-list-item v-if="isOwned" append-icon="mdi-pencil" title="Edit" @click="editChannel" /> | ||||
|       <v-list-item v-if="isOwned" append-icon="mdi-account-supervisor-circle" title="Members" @click="manageChannel" /> | ||||
|       <v-list-item v-if="isOwned" append-icon="mdi-delete" title="Delete" @click="deleteChannel" /> | ||||
|     </v-list> | ||||
|   </v-menu> | ||||
| </template> | ||||
|  | ||||
| <script setup lang="ts"> | ||||
| import { useUserinfo } from "@/stores/userinfo" | ||||
| import { useChannels } from "@/stores/channels" | ||||
| import { computed } from "vue" | ||||
|  | ||||
| const id = useUserinfo() | ||||
| const channels = useChannels() | ||||
|  | ||||
| const props = defineProps<{ item: any }>() | ||||
|  | ||||
| const isOwned = computed(() => props.item?.account_id === id.userinfo.idSet?.messaging) | ||||
|  | ||||
| function editChannel() { | ||||
|   channels.related.edit_to = props.item | ||||
|   channels.show.editor = true | ||||
| } | ||||
|  | ||||
| function manageChannel() { | ||||
|   channels.related.manage_to = props.item | ||||
|   channels.show.members = true | ||||
| } | ||||
|  | ||||
| function deleteChannel() { | ||||
|   channels.related.delete_to = props.item | ||||
|   channels.show.delete = true | ||||
| } | ||||
| </script> | ||||
							
								
								
									
										61
									
								
								src/components/chat/channels/ChannelDeletion.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,61 @@ | ||||
| <template> | ||||
|   <v-card title="Delete a realm" class="min-h-[540px]" :loading="loading"> | ||||
|     <template #text> | ||||
|       You are deleting a channel | ||||
|       <b>{{ channels.related.delete_to?.name }}</b> <br /> | ||||
|       All messaging belonging to this channel will be deleted and never appear again. Are you confirm? | ||||
|     </template> | ||||
|     <template #actions> | ||||
|       <div class="w-full flex justify-end"> | ||||
|         <v-btn color="grey-darken-3" @click="channels.show.delete = false">Not really</v-btn> | ||||
|         <v-btn color="error" :disabled="loading" @click="deleteChannel">Yes</v-btn> | ||||
|       </div> | ||||
|     </template> | ||||
|   </v-card> | ||||
|  | ||||
|   <v-snackbar v-model="success" :timeout="3000">The realm has been deleted.</v-snackbar> | ||||
|  | ||||
|   <!-- @vue-ignore --> | ||||
|   <v-snackbar v-model="error" :timeout="5000">Something went wrong... {{ error }}</v-snackbar> | ||||
| </template> | ||||
|  | ||||
| <script setup lang="ts"> | ||||
| import { request } from "@/scripts/request" | ||||
| import { getAtk } from "@/stores/userinfo" | ||||
| import { useChannels } from "@/stores/channels" | ||||
| import { useRoute, useRouter } from "vue-router" | ||||
| import { ref } from "vue" | ||||
|  | ||||
| const route = useRoute() | ||||
| const router = useRouter() | ||||
| const channels = useChannels() | ||||
|  | ||||
| const emits = defineEmits(["relist"]) | ||||
|  | ||||
| const error = ref<string | null>(null) | ||||
| const success = ref(false) | ||||
| const loading = ref(false) | ||||
|  | ||||
| async function deleteChannel() { | ||||
|   const target = channels.related.delete_to | ||||
|   const url = `/api/channels/${target.id}` | ||||
|  | ||||
|   loading.value = true | ||||
|   const res = await request("messaging", url, { | ||||
|     method: "DELETE", | ||||
|     headers: { Authorization: `Bearer ${await getAtk()}` } | ||||
|   }) | ||||
|   if (res.status !== 200) { | ||||
|     error.value = await res.text() | ||||
|   } else { | ||||
|     success.value = true | ||||
|     channels.show.delete = false | ||||
|     channels.related.delete_to = null | ||||
|     emits("relist") | ||||
|     if (route.name?.toString()?.includes("channel")) { | ||||
|       await router.push({ name: "explore" }) | ||||
|     } | ||||
|   } | ||||
|   loading.value = false | ||||
| } | ||||
| </script> | ||||
							
								
								
									
										77
									
								
								src/components/chat/channels/ChannelEditor.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,77 @@ | ||||
| <template> | ||||
|   <v-card title="Establish a channel" prepend-icon="mdi-pound-box" class="min-h-[540px]" :loading="loading"> | ||||
|     <v-form @submit.prevent="submit"> | ||||
|       <v-card-text> | ||||
|         <v-text-field label="Alias" variant="outlined" density="comfortable" hint="Must be unique" | ||||
|                       v-model="data.alias" /> | ||||
|         <v-text-field label="Name" variant="outlined" density="comfortable" v-model="data.name" /> | ||||
|         <v-textarea label="Description" variant="outlined" density="comfortable" v-model="data.description" /> | ||||
|       </v-card-text> | ||||
|       <v-card-actions> | ||||
|         <v-spacer></v-spacer> | ||||
|  | ||||
|         <v-btn type="reset" color="grey-darken-3" @click="channels.show.editor = false">Cancel</v-btn> | ||||
|         <v-btn type="submit" :disabled="loading">Save</v-btn> | ||||
|       </v-card-actions> | ||||
|     </v-form> | ||||
|   </v-card> | ||||
|  | ||||
|   <!-- @vue-ignore --> | ||||
|   <v-snackbar v-model="error" :timeout="5000">Something went wrong... {{ error }}</v-snackbar> | ||||
| </template> | ||||
|  | ||||
| <script setup lang="ts"> | ||||
| import { ref, watch } from "vue" | ||||
| import { getAtk } from "@/stores/userinfo" | ||||
| import { request } from "@/scripts/request" | ||||
| import { useChannels } from "@/stores/channels" | ||||
|  | ||||
| const emits = defineEmits(["relist"]) | ||||
|  | ||||
| const channels = useChannels() | ||||
|  | ||||
| const error = ref<null | string>(null) | ||||
| const loading = ref(false) | ||||
|  | ||||
| const data = ref({ | ||||
|   alias: "", | ||||
|   name: "", | ||||
|   description: "" | ||||
| }) | ||||
|  | ||||
| async function submit(evt: SubmitEvent) { | ||||
|   const form = evt.target as HTMLFormElement | ||||
|   const payload = data.value | ||||
|   if (!payload.name) return | ||||
|  | ||||
|   const url = channels.related.edit_to ? `/api/channels/${channels.related.edit_to?.id}` : "/api/channels" | ||||
|   const method = channels.related.edit_to ? "PUT" : "POST" | ||||
|  | ||||
|   loading.value = true | ||||
|   const res = await request("messaging", url, { | ||||
|     method: method, | ||||
|     headers: { "Content-Type": "application/json", Authorization: `Bearer ${await getAtk()}` }, | ||||
|     body: JSON.stringify(payload) | ||||
|   }) | ||||
|   if (res.status !== 200) { | ||||
|     error.value = await res.text() | ||||
|   } else { | ||||
|     emits("relist") | ||||
|     form.reset() | ||||
|     channels.done = true | ||||
|     channels.show.editor = false | ||||
|     channels.related.edit_to = null | ||||
|   } | ||||
|   loading.value = false | ||||
| } | ||||
|  | ||||
| watch( | ||||
|   channels.related, | ||||
|   (val) => { | ||||
|     if (val.edit_to) { | ||||
|       data.value = JSON.parse(JSON.stringify(val.edit_to)) | ||||
|     } | ||||
|   }, | ||||
|   { immediate: true } | ||||
| ) | ||||
| </script> | ||||
							
								
								
									
										54
									
								
								src/components/chat/channels/ChannelInvitation.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,54 @@ | ||||
| <template> | ||||
|   <v-card prepend-icon="mdi-account-plus" title="Invite someone"> | ||||
|     <v-form @submit.prevent="inviteMember"> | ||||
|       <v-card-text> | ||||
|         <v-text-field | ||||
|           label="Username" | ||||
|           variant="outlined" | ||||
|           density="comfortable" | ||||
|           hint="Require username not the nickname" | ||||
|           v-model="targetName" | ||||
|         /> | ||||
|       </v-card-text> | ||||
|       <v-card-actions> | ||||
|         <v-spacer></v-spacer> | ||||
|  | ||||
|         <v-btn type="reset" color="grey-darken-3" @click="emits('close')">Cancel</v-btn> | ||||
|         <v-btn type="submit" :disabled="loading">Invite</v-btn> | ||||
|       </v-card-actions> | ||||
|     </v-form> | ||||
|   </v-card> | ||||
| </template> | ||||
|  | ||||
| <script setup lang="ts"> | ||||
| import { ref } from "vue" | ||||
| import { request } from "@/scripts/request" | ||||
| import { getAtk } from "@/stores/userinfo" | ||||
|  | ||||
| const props = defineProps<{ item: any }>() | ||||
| const emits = defineEmits(["close", "error", "relist"]) | ||||
|  | ||||
| const loading = ref(false) | ||||
|  | ||||
| const targetName = ref("") | ||||
|  | ||||
| async function inviteMember(evt: SubmitEvent) { | ||||
|   const form = evt.target as HTMLFormElement | ||||
|  | ||||
|   loading.value = true | ||||
|   const res = await request("messaging", `/api/channels/${props.item?.id}/invite`, { | ||||
|     method: "POST", | ||||
|     headers: { "Content-Type": "application/json", Authorization: `Bearer ${await getAtk()}` }, | ||||
|     body: JSON.stringify({ | ||||
|       account_name: targetName.value | ||||
|     }) | ||||
|   }) | ||||
|   if (res.status !== 200) { | ||||
|     emits("error", await res.text()) | ||||
|   } else { | ||||
|     form.reset() | ||||
|     emits("relist") | ||||
|   } | ||||
|   loading.value = false | ||||
| } | ||||
| </script> | ||||
							
								
								
									
										43
									
								
								src/components/chat/channels/ChannelList.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,43 @@ | ||||
| <template> | ||||
|   <v-list-group value="channels"> | ||||
|     <template #activator="{ props }"> | ||||
|       <v-list-item | ||||
|         v-bind="props" | ||||
|         prepend-icon="mdi-chat" | ||||
|         title="Channels" | ||||
|       /> | ||||
|     </template> | ||||
|  | ||||
|     <v-list-item | ||||
|       v-for="item in channels.available" | ||||
|       exact | ||||
|       append-icon="mdi-pound-box" | ||||
|       :to="{ name: 'chat.channel', params: { channel: item.alias } }" | ||||
|       :title="item.name" | ||||
|     /> | ||||
|  | ||||
|     <v-list-item | ||||
|       append-icon="mdi-plus" | ||||
|       title="Create a channel" | ||||
|       variant="plain" | ||||
|       :disabled="!id.userinfo.isLoggedIn" | ||||
|       @click="createChannel" | ||||
|     /> | ||||
|   </v-list-group> | ||||
| </template> | ||||
|  | ||||
| <script setup lang="ts"> | ||||
| import { useUserinfo } from "@/stores/userinfo" | ||||
| import { useRealms } from "@/stores/realms" | ||||
| import { useChannels } from "@/stores/channels" | ||||
|  | ||||
| const id = useUserinfo() | ||||
| const channels = useChannels() | ||||
|  | ||||
| function createChannel() { | ||||
|   channels.related.edit_to = null | ||||
|   channels.related.manage_to = null | ||||
|   channels.related.delete_to = null | ||||
|   channels.show.editor = true | ||||
| } | ||||
| </script> | ||||
							
								
								
									
										124
									
								
								src/components/chat/channels/ChannelMembers.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,124 @@ | ||||
| <template> | ||||
|   <v-card title="Channel members" class="min-h-[540px]"> | ||||
|     <v-list density="comfortable" lines="one"> | ||||
|       <v-list-item v-for="item in members" :title="item.account.nick"> | ||||
|         <template #subtitle>@{{ item.account.name }}</template> | ||||
|         <template #prepend> | ||||
|           <v-avatar | ||||
|             color="grey-lighten-2" | ||||
|             icon="mdi-account-circle" | ||||
|             class="rounded-card me-2" | ||||
|             size="small" | ||||
|             :image="item?.account.avatar" | ||||
|           /> | ||||
|         </template> | ||||
|         <template #append> | ||||
|           <v-btn | ||||
|             icon="mdi-account-remove" | ||||
|             size="x-small" | ||||
|             color="error" | ||||
|             variant="text" | ||||
|             :disabled="!checkKickable(item)" | ||||
|             @click="kickMember(item)" | ||||
|           /> | ||||
|         </template> | ||||
|       </v-list-item> | ||||
|     </v-list> | ||||
|  | ||||
|     <div v-if="isOwned"> | ||||
|       <v-divider class="mt-2 mb-3 border-opacity-50 mx-[-1rem]" /> | ||||
|  | ||||
|       <div class="px-3"> | ||||
|         <v-dialog class="max-w-[540px]"> | ||||
|           <template #activator="{ props }"> | ||||
|             <v-btn v-bind="props" block prepend-icon="mdi-account-plus" variant="plain"> Invite someone </v-btn> | ||||
|           </template> | ||||
|  | ||||
|           <template #default="{ isActive }"> | ||||
|             <channel-invitation | ||||
|               :item="props.item" | ||||
|               @relist="listMembers" | ||||
|               @error="(val) => (error = val)" | ||||
|               @close="isActive.value = false" | ||||
|             /> | ||||
|           </template> | ||||
|         </v-dialog> | ||||
|       </div> | ||||
|     </div> | ||||
|  | ||||
|     <!-- @vue-ignore --> | ||||
|     <v-snackbar v-model="error" :timeout="5000">Something went wrong... {{ error }}</v-snackbar> | ||||
|   </v-card> | ||||
| </template> | ||||
|  | ||||
| <script setup lang="ts"> | ||||
| import { ref, watch } from "vue" | ||||
| import { request } from "@/scripts/request" | ||||
| import { getAtk, useUserinfo } from "@/stores/userinfo" | ||||
| import { computed } from "vue" | ||||
| import ChannelInvitation from "@/components/chat/channels/ChannelInvitation.vue" | ||||
|  | ||||
| const id = useUserinfo() | ||||
|  | ||||
| const props = defineProps<{ item: any }>() | ||||
|  | ||||
| const members = ref<any[]>([]) | ||||
|  | ||||
| const isOwned = computed(() => { | ||||
|   return id.userinfo.idSet?.messaging === props.item?.account_id | ||||
| }) | ||||
|  | ||||
| const loading = ref(false) | ||||
| const error = ref<string | null>(null) | ||||
|  | ||||
| watch( | ||||
|   () => props.item, | ||||
|   (val) => { | ||||
|     if (val?.id) { | ||||
|       listMembers(val.id) | ||||
|     } | ||||
|   }, | ||||
|   { deep: true, immediate: true } | ||||
| ) | ||||
|  | ||||
| async function listMembers(id: number) { | ||||
|   loading.value = true | ||||
|   const res = await request("messaging", `/api/channels/${id}/members`) | ||||
|   if (res.status !== 200) { | ||||
|     error.value = await res.text() | ||||
|   } else { | ||||
|     error.value = null | ||||
|     members.value = await res.json() | ||||
|   } | ||||
|   loading.value = false | ||||
| } | ||||
|  | ||||
| async function kickMember(item: any) { | ||||
|   loading.value = true | ||||
|   const res = await request("messaging", `/api/channels/${props.item?.id}/kick`, { | ||||
|     method: "POST", | ||||
|     headers: { "Content-Type": "application/json", Authorization: `Bearer ${await getAtk()}` }, | ||||
|     body: JSON.stringify({ | ||||
|       account_name: item.account.name | ||||
|     }) | ||||
|   }) | ||||
|   if (res.status !== 200) { | ||||
|     error.value = await res.text() | ||||
|   } else { | ||||
|     await listMembers(props.item?.id) | ||||
|   } | ||||
|   loading.value = false | ||||
| } | ||||
|  | ||||
| function checkKickable(item: any) { | ||||
|   if (item.account?.id === id.userinfo.idSet?.messaging) return false | ||||
|   if (item.account?.id === props.item?.account_id) return false | ||||
|   return true | ||||
| } | ||||
| </script> | ||||
|  | ||||
| <style> | ||||
| .rounded-card { | ||||
|   border-radius: 8px; | ||||
| } | ||||
| </style> | ||||
							
								
								
									
										25
									
								
								src/components/chat/channels/ChannelTools.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,25 @@ | ||||
| <template> | ||||
|   <v-bottom-sheet class="max-w-[480px]" v-model="channels.show.editor"> | ||||
|     <channel-editor @relist="channels.list" /> | ||||
|   </v-bottom-sheet> | ||||
|   <v-bottom-sheet class="max-w-[480px]" v-model="channels.show.members"> | ||||
|     <channel-members :item="channels.related.manage_to" @relist="channels.list" /> | ||||
|   </v-bottom-sheet> | ||||
|   <v-bottom-sheet class="max-w-[480px]" v-model="channels.show.delete"> | ||||
|     <channel-deletion @relist="channels.list" /> | ||||
|   </v-bottom-sheet> | ||||
|  | ||||
|   <v-bottom-sheet class="max-w-[480px]" v-model="channels.show.messages.delete"> | ||||
|     <message-deletion /> | ||||
|   </v-bottom-sheet> | ||||
| </template> | ||||
|  | ||||
| <script setup lang="ts"> | ||||
| import { useChannels } from "@/stores/channels" | ||||
| import ChannelEditor from "@/components/chat/channels/ChannelEditor.vue" | ||||
| import ChannelMembers from "@/components/chat/channels/ChannelMembers.vue" | ||||
| import ChannelDeletion from "@/components/chat/channels/ChannelDeletion.vue" | ||||
| import MessageDeletion from "@/components/chat/MessageDeletion.vue" | ||||
|  | ||||
| const channels = useChannels() | ||||
| </script> | ||||
							
								
								
									
										141
									
								
								src/components/chat/parts/ChatAttachments.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,141 @@ | ||||
| <template> | ||||
|   <v-dialog | ||||
|     eager | ||||
|     class="max-w-[540px]" | ||||
|     :model-value="props.show" | ||||
|     @update:model-value="(val) => emits('update:show', val)" | ||||
|   > | ||||
|     <v-card title="Attachments"> | ||||
|       <template #text> | ||||
|         <v-file-input | ||||
|           prepend-icon="" | ||||
|           append-icon="mdi-upload" | ||||
|           variant="solo-filled" | ||||
|           label="File Picker" | ||||
|           v-model="picked" | ||||
|           :loading="props.uploading" | ||||
|           @click:append="upload()" | ||||
|         /> | ||||
|  | ||||
|         <h2 class="px-2 mb-1">Media list</h2> | ||||
|         <v-card variant="tonal"> | ||||
|           <v-list> | ||||
|             <v-list-item v-for="(item, idx) in props.value" :title="getFileName(item)"> | ||||
|               <template #subtitle> {{ getFileType(item) }} · {{ formatBytes(item.filesize) }}</template> | ||||
|               <template #append> | ||||
|                 <v-btn icon="mdi-delete" size="small" variant="text" color="error" @click="dispose(idx)" /> | ||||
|               </template> | ||||
|             </v-list-item> | ||||
|           </v-list> | ||||
|         </v-card> | ||||
|       </template> | ||||
|       <template #actions> | ||||
|         <v-btn class="ms-auto" text="Ok" @click="emits('update:show', false)"></v-btn> | ||||
|       </template> | ||||
|     </v-card> | ||||
|   </v-dialog> | ||||
| </template> | ||||
|  | ||||
| <script setup lang="ts"> | ||||
| import { request } from "@/scripts/request" | ||||
| import { getAtk } from "@/stores/userinfo" | ||||
| import { ref } from "vue" | ||||
|  | ||||
| const props = defineProps<{ show: boolean; uploading: boolean; value: any[] }>() | ||||
| const emits = defineEmits(["update:show", "update:uploading", "update:value"]) | ||||
|  | ||||
| const picked = ref<any[]>([]) | ||||
|  | ||||
| const error = ref<string | null>(null) | ||||
|  | ||||
| async function upload(file?: any) { | ||||
|   if (props.uploading) return | ||||
|  | ||||
|   const data = new FormData() | ||||
|   if (!file) { | ||||
|     file = picked.value[0] | ||||
|   } | ||||
|  | ||||
|   data.set("attachment", file) | ||||
|   data.set("hashcode", await calculateHashCode(file)) | ||||
|  | ||||
|   emits("update:uploading", true) | ||||
|   const res = await request("messaging", "/api/attachments", { | ||||
|     method: "POST", | ||||
|     headers: { Authorization: `Bearer ${await getAtk()}` }, | ||||
|     body: data | ||||
|   }) | ||||
|   let meta: any | ||||
|   if (res.status !== 200) { | ||||
|     error.value = await res.text() | ||||
|   } else { | ||||
|     meta = await res.json() | ||||
|     emits("update:value", props.value.concat([meta.info])) | ||||
|     picked.value = [] | ||||
|   } | ||||
|   emits("update:uploading", false) | ||||
|   return meta | ||||
| } | ||||
|  | ||||
| async function dispose(idx: number) { | ||||
|   const media = JSON.parse(JSON.stringify(props.value)) | ||||
|   const item = media.splice(idx)[0] | ||||
|   emits("update:value", media) | ||||
|  | ||||
|   const res = await request("messaging", `/api/attachments/${item.id}`, { | ||||
|     method: "DELETE", | ||||
|     headers: { Authorization: `Bearer ${await getAtk()}` } | ||||
|   }) | ||||
|   if (res.status !== 200) { | ||||
|     error.value = await res.text() | ||||
|   } | ||||
| } | ||||
|  | ||||
| defineExpose({ upload, dispose }) | ||||
|  | ||||
| async function calculateHashCode(file: File): Promise<string> { | ||||
|   return new Promise((resolve, reject) => { | ||||
|     const reader = new FileReader() | ||||
|     reader.onload = async () => { | ||||
|       const buffer = reader.result as ArrayBuffer | ||||
|       const hashBuffer = await crypto.subtle.digest("SHA-256", buffer) | ||||
|       const hashArray = Array.from(new Uint8Array(hashBuffer)) | ||||
|       const hashHex = hashArray.map((byte) => byte.toString(16).padStart(2, "0")).join("") | ||||
|       resolve(hashHex) | ||||
|     } | ||||
|     reader.onerror = () => { | ||||
|       reject(reader.error) | ||||
|     } | ||||
|     reader.readAsArrayBuffer(file) | ||||
|   }) | ||||
| } | ||||
|  | ||||
| function getFileName(item: any) { | ||||
|   return item.filename.replace(/\.[^/.]+$/, "") | ||||
| } | ||||
|  | ||||
| function getFileType(item: any) { | ||||
|   switch (item.type) { | ||||
|     case 1: | ||||
|       return "Photo" | ||||
|     case 2: | ||||
|       return "Video" | ||||
|     case 3: | ||||
|       return "Audio" | ||||
|     default: | ||||
|       return "Others" | ||||
|   } | ||||
| } | ||||
|  | ||||
| function formatBytes(bytes: number, decimals = 2) { | ||||
|   if (!+bytes) return "0 Bytes" | ||||
|  | ||||
|   const k = 1024 | ||||
|   const dm = decimals < 0 ? 0 : decimals | ||||
|   const sizes = ["Bytes", "KiB", "MiB", "GiB", "TiB", "PiB", "EiB", "ZiB", "YiB"] | ||||
|  | ||||
|   const i = Math.floor(Math.log(bytes) / Math.log(k)) | ||||
|  | ||||
|   return `${parseFloat((bytes / Math.pow(k, i)).toFixed(dm))} ${sizes[i]}` | ||||
| } | ||||
| </script> | ||||
							
								
								
									
										102
									
								
								src/components/chat/renderer/MessageAttachment.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,102 @@ | ||||
| <template> | ||||
|   <v-chip size="small" variant="tonal" prepend-icon="mdi-paperclip" v-if="props.overview"> | ||||
|     Attached {{ props.attachments.length }} attachment(s) | ||||
|   </v-chip> | ||||
|  | ||||
|   <v-card variant="outlined" class="w-fit max-h-[540px]"> | ||||
|     <v-carousel | ||||
|       hide-delimiter-background | ||||
|       height="100%" | ||||
|       :hide-delimiters="props.attachments.length <= 1" | ||||
|       :show-arrows="false" | ||||
|     > | ||||
|       <v-carousel-item v-for="(item, idx) in attachments"> | ||||
|         <img | ||||
|           v-if="item.type === 1" | ||||
|           loading="lazy" | ||||
|           decoding="async" | ||||
|           class="cursor-zoom-in content-visibility-auto max-h-[540px] object-cover object-c" | ||||
|           :src="getUrl(item)" | ||||
|           :alt="item.filename" | ||||
|           @click="openLightbox(item, idx)" | ||||
|         /> | ||||
|         <video v-else-if="item.type === 2" controls class="w-full content-visibility-auto"> | ||||
|           <source :src="getUrl(item)" /> | ||||
|         </video> | ||||
|         <div v-else-if="item.type === 3" class="w-[480px] py-12"> | ||||
|           <div class="text-center"> | ||||
|             <p class="mb-1">{{ getFileName(item) }}</p> | ||||
|             <audio controls :src="getUrl(item)" class="mx-auto"></audio> | ||||
|           </div> | ||||
|         </div> | ||||
|         <div v-else class="w-[480px] py-12"> | ||||
|           <div class="text-center"> | ||||
|             <p>{{ getFileName(item) }}</p> | ||||
|             <a class="underline" target="_blank" :href="getUrl(item)">Download</a> | ||||
|           </div> | ||||
|         </div> | ||||
|       </v-carousel-item> | ||||
|     </v-carousel> | ||||
|  | ||||
|     <vue-easy-lightbox | ||||
|       teleport="#app" | ||||
|       :visible="lightbox" | ||||
|       :imgs="props.attachments.map((x) => getUrl(x))" | ||||
|       v-model:index="currentIndex" | ||||
|       @hide="lightbox = false" | ||||
|     > | ||||
|       <template v-slot:close-btn="{ close }"> | ||||
|         <v-btn | ||||
|           class="fixed left-2 top-2" | ||||
|           icon="mdi-close" | ||||
|           variant="text" | ||||
|           color="white" | ||||
|           :style="`margin-top: ${safeAreaTop}`" | ||||
|           @click="close" | ||||
|         /> | ||||
|       </template> | ||||
|     </vue-easy-lightbox> | ||||
|   </v-card> | ||||
| </template> | ||||
|  | ||||
| <script setup lang="ts"> | ||||
| import { buildRequestUrl } from "@/scripts/request" | ||||
| import { computed, ref } from "vue" | ||||
| import { useUI } from "@/stores/ui" | ||||
| import VueEasyLightbox from "vue-easy-lightbox" | ||||
|  | ||||
| const props = defineProps<{ attachments: any[]; overview?: boolean }>() | ||||
|  | ||||
| const ui = useUI() | ||||
|  | ||||
| const lightbox = ref(false) | ||||
|  | ||||
| const current = ref<any>(null) | ||||
| const currentIndex = ref(0) | ||||
|  | ||||
| const safeAreaTop = computed(() => { | ||||
|   return `${ui.safeArea.top}px` | ||||
| }) | ||||
|  | ||||
| function getFileName(item: any) { | ||||
|   return item.filename.replace(/\.[^/.]+$/, "") | ||||
| } | ||||
|  | ||||
| function getUrl(item: any) { | ||||
|   return item.external_url | ||||
|     ? item.external_url | ||||
|     : buildRequestUrl("messaging", `/api/attachments/o/${item.file_id}`) | ||||
| } | ||||
|  | ||||
| function openLightbox(item: any, idx: number) { | ||||
|   current.value = item | ||||
|   currentIndex.value = idx | ||||
|   lightbox.value = true | ||||
| } | ||||
| </script> | ||||
|  | ||||
| <style> | ||||
| .vel-model { | ||||
|   z-index: 10; | ||||
| } | ||||
| </style> | ||||
| @@ -3,21 +3,47 @@ | ||||
|     Attached {{ props.attachments.length }} attachment(s) | ||||
|   </v-chip> | ||||
|  | ||||
|   <v-card v-else variant="outlined" class="max-w-[540px] max-h-[720px]"> | ||||
|     <v-carousel hide-delimiter-background height="100%" :show-arrows="false"> | ||||
|       <v-carousel-item v-for="item in attachments"> | ||||
|         <img v-if="item.type === 1" :src="getUrl(item)" :alt="item.filename" class="cursor-zoom-in" | ||||
|              @click="openLightbox" /> | ||||
|         <video v-if="item.type === 2" controls class="w-full"> | ||||
|   <v-card v-else variant="outlined" class="w-fit max-h-[540px]"> | ||||
|     <v-carousel | ||||
|       hide-delimiter-background | ||||
|       height="100%" | ||||
|       :hide-delimiters="props.attachments.length <= 1" | ||||
|       :show-arrows="false" | ||||
|     > | ||||
|       <v-carousel-item v-for="(item, idx) in attachments"> | ||||
|         <img | ||||
|           v-if="item.type === 1" | ||||
|           decoding="async" | ||||
|           class="cursor-zoom-in max-h-[540px] object-cover object-c" | ||||
|           :src="getUrl(item)" | ||||
|           :alt="getFileName(item)" | ||||
|           @click="openLightbox(item, idx)" | ||||
|         /> | ||||
|         <video v-else-if="item.type === 2" controls class="w-full content-visibility-auto"> | ||||
|           <source :src="getUrl(item)" /> | ||||
|         </video> | ||||
|         <div v-if="item.type === 3" class="w-full px-7 py-12"> | ||||
|           <audio controls :src="getUrl(item)" class="mx-auto"></audio> | ||||
|         <div v-else-if="item.type === 3" class="w-[480px] py-12"> | ||||
|           <div class="text-center"> | ||||
|             <p class="mb-1">{{ getFileName(item) }}</p> | ||||
|             <audio controls :src="getUrl(item)" class="mx-auto"></audio> | ||||
|           </div> | ||||
|         </div> | ||||
|         <div v-else class="w-[480px] py-12"> | ||||
|           <div class="text-center"> | ||||
|             <p>{{ getFileName(item) }}</p> | ||||
|             <a class="underline" target="_blank" :href="getUrl(item)">Download</a> | ||||
|           </div> | ||||
|         </div> | ||||
|       </v-carousel-item> | ||||
|     </v-carousel> | ||||
|  | ||||
|     <vue-easy-lightbox teleport="#app" :visible="lightbox" :imgs="[getUrl(current)]" @hide="lightbox = false"> | ||||
|     <vue-easy-lightbox | ||||
|       teleport="#app" | ||||
|       :visible="lightbox" | ||||
|       :imgs="props.attachments.map((x) => getUrl(x))" | ||||
|       v-model:index="currentIndex" | ||||
|       @hide="lightbox = false" | ||||
|     > | ||||
|       <template v-slot:close-btn="{ close }"> | ||||
|         <v-btn | ||||
|           class="fixed left-2 top-2" | ||||
| @@ -43,23 +69,28 @@ const props = defineProps<{ attachments: any[]; overview?: boolean }>() | ||||
| const ui = useUI() | ||||
|  | ||||
| const lightbox = ref(false) | ||||
| const focus = ref(0) | ||||
|  | ||||
| const current = computed(() => props.attachments[focus.value]) | ||||
| const canLightbox = computed(() => current.value.type === 1) | ||||
| const current = ref<any>(null) | ||||
| const currentIndex = ref(0) | ||||
|  | ||||
| const safeAreaTop = computed(() => { | ||||
|   return `${ui.safeArea.top}px` | ||||
| }) | ||||
|  | ||||
| function getUrl(item: any) { | ||||
|   return item.external_url ? item.external_url : buildRequestUrl("interactive", `/api/attachments/o/${item.file_id}`) | ||||
| function getFileName(item: any) { | ||||
|   return item.filename.replace(/\.[^/.]+$/, "") | ||||
| } | ||||
|  | ||||
| function openLightbox() { | ||||
|   if (canLightbox.value) { | ||||
|     lightbox.value = true | ||||
|   } | ||||
| function getUrl(item: any) { | ||||
|   return item.external_url | ||||
|     ? item.external_url | ||||
|     : buildRequestUrl("interactive", `/api/attachments/o/${item.file_id}`) | ||||
| } | ||||
|  | ||||
| function openLightbox(item: any, idx: number) { | ||||
|   current.value = item | ||||
|   currentIndex.value = idx | ||||
|   lightbox.value = true | ||||
| } | ||||
| </script> | ||||
|  | ||||
|   | ||||
| @@ -1,8 +1,8 @@ | ||||
| <template> | ||||
|   <div class="post-list"> | ||||
|   <div class="post-list mx-[-8px]"> | ||||
|     <v-infinite-scroll :items="props.posts" :onLoad="props.loader"> | ||||
|       <template v-for="(item, idx) in props.posts" :key="item"> | ||||
|         <div class="mb-3 px-1"> | ||||
|       <template v-for="(item, idx) in props.posts" :key="item.id"> | ||||
|         <div class="mb-3 px-[8px]"> | ||||
|           <v-card> | ||||
|             <template #text> | ||||
|               <post-item brief :item="item" @update:item="(val) => updateItem(idx, val)" /> | ||||
|   | ||||
| @@ -127,7 +127,7 @@ import { useRealms } from "@/stores/realms" | ||||
| import { computed, reactive, ref, watch } from "vue" | ||||
| import { useRoute, useRouter } from "vue-router" | ||||
| import PlannedPublish from "@/components/publish/parts/PlannedPublish.vue" | ||||
| import Media from "@/components/publish/parts/Media.vue" | ||||
| import Media from "@/components/publish/parts/PublishMedia.vue" | ||||
| import PublishArea from "@/components/publish/parts/PublishArea.vue" | ||||
|  | ||||
| const route = useRoute() | ||||
| @@ -262,10 +262,7 @@ watch( | ||||
| } | ||||
|  | ||||
| .snackbar-progress { | ||||
|   margin-left: -16px; | ||||
|   margin-right: -16px; | ||||
|   margin-bottom: -14px; | ||||
|   margin-top: 12px; | ||||
|   margin: 12px -16px -14px; | ||||
|   width: calc(100% + 64px); | ||||
| } | ||||
| </style> | ||||
|   | ||||
| @@ -93,7 +93,7 @@ import { reactive, ref, watch } from "vue" | ||||
| import { useRoute, useRouter } from "vue-router" | ||||
| import PlannedPublish from "@/components/publish/parts/PlannedPublish.vue" | ||||
| import PublishArea from "@/components/publish/parts/PublishArea.vue" | ||||
| import Media from "@/components/publish/parts/Media.vue" | ||||
| import Media from "@/components/publish/parts/PublishMedia.vue" | ||||
|  | ||||
| const route = useRoute() | ||||
| const editor = useEditor() | ||||
| @@ -188,10 +188,7 @@ watch( | ||||
|  | ||||
| <style> | ||||
| .snackbar-progress { | ||||
|   margin-left: -16px; | ||||
|   margin-right: -16px; | ||||
|   margin-bottom: -14px; | ||||
|   margin-top: 12px; | ||||
|   margin: 12px -16px -14px; | ||||
|   width: calc(100% + 64px); | ||||
| } | ||||
| </style> | ||||
|   | ||||
| @@ -22,7 +22,7 @@ const realms = useRealms() | ||||
|  | ||||
| const props = defineProps<{ item: any }>() | ||||
|  | ||||
| const isOwned = computed(() => props.item?.account_id === id.userinfo.data.id) | ||||
| const isOwned = computed(() => props.item?.account_id === id.userinfo.idSet?.interactive) | ||||
|  | ||||
| function editRealm() { | ||||
|   realms.related.edit_to = props.item | ||||
|   | ||||
| @@ -3,12 +3,12 @@ | ||||
|     <template #text> | ||||
|       You are deleting a realm | ||||
|       <b>{{ realms.related.delete_to?.name }}</b> <br /> | ||||
|       All posts belonging to this domain will be deleted and never appear again. Are you confirm? | ||||
|       All posts belonging to this realm will be deleted and never appear again. Are you confirm? | ||||
|     </template> | ||||
|     <template #actions> | ||||
|       <div class="w-full flex justify-end"> | ||||
|         <v-btn color="grey-darken-3" @click="realms.show.delete = false">Not really</v-btn> | ||||
|         <v-btn color="error" :disabled="loading" @click="deletePost">Yes</v-btn> | ||||
|         <v-btn color="error" :disabled="loading" @click="deleteRealm">Yes</v-btn> | ||||
|       </div> | ||||
|     </template> | ||||
|   </v-card> | ||||
| @@ -36,7 +36,7 @@ const error = ref<string | null>(null) | ||||
| const success = ref(false) | ||||
| const loading = ref(false) | ||||
|  | ||||
| async function deletePost() { | ||||
| async function deleteRealm() { | ||||
|   const target = realms.related.delete_to | ||||
|   const url = `/api/realms/${target.id}` | ||||
|  | ||||
| @@ -52,8 +52,8 @@ async function deletePost() { | ||||
|     realms.show.delete = false | ||||
|     realms.related.delete_to = null | ||||
|     emits("relist") | ||||
|     if (route.name?.toString()?.startsWith("realm")) { | ||||
|       router.push({ name: "explore" }) | ||||
|     if (route.name?.toString()?.includes("realm")) { | ||||
|       await router.push({ name: "explore" }) | ||||
|     } | ||||
|   } | ||||
|   loading.value = false | ||||
|   | ||||
| @@ -1,27 +1,29 @@ | ||||
| <template> | ||||
|   <v-list density="comfortable"> | ||||
|     <v-list-subheader> | ||||
|       Realms | ||||
|       <v-badge color="warning" content="Alpha" inline /> | ||||
|     </v-list-subheader> | ||||
|   <v-list-group value="realms"> | ||||
|     <template #activator="{ props }"> | ||||
|       <v-list-item | ||||
|         v-bind="props" | ||||
|         prepend-icon="mdi-account-box-multiple" | ||||
|         title="Realms" | ||||
|       /> | ||||
|     </template> | ||||
|  | ||||
|     <v-list-item | ||||
|       v-for="item in realms.available" | ||||
|       exact | ||||
|       prepend-icon="mdi-account-multiple" | ||||
|       append-icon="mdi-account-multiple" | ||||
|       :to="{ name: 'realms.page', params: { realmId: item.id } }" | ||||
|       :title="item.name" | ||||
|     /> | ||||
|  | ||||
|     <v-divider v-if="realms.available.length > 0" class="border-opacity-75 my-2" /> | ||||
|  | ||||
|     <v-list-item | ||||
|       prepend-icon="mdi-plus" | ||||
|       append-icon="mdi-plus" | ||||
|       title="Create a realm" | ||||
|       variant="plain" | ||||
|       :disabled="!id.userinfo.isLoggedIn" | ||||
|       @click="createRealm" | ||||
|     /> | ||||
|   </v-list> | ||||
|   </v-list-group> | ||||
| </template> | ||||
|  | ||||
| <script setup lang="ts"> | ||||
|   | ||||
| @@ -111,7 +111,7 @@ async function kickMember(item: any) { | ||||
| } | ||||
|  | ||||
| function checkKickable(item: any) { | ||||
|   if (item.account?.id === id.userinfo.data?.id) return false | ||||
|   if (item.account?.id === id.userinfo.idSet?.interactive) return false | ||||
|   if (item.account?.id === props.item?.account_id) return false | ||||
|   return true | ||||
| } | ||||
|   | ||||
| @@ -1,8 +1,8 @@ | ||||
| <template> | ||||
|   <v-bottom-sheet v-model="realms.show.editor"> | ||||
|   <v-bottom-sheet class="max-w-[480px]" v-model="realms.show.editor"> | ||||
|     <realm-editor @relist="realms.list" /> | ||||
|   </v-bottom-sheet> | ||||
|   <v-bottom-sheet v-model="realms.show.delete"> | ||||
|   <v-bottom-sheet class="max-w-[480px]" v-model="realms.show.delete"> | ||||
|     <realm-deletion @relist="realms.list" /> | ||||
|   </v-bottom-sheet> | ||||
| </template> | ||||
|   | ||||
| @@ -3,6 +3,9 @@ | ||||
|     <v-system-bar v-show="ui.safeArea.top > 0" color="primary" :order="1" :height="ui.safeArea.top" /> | ||||
|  | ||||
|     <router-view /> | ||||
|  | ||||
|     <realm-tools /> | ||||
|     <channel-tools /> | ||||
|   </v-app> | ||||
| </template> | ||||
|  | ||||
| @@ -10,6 +13,8 @@ | ||||
| import { onMounted, ref } from "vue" | ||||
| import { Capacitor } from "@capacitor/core" | ||||
| import { useUI } from "@/stores/ui" | ||||
| import RealmTools from "@/components/realms/RealmTools.vue" | ||||
| import ChannelTools from "@/components/chat/channels/ChannelTools.vue" | ||||
|  | ||||
| const ui = useUI() | ||||
|  | ||||
|   | ||||
							
								
								
									
										72
									
								
								src/layouts/chat.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,72 @@ | ||||
| <template> | ||||
|   <v-app-bar :order="5" color="grey-lighten-3"> | ||||
|     <div class="max-md:px-5 md:px-12 flex flex-grow-1 items-center max-w-full"> | ||||
|       <v-app-bar-nav-icon icon="mdi-chat" :loading="loading" /> | ||||
|  | ||||
|       <h2 class="ml-2 text-lg font-500 overflow-hidden ws-nowrap text-clip">{{ channels.current?.name }}</h2> | ||||
|       <p class="ml-3 text-xs opacity-80 overflow-hidden ws-nowrap text-clip">{{ channels.current?.description }}</p> | ||||
|  | ||||
|       <v-spacer /> | ||||
|  | ||||
|       <div v-if="channels.current"> | ||||
|         <channel-action :item="channels.current" /> | ||||
|       </div> | ||||
|     </div> | ||||
|   </v-app-bar> | ||||
|  | ||||
|   <router-view /> | ||||
|  | ||||
|   <!-- @vue-ignore --> | ||||
|   <v-snackbar v-model="error" :timeout="5000">Something went wrong... {{ error }}</v-snackbar> | ||||
| </template> | ||||
|  | ||||
| <script setup lang="ts"> | ||||
| import { request } from "@/scripts/request" | ||||
| import { useRoute } from "vue-router" | ||||
| import { onMounted, ref, watch } from "vue" | ||||
| import { useChannels } from "@/stores/channels" | ||||
| import ChannelAction from "@/components/chat/channels/ChannelAction.vue" | ||||
|  | ||||
| const route = useRoute() | ||||
| const channels = useChannels() | ||||
|  | ||||
| const error = ref<string | null>(null) | ||||
| const loading = ref(false) | ||||
|  | ||||
| async function readMetadata() { | ||||
|   loading.value = true | ||||
|   const res = await request("messaging", `/api/channels/${route.params.channel}`) | ||||
|   if (res.status !== 200) { | ||||
|     error.value = await res.text() | ||||
|   } else { | ||||
|     error.value = null | ||||
|     channels.current = await res.json() | ||||
|   } | ||||
|   loading.value = false | ||||
| } | ||||
|  | ||||
| watch( | ||||
|   () => route.params.channel, | ||||
|   (val) => { | ||||
|     if (val) { | ||||
|       channels.messages = [] | ||||
|       readMetadata() | ||||
|     } | ||||
|   }, | ||||
|   { immediate: true } | ||||
| ) | ||||
|  | ||||
| watch(() => channels.done, (val) => { | ||||
|   if (val) { | ||||
|     readMetadata().then(() => { | ||||
|       channels.messages = [] | ||||
|       channels.done = false | ||||
|     }) | ||||
|   } | ||||
| }, { immediate: true }) | ||||
|  | ||||
| onMounted(() => { | ||||
|   channels.current = null | ||||
|   channels.messages = [] | ||||
| }) | ||||
| </script> | ||||
| @@ -6,33 +6,46 @@ | ||||
|     :rail="drawerMini" | ||||
|     :rail-width="58" | ||||
|     :order="0" | ||||
|     floating | ||||
|     @click="drawerMini = false" | ||||
|   > | ||||
|     <div class="flex flex-col h-full"> | ||||
|       <div class="flex items-center justify-between px-3 pb-2.5 border-opacity-15 min-h-[64px]" | ||||
|            style="border-bottom-width: thin" | ||||
|            :style="`padding-top: max(${safeAreaTop}, 10px)`"> | ||||
|       <v-toolbar | ||||
|         class="flex items-center justify-between px-[14px] border-opacity-15" | ||||
|         color="primary" | ||||
|         height="64" | ||||
|         :style="`padding-top: ${safeAreaTop}`" | ||||
|       > | ||||
|         <div class="flex items-center"> | ||||
|           <img src="/favicon.png" alt="Logo" width="36" height="36" class="block" /> | ||||
|           <img src="/favicon.png" alt="Logo" width="32" height="32" class="block" /> | ||||
|           <div v-show="!drawerMini" class="ms-6 font-medium">Solar Network</div> | ||||
|         </div> | ||||
|  | ||||
|         <v-btn | ||||
|           v-show="!drawerMini" | ||||
|           icon="mdi-arrow-collapse-left" | ||||
|           size="small" | ||||
|           variant="text" | ||||
|           @click.stop="drawerMini = true" | ||||
|         /> | ||||
|       </div> | ||||
|         <v-spacer /> | ||||
|  | ||||
|       <div class="flex-grow-1"> | ||||
|         <div> | ||||
|           <v-btn | ||||
|             v-show="!drawerMini" | ||||
|             icon="mdi-arrow-collapse-left" | ||||
|             size="small" | ||||
|             variant="text" | ||||
|             @click.stop="drawerMini = true" | ||||
|           /> | ||||
|         </div> | ||||
|       </v-toolbar> | ||||
|  | ||||
|       <v-list class="flex-grow-1" :opened="drawerMini ? [] : expanded" @update:opened="(val) => expanded = val"> | ||||
|         <channel-list /> | ||||
|         <v-divider class="border-opacity-75 my-2" /> | ||||
|         <realm-list /> | ||||
|       </div> | ||||
|       </v-list> | ||||
|  | ||||
|       <!-- User info --> | ||||
|       <v-list class="border-opacity-15 h-[64px]" style="border-top-width: thin" | ||||
|               :style="`margin-bottom: ${safeAreaBottom}`"> | ||||
|       <v-list | ||||
|         class="border-opacity-15 h-[64px]" | ||||
|         style="border-top-width: thin" | ||||
|         :style="`margin-bottom: ${safeAreaBottom}`" | ||||
|       > | ||||
|         <v-list-item :subtitle="username" :title="nickname"> | ||||
|           <template #prepend> | ||||
|             <v-avatar icon="mdi-account-circle" :image="id.userinfo.data?.picture" /> | ||||
| @@ -44,8 +57,17 @@ | ||||
|               </template> | ||||
|  | ||||
|               <v-list density="compact"> | ||||
|                 <v-list-item title="Solarpass" prepend-icon="mdi-passport-biometric" target="_blank" | ||||
|                              :href="passportUrl" /> | ||||
|                 <v-list-item | ||||
|                   title="Sign out" | ||||
|                   prepend-icon="mdi-logout-variant" | ||||
|                   @click="signout" | ||||
|                 /> | ||||
|                 <v-list-item | ||||
|                   title="Solarpass" | ||||
|                   prepend-icon="mdi-passport-biometric" | ||||
|                   target="_blank" | ||||
|                   :href="passportUrl" | ||||
|                 /> | ||||
|               </v-list> | ||||
|             </v-menu> | ||||
|  | ||||
| @@ -75,36 +97,19 @@ | ||||
|   <v-main id="main"> | ||||
|     <router-view /> | ||||
|   </v-main> | ||||
|  | ||||
|   <v-menu open-on-hover open-on-click :open-delay="0" :close-delay="0" location="top" | ||||
|           transition="scroll-y-reverse-transition"> | ||||
|     <template v-slot:activator="{ props }"> | ||||
|       <v-fab v-bind="props" appear class="editor-fab" icon="mdi-pencil" color="primary" size="64" | ||||
|              :active="id.userinfo.isLoggedIn" /> | ||||
|     </template> | ||||
|  | ||||
|     <div class="flex flex-col items-center gap-4 mb-4"> | ||||
|       <v-btn variant="elevated" color="secondary" icon="mdi-newspaper-variant" @click="editor.show.article = true" /> | ||||
|       <v-btn variant="elevated" color="accent" icon="mdi-camera-iris" @click="editor.show.moment = true" /> | ||||
|     </div> | ||||
|   </v-menu> | ||||
|  | ||||
|   <post-tools /> | ||||
|   <realm-tools /> | ||||
| </template> | ||||
|  | ||||
| <script setup lang="ts"> | ||||
| import { computed, ref } from "vue" | ||||
| import { useEditor } from "@/stores/editor" | ||||
| import { useUserinfo } from "@/stores/userinfo" | ||||
| import { useUserinfo, signout as signoutAccount } from "@/stores/userinfo" | ||||
| import { useWellKnown } from "@/stores/wellKnown" | ||||
| import { useUI } from "@/stores/ui" | ||||
| import PostTools from "@/components/publish/PostTools.vue" | ||||
| import RealmTools from "@/components/realms/RealmTools.vue" | ||||
| import RealmList from "@/components/realms/RealmList.vue" | ||||
| import NotificationList from "@/components/NotificationList.vue" | ||||
| import ChannelList from "@/components/chat/channels/ChannelList.vue" | ||||
|  | ||||
| const ui = useUI() | ||||
| const expanded = ref<string[]>(["channels"]) | ||||
|  | ||||
| const safeAreaTop = computed(() => { | ||||
|   return `${ui.safeArea.top}px` | ||||
| @@ -115,7 +120,6 @@ const safeAreaBottom = computed(() => { | ||||
| }) | ||||
|  | ||||
| const id = useUserinfo() | ||||
| const editor = useEditor() | ||||
|  | ||||
| const username = computed(() => { | ||||
|   if (id.userinfo.isLoggedIn) { | ||||
| @@ -144,12 +148,11 @@ meta.readWellKnown() | ||||
|  | ||||
| const drawerOpen = ref(true) | ||||
| const drawerMini = ref(false) | ||||
|  | ||||
| async function signout() { | ||||
|   signoutAccount().then(() => { | ||||
|     window.location.reload() | ||||
|   }) | ||||
| } | ||||
| </script> | ||||
|  | ||||
| <style scoped> | ||||
| .editor-fab { | ||||
|   position: fixed !important; | ||||
|   bottom: 16px; | ||||
|   right: 20px; | ||||
| } | ||||
| </style> | ||||
|   | ||||
							
								
								
									
										63
									
								
								src/layouts/plaza.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,63 @@ | ||||
| <template> | ||||
|   <router-view /> | ||||
|  | ||||
|   <v-fab | ||||
|     appear | ||||
|     class="editor-fab" | ||||
|     color="primary" | ||||
|     size="64" | ||||
|     :active="id.userinfo.isLoggedIn" | ||||
|   > | ||||
|     <v-icon icon="mdi-pencil" /> | ||||
|  | ||||
|     <v-speed-dial | ||||
|       target=".editor-fab" | ||||
|       activator="parent" | ||||
|       location="top center" | ||||
|       class="editor-speed-dial" | ||||
|       transition="slide-y-reverse-transition" | ||||
|       open-on-hover | ||||
|       open-on-click | ||||
|     > | ||||
|       <v-btn | ||||
|         key="article" | ||||
|         variant="elevated" | ||||
|         color="secondary" | ||||
|         icon="mdi-newspaper-variant" | ||||
|         @click="editor.show.article = true" | ||||
|       /> | ||||
|       <v-btn | ||||
|         key="moment" | ||||
|         variant="elevated" | ||||
|         color="accent" | ||||
|         icon="mdi-camera-iris" | ||||
|         @click="editor.show.moment = true" | ||||
|       /> | ||||
|     </v-speed-dial> | ||||
|   </v-fab> | ||||
|  | ||||
|   <post-tools /> | ||||
| </template> | ||||
|  | ||||
| <script setup lang="ts"> | ||||
| import { useEditor } from "@/stores/editor" | ||||
| import { useUserinfo } from "@/stores/userinfo" | ||||
| import PostTools from "@/components/publish/PostTools.vue" | ||||
|  | ||||
| const id = useUserinfo() | ||||
| const editor = useEditor() | ||||
| </script> | ||||
|  | ||||
| <style scoped> | ||||
| .editor-fab { | ||||
|   position: fixed !important; | ||||
|   bottom: 16px; | ||||
|   right: 20px; | ||||
| } | ||||
|  | ||||
| .editor-speed-dial { | ||||
|   position: fixed !important; | ||||
|   bottom: 16px; | ||||
|   right: 20px; | ||||
| } | ||||
| </style> | ||||
| @@ -3,6 +3,8 @@ import "virtual:uno.css" | ||||
| import "./assets/utils.css" | ||||
| import "./assets/safe-area.css" | ||||
|  | ||||
| import "nprogress/nprogress.css" | ||||
|  | ||||
| import { createApp } from "vue" | ||||
| import { createPinia } from "pinia" | ||||
|  | ||||
| @@ -17,9 +19,16 @@ import "@mdi/font/css/materialdesignicons.min.css" | ||||
| import "@fontsource/roboto/latin.css" | ||||
| import "@unocss/reset/tailwind.css" | ||||
|  | ||||
| import nprogress from "nprogress"; | ||||
|  | ||||
| import index from "./index.vue" | ||||
| import router from "./router" | ||||
|  | ||||
| nprogress.configure({showSpinner: false}) | ||||
| nprogress.start() | ||||
|  | ||||
| window.onload = () => nprogress.done() | ||||
|  | ||||
| const app = createApp(index) | ||||
|  | ||||
| app.use( | ||||
|   | ||||
| @@ -1,6 +1,8 @@ | ||||
| import { createRouter, createWebHistory } from "vue-router" | ||||
| import MasterLayout from "@/layouts/master.vue" | ||||
|  | ||||
| import nprogress from "nprogress"; | ||||
|  | ||||
| const router = createRouter({ | ||||
|   history: createWebHistory(import.meta.env.BASE_URL), | ||||
|   routes: [ | ||||
| @@ -10,25 +12,43 @@ const router = createRouter({ | ||||
|       children: [ | ||||
|         { | ||||
|           path: "/", | ||||
|           name: "explore", | ||||
|           component: () => import("@/views/explore.vue") | ||||
|           component: () => import("@/layouts/plaza.vue"), | ||||
|           children: [ | ||||
|             { | ||||
|               path: "/", | ||||
|               name: "explore", | ||||
|               component: () => import("@/views/explore.vue") | ||||
|             }, | ||||
|  | ||||
|             { | ||||
|               path: "/p/moments/:alias", | ||||
|               name: "posts.details.moments", | ||||
|               component: () => import("@/views/posts/moments.vue") | ||||
|             }, | ||||
|             { | ||||
|               path: "/p/articles/:alias", | ||||
|               name: "posts.details.articles", | ||||
|               component: () => import("@/views/posts/articles.vue") | ||||
|             }, | ||||
|  | ||||
|             { | ||||
|               path: "/realms/:realmId", | ||||
|               name: "realms.page", | ||||
|               component: () => import("@/views/realms/page.vue") | ||||
|             } | ||||
|           ] | ||||
|         }, | ||||
|  | ||||
|         { | ||||
|           path: "/p/moments/:alias", | ||||
|           name: "posts.details.moments", | ||||
|           component: () => import("@/views/posts/moments.vue") | ||||
|         }, | ||||
|         { | ||||
|           path: "/p/articles/:alias", | ||||
|           name: "posts.details.articles", | ||||
|           component: () => import("@/views/posts/articles.vue") | ||||
|         }, | ||||
|  | ||||
|         { | ||||
|           path: "/realms/:realmId", | ||||
|           name: "realms.page", | ||||
|           component: () => import("@/views/realms/page.vue") | ||||
|           path: "/chat/:channel", | ||||
|           component: () => import("@/layouts/chat.vue"), | ||||
|           children: [ | ||||
|             { | ||||
|               path: "", | ||||
|               name: "chat.channel", | ||||
|               component: () => import("@/views/chat/page.vue"), | ||||
|             } | ||||
|           ] | ||||
|         }, | ||||
|  | ||||
|         { | ||||
| @@ -53,4 +73,11 @@ const router = createRouter({ | ||||
|   ] | ||||
| }) | ||||
|  | ||||
| router.beforeEach((_to, _from, next) => { | ||||
|   nprogress.start() | ||||
|   next() | ||||
| }) | ||||
|  | ||||
| router.afterEach(() => nprogress.done()) | ||||
|  | ||||
| export default router | ||||
|   | ||||
| @@ -3,7 +3,8 @@ import { Preferences } from "@capacitor/preferences" | ||||
|  | ||||
| const serviceMap: { [id: string]: string } = { | ||||
|   interactive: "https://co.solsynth.dev", | ||||
|   identity: "https://id.solsynth.dev" | ||||
|   identity: "https://id.solsynth.dev", | ||||
|   messaging: "https://im.solsynth.dev", | ||||
| } | ||||
|  | ||||
| export async function request(service: string, input: string, init?: RequestInit, noRetry?: boolean) { | ||||
|   | ||||
							
								
								
									
										105
									
								
								src/stores/channels.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,105 @@ | ||||
| import { defineStore } from "pinia" | ||||
| import { reactive, ref, watch } from "vue" | ||||
| import { checkLoggedIn, getAtk } from "@/stores/userinfo" | ||||
| import { buildRequestUrl, request } from "@/scripts/request" | ||||
| import { useRoute } from "vue-router" | ||||
|  | ||||
| export const useChannels = defineStore("channels", () => { | ||||
|   let socket: WebSocket | ||||
|  | ||||
|   const done = ref(false) | ||||
|  | ||||
|   const show = reactive({ | ||||
|     members: false, | ||||
|     editor: false, | ||||
|     delete: false, | ||||
|  | ||||
|     messages: { | ||||
|       delete: false | ||||
|     } | ||||
|   }) | ||||
|  | ||||
|   const related = reactive<{ [id: string]: any }>({ | ||||
|     edit_to: null, | ||||
|     manage_to: null, | ||||
|     delete_to: null, | ||||
|  | ||||
|     messages: { | ||||
|       edit_to: null, | ||||
|       reply_to: null, | ||||
|       delete_to: null | ||||
|     } | ||||
|   }) | ||||
|  | ||||
|   const available = ref<any[]>([]) | ||||
|   const current = ref<any>(null) | ||||
|   const messages = ref<any[]>([]) | ||||
|  | ||||
|   const route = useRoute() | ||||
|   watch( | ||||
|     () => route.params.channel, | ||||
|     (val) => { | ||||
|       if (!val) { | ||||
|         messages.value = [] | ||||
|         current.value = null | ||||
|       } | ||||
|     }, | ||||
|     { immediate: true } | ||||
|   ) | ||||
|  | ||||
|   async function list() { | ||||
|     if (!(await checkLoggedIn())) return | ||||
|  | ||||
|     const res = await request("messaging", "/api/channels/me/available", { | ||||
|       headers: { Authorization: `Bearer ${await getAtk()}` } | ||||
|     }) | ||||
|     if (res.status !== 200) { | ||||
|       throw new Error(await res.text()) | ||||
|     } else { | ||||
|       available.value = await res.json() | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   async function connect() { | ||||
|     if (!(await checkLoggedIn())) return | ||||
|  | ||||
|     const uri = buildRequestUrl("messaging", "/api/unified").replace("http", "ws") | ||||
|  | ||||
|     socket = new WebSocket(uri + `?tk=${await getAtk() as string}`) | ||||
|  | ||||
|     socket.addEventListener("open", (event) => { | ||||
|       console.log("[MESSAGING] The unified websocket has been established... ", event.type) | ||||
|     }) | ||||
|     socket.addEventListener("close", (event) => { | ||||
|       console.warn("[MESSAGING] The unified websocket is disconnected... ", event.reason, event.code) | ||||
|     }) | ||||
|     socket.addEventListener("message", (event) => { | ||||
|       const data = JSON.parse(event.data) | ||||
|       const payload = data["p"] | ||||
|       if (payload?.channel_id === current.value.id) { | ||||
|         switch (data["w"]) { | ||||
|           case "messages.new": | ||||
|             messages.value.unshift(payload) | ||||
|             break | ||||
|           case "messages.update": | ||||
|             messages.value = messages.value.map((x) => { | ||||
|               if (x.id === payload.id) return payload | ||||
|               else return x | ||||
|             }) | ||||
|             break | ||||
|           case "messages.burnt": | ||||
|             messages.value = messages.value.filter((x) => { | ||||
|               return x.id !== payload.id | ||||
|             }) | ||||
|             break | ||||
|         } | ||||
|       } | ||||
|     }) | ||||
|   } | ||||
|  | ||||
|   function disconnect() { | ||||
|     socket.close() | ||||
|   } | ||||
|  | ||||
|   return { done, show, related, available, current, messages, list, connect, disconnect } | ||||
| }) | ||||
							
								
								
									
										85
									
								
								src/stores/notifications.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,85 @@ | ||||
| import { defineStore } from "pinia" | ||||
| import { ref } from "vue" | ||||
| import { checkLoggedIn, getAtk } from "@/stores/userinfo" | ||||
| import { buildRequestUrl, request } from "@/scripts/request" | ||||
| import { LocalNotifications } from "@capacitor/local-notifications" | ||||
| import { Capacitor } from "@capacitor/core" | ||||
|  | ||||
| export const useNotifications = defineStore("notifications", () => { | ||||
|   let socket: WebSocket | ||||
|  | ||||
|   const loading = ref(false) | ||||
|  | ||||
|   const notifications = ref<any[]>([]) | ||||
|   const total = ref(0) | ||||
|  | ||||
|   async function list() { | ||||
|     loading.value = true | ||||
|     const res = await request( | ||||
|       "identity", | ||||
|       "/api/notifications?" + | ||||
|       new URLSearchParams({ | ||||
|         take: (25).toString(), | ||||
|         offset: (0).toString() | ||||
|       }), | ||||
|       { | ||||
|         headers: { Authorization: `Bearer ${await getAtk()}` } | ||||
|       } | ||||
|     ) | ||||
|     if (res.status === 200) { | ||||
|       const data = await res.json() | ||||
|       notifications.value = data["data"] | ||||
|       total.value = data["count"] | ||||
|     } | ||||
|     loading.value = false | ||||
|   } | ||||
|  | ||||
|   function remove(idx: number) { | ||||
|     notifications.value.splice(idx, 1) | ||||
|     total.value-- | ||||
|   } | ||||
|  | ||||
|   async function connect() { | ||||
|     if (!(await checkLoggedIn())) return | ||||
|  | ||||
|     const uri = buildRequestUrl("identity", "/api/notifications/listen").replace("http", "ws") | ||||
|  | ||||
|     socket = new WebSocket(uri + `?tk=${await getAtk() as string}`) | ||||
|  | ||||
|     socket.addEventListener("open", (event) => { | ||||
|       console.log("[NOTIFICATIONS] The listen websocket has been established... ", event.type) | ||||
|     }) | ||||
|     socket.addEventListener("close", (event) => { | ||||
|       console.warn("[NOTIFICATIONS] The listen websocket is disconnected... ", event.reason, event.code) | ||||
|     }) | ||||
|     socket.addEventListener("message", (event) => { | ||||
|       const data = JSON.parse(event.data) | ||||
|       notifications.value.push(data) | ||||
|       total.value++ | ||||
|  | ||||
|       if (Capacitor.getPlatform() === "web") { | ||||
|         new Notification(data["subject"], { | ||||
|           body: data["content"] | ||||
|         }) | ||||
|       } else { | ||||
|         LocalNotifications.schedule({ | ||||
|           notifications: [ | ||||
|             { id: data["id"], title: data["subject"], body: data["content"] } | ||||
|           ] | ||||
|         }).then((res) => console.log(res)) | ||||
|       } | ||||
|     }) | ||||
|  | ||||
|     if (Capacitor.getPlatform() === "web") { | ||||
|       await Notification.requestPermission() | ||||
|     } else { | ||||
|       await LocalNotifications.requestPermissions() | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   function disconnect() { | ||||
|     socket.close() | ||||
|   } | ||||
|  | ||||
|   return { loading, notifications, total, list, remove, connect, disconnect } | ||||
| }) | ||||