소스 검색

Add Direct Messages

Daniel Supernault 4 년 전
부모
커밋
d63569c120

+ 22 - 1
app/DirectMessage.php

@@ -14,7 +14,7 @@ class DirectMessage extends Model
 
     public function url()
     {
-    	return url('/i/message/' . $this->to_id . '/' . $this->id);
+    	return config('app.url') . '/account/direct/m/' . $this->status_id;
     }
 
     public function author()
@@ -22,8 +22,29 @@ class DirectMessage extends Model
     	return $this->hasOne(Profile::class, 'id', 'from_id');
     }
 
+    public function recipient()
+    {
+        return $this->hasOne(Profile::class, 'id', 'to_id');
+    }
+
     public function me()
     {
     	return Auth::user()->profile->id === $this->from_id;
     }
+
+    public function toText()
+    {
+        $actorName = $this->author->username;
+
+        return "{$actorName} sent a direct message.";
+    }
+
+    public function toHtml()
+    {
+        $actorName = $this->author->username;
+        $actorUrl = $this->author->url();
+        $url = $this->url();
+
+        return "<a href='{$actorUrl}' class='profile-link'>{$actorName}</a> sent a <a href='{$url}' class='dm-link'>direct message</a>.";
+    }
 }

+ 6 - 7
app/Http/Controllers/AccountController.php

@@ -13,6 +13,7 @@ use Illuminate\Http\Request;
 use PragmaRX\Google2FA\Google2FA;
 use App\Jobs\FollowPipeline\FollowPipeline;
 use App\{
+	DirectMessage,
 	EmailVerification,
 	Follower,
 	FollowRequest,
@@ -114,19 +115,17 @@ class AccountController extends Controller
 		}
 	}
 
-	public function messages()
-	{
-		return view('account.messages');
-	}
-
 	public function direct()
 	{
 		return view('account.direct');
 	}
 
-	public function showMessage(Request $request, $id)
+	public function directMessage(Request $request, $id)
 	{
-		return view('account.message');
+		$profile = Profile::where('id', '!=', $request->user()->profile_id)
+			// ->whereNull('domain')
+			->findOrFail($id);
+		return view('account.directmessage', compact('id'));
 	}
 
 	public function mute(Request $request)

+ 604 - 45
app/Http/Controllers/DirectMessageController.php

@@ -2,57 +2,616 @@
 
 namespace App\Http\Controllers;
 
-use Auth;
+use Auth, Cache;
 use Illuminate\Http\Request;
 use App\{
 	DirectMessage,
+	Media,
+	Notification,
 	Profile,
-	Status
+	Status,
+	User,
+	UserFilter,
+	UserSetting
 };
+use App\Services\MediaPathService;
+use App\Services\MediaBlocklistService;
+use App\Jobs\StatusPipeline\NewStatusPipeline;
+use Illuminate\Support\Str;
+use App\Util\ActivityPub\Helpers;
 
 class DirectMessageController extends Controller
 {
-    public function __construct()
-    {
-    	$this->middleware('auth');
-    }
-
-    public function inbox(Request $request)
-    {
-    	$profile = Auth::user()->profile;
-    	$inbox = DirectMessage::selectRaw('*, max(created_at) as createdAt')
-            ->whereToId($profile->id)
-    		->with(['author','status'])
-            ->orderBy('createdAt', 'desc')
-            ->groupBy('from_id')
-    		->paginate(12);
-    	return view('account.messages', compact('inbox'));
-
-    }
-
-    public function show(Request $request, int $pid, $mid)
-    {
-    	$profile = Auth::user()->profile;
-
-    	if($pid !== $profile->id) { 
-    		abort(403); 
-    	}
-
-    	$msg = DirectMessage::whereToId($profile->id)
-    		->findOrFail($mid);
-
-    	$thread = DirectMessage::whereIn('to_id', [$profile->id, $msg->from_id])
-    		->whereIn('from_id', [$profile->id,$msg->from_id])
-    		->orderBy('created_at', 'desc')
-    		->paginate(30);
-
-        $thread = $thread->reverse();
-
-    	return view('account.message', compact('msg', 'profile', 'thread'));
-    }
-
-    public function compose(Request $request)
-    {
-        $profile = Auth::user()->profile;
-    }
+	public function __construct()
+	{
+		$this->middleware('auth');
+	}
+
+	public function browse(Request $request)
+	{
+		$this->validate($request, [
+			'a' => 'nullable|string|in:inbox,sent,filtered',
+			'page' => 'nullable|integer|min:1|max:99'
+		]);
+
+		$profile = $request->user()->profile_id;
+		$action = $request->input('a', 'inbox');
+		$page = $request->input('page');
+
+		if($action == 'inbox') {
+			$dms = DirectMessage::selectRaw('*, max(created_at) as createdAt')
+			->whereToId($profile)
+			->with(['author','status'])
+			->whereIsHidden(false)
+			->groupBy('from_id')
+			->latest()
+			->when($page, function($q, $page) {
+				if($page > 1) {
+					return $q->offset($page * 8 - 8);
+				}
+			})
+			->limit(8)
+			->get()
+			->map(function($r) use($profile) {
+				return $r->from_id !== $profile ? [
+					'id' => (string) $r->from_id,
+					'name' => $r->author->name,
+					'username' => $r->author->username,
+					'avatar' => $r->author->avatarUrl(),
+					'url' => $r->author->url(),
+					'isLocal' => (bool) !$r->author->domain,
+					'domain' => $r->author->domain,
+					'timeAgo' => $r->created_at->diffForHumans(null, true, true),
+					'lastMessage' => $r->status->caption,
+					'messages' => []
+				] : [
+					'id' => (string) $r->to_id,
+					'name' => $r->recipient->name,
+					'username' => $r->recipient->username,
+					'avatar' => $r->recipient->avatarUrl(),
+					'url' => $r->recipient->url(),
+					'isLocal' => (bool) !$r->recipient->domain,
+					'domain' => $r->recipient->domain,
+					'timeAgo' => $r->created_at->diffForHumans(null, true, true),
+					'lastMessage' => $r->status->caption,
+					'messages' => []
+				];
+			});
+		}
+
+		if($action == 'sent') {
+			$dms = DirectMessage::selectRaw('*, max(created_at) as createdAt')
+			->whereFromId($profile)
+			->with(['author','status'])
+			->groupBy('to_id')
+			->orderBy('createdAt', 'desc')
+			->when($page, function($q, $page) {
+				if($page > 1) {
+					return $q->offset($page * 8 - 8);
+				}
+			})
+			->limit(8)
+			->get()
+			->map(function($r) use($profile) {
+				return $r->from_id !== $profile ? [
+					'id' => (string) $r->from_id,
+					'name' => $r->author->name,
+					'username' => $r->author->username,
+					'avatar' => $r->author->avatarUrl(),
+					'url' => $r->author->url(),
+					'isLocal' => (bool) !$r->author->domain,
+					'domain' => $r->author->domain,
+					'timeAgo' => $r->created_at->diffForHumans(null, true, true),
+					'lastMessage' => $r->status->caption,
+					'messages' => []
+				] : [
+					'id' => (string) $r->to_id,
+					'name' => $r->recipient->name,
+					'username' => $r->recipient->username,
+					'avatar' => $r->recipient->avatarUrl(),
+					'url' => $r->recipient->url(),
+					'isLocal' => (bool) !$r->recipient->domain,
+					'domain' => $r->recipient->domain,
+					'timeAgo' => $r->created_at->diffForHumans(null, true, true),
+					'lastMessage' => $r->status->caption,
+					'messages' => []
+				];
+			});
+		}
+
+		if($action == 'filtered') {
+			$dms = DirectMessage::selectRaw('*, max(created_at) as createdAt')
+			->whereToId($profile)
+			->with(['author','status'])
+			->whereIsHidden(true)
+			->groupBy('from_id')
+			->orderBy('createdAt', 'desc')
+			->when($page, function($q, $page) {
+				if($page > 1) {
+					return $q->offset($page * 8 - 8);
+				}
+			})
+			->limit(8)
+			->get()
+			->map(function($r) use($profile) {
+				return $r->from_id !== $profile ? [
+					'id' => (string) $r->from_id,
+					'name' => $r->author->name,
+					'username' => $r->author->username,
+					'avatar' => $r->author->avatarUrl(),
+					'url' => $r->author->url(),
+					'isLocal' => (bool) !$r->author->domain,
+					'domain' => $r->author->domain,
+					'timeAgo' => $r->created_at->diffForHumans(null, true, true),
+					'lastMessage' => $r->status->caption,
+					'messages' => []
+				] : [
+					'id' => (string) $r->to_id,
+					'name' => $r->recipient->name,
+					'username' => $r->recipient->username,
+					'avatar' => $r->recipient->avatarUrl(),
+					'url' => $r->recipient->url(),
+					'isLocal' => (bool) !$r->recipient->domain,
+					'domain' => $r->recipient->domain,
+					'timeAgo' => $r->created_at->diffForHumans(null, true, true),
+					'lastMessage' => $r->status->caption,
+					'messages' => []
+				];
+			});
+		}
+
+		return response()->json($dms);
+	}
+
+	public function create(Request $request)
+	{
+		$this->validate($request, [
+			'to_id' => 'required',
+			'message' => 'required|string|min:1|max:500',
+			'type'  => 'required|in:text,emoji'
+		]);
+
+		$profile = $request->user()->profile;
+		$recipient = Profile::where('id', '!=', $profile->id)->findOrFail($request->input('to_id'));
+
+		abort_if(in_array($profile->id, $recipient->blockedIds()->toArray()), 403);
+		$msg = $request->input('message');
+
+		if((!$recipient->domain && $recipient->user->settings->public_dm == false) || $recipient->is_private) {
+			if($recipient->follows($profile) == true) {
+				$hidden = false;
+			} else {
+				$hidden = true;
+			}
+		} else {
+			$hidden = false;
+		}
+
+		$status = new Status;
+		$status->profile_id = $profile->id;
+		$status->caption = $msg;
+		$status->rendered = $msg;
+		$status->visibility = 'direct';
+		$status->scope = 'direct';
+		$status->in_reply_to_profile_id = $recipient->id;
+		$status->save();
+
+		$dm = new DirectMessage;
+		$dm->to_id = $recipient->id;
+		$dm->from_id = $profile->id;
+		$dm->status_id = $status->id;
+		$dm->is_hidden = $hidden;
+		$dm->type = $request->input('type');
+		$dm->save();
+
+		if(filter_var($msg, FILTER_VALIDATE_URL)) {
+			if(Helpers::validateUrl($msg)) {
+				$dm->type = 'link';
+				$dm->meta = [
+					'domain' => parse_url($msg, PHP_URL_HOST),
+					'local' => parse_url($msg, PHP_URL_HOST) == 
+					parse_url(config('app.url'), PHP_URL_HOST)
+				];
+				$dm->save();
+			}
+		}
+
+		$nf = UserFilter::whereUserId($recipient->id)
+		->whereFilterableId($profile->id)
+		->whereFilterableType('App\Profile')
+		->whereFilterType('dm.mute')
+		->exists();
+
+		if($recipient->domain == null && $hidden == false && !$nf) {
+			$notification = new Notification();
+			$notification->profile_id = $recipient->id;
+			$notification->actor_id = $profile->id;
+			$notification->action = 'dm';
+			$notification->message = $dm->toText();
+			$notification->rendered = $dm->toHtml();
+			$notification->item_id = $dm->id;
+			$notification->item_type = "App\DirectMessage";
+			$notification->save();
+		}
+
+		if($recipient->domain) {
+			$this->remoteDeliver($dm);
+		}
+
+		$res = [
+			'id' => (string) $dm->id,
+			'isAuthor' => $profile->id == $dm->from_id,
+			'hidden' => (bool) $dm->is_hidden,
+			'type'  => $dm->type,
+			'text' => $dm->status->caption,
+			'media' => null,
+			'timeAgo' => $dm->created_at->diffForHumans(null,null,true),
+			'seen' => $dm->read_at != null,
+			'meta' => $dm->meta
+		];
+
+		return response()->json($res);
+	}
+
+	public function thread(Request $request)
+	{
+		$this->validate($request, [
+			'pid' => 'required'
+		]);
+		$uid = $request->user()->profile_id;
+		$pid = $request->input('pid');
+		$max_id = $request->input('max_id');
+		$min_id = $request->input('min_id');
+		
+		$r = Profile::findOrFail($pid);
+		// $r = Profile::whereNull('domain')->findOrFail($pid);
+
+		if($min_id) {
+			$res = DirectMessage::select('*')
+			->where('id', '>', $min_id)
+			->where(function($q) use($pid,$uid) {
+				return $q->where([['from_id',$pid],['to_id',$uid]
+			])->orWhere([['from_id',$uid],['to_id',$pid]]);
+			})
+			->latest()
+			->take(8)
+			->get();
+		} else if ($max_id) {
+			$res = DirectMessage::select('*')
+			->where('id', '<', $max_id)
+			->where(function($q) use($pid,$uid) {
+				return $q->where([['from_id',$pid],['to_id',$uid]
+			])->orWhere([['from_id',$uid],['to_id',$pid]]);
+			})
+			->latest()
+			->take(8)
+			->get();
+		} else {
+			$res = DirectMessage::where(function($q) use($pid,$uid) {
+				return $q->where([['from_id',$pid],['to_id',$uid]
+			])->orWhere([['from_id',$uid],['to_id',$pid]]);
+			})
+			->latest()
+			->take(8)
+			->get();
+		}
+
+
+		$res = $res->map(function($s) use ($uid){
+			return [
+				'id' => (string) $s->id,
+				'hidden' => (bool) $s->is_hidden,
+				'isAuthor' => $uid == $s->from_id,
+				'type'  => $s->type,
+				'text' => $s->status->caption,
+				'media' => $s->status->firstMedia() ? $s->status->firstMedia()->url() : null,
+				'timeAgo' => $s->created_at->diffForHumans(null,null,true),
+				'seen' => $s->read_at != null,
+				'meta' => json_decode($s->meta,true)
+			];
+		});
+
+		$w = [
+			'id' => (string) $r->id,
+			'name' => $r->name,
+			'username' => $r->username,
+			'avatar' => $r->avatarUrl(),
+			'url' => $r->url(),
+			'muted' => UserFilter::whereUserId($uid)
+				->whereFilterableId($r->id)
+				->whereFilterableType('App\Profile')
+				->whereFilterType('dm.mute')
+				->first() ? true : false,
+			'isLocal' => (bool) !$r->domain,
+			'domain' => $r->domain,
+			'timeAgo' => $r->created_at->diffForHumans(null, true, true),
+			'lastMessage' => '',
+			'messages' => $res
+		];
+
+		return response()->json($w, 200, [], JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES);
+	}
+
+	public function delete(Request $request)
+	{
+		$this->validate($request, [
+			'id' => 'required'
+		]);
+
+		$sid = $request->input('id');
+		$pid = $request->user()->profile_id;
+
+		$dm = DirectMessage::whereFromId($pid)
+		->findOrFail($sid);
+
+		$status = Status::whereProfileId($pid)
+		->findOrFail($dm->status_id);
+
+		if($dm->recipient->domain) {
+			$dmc = $dm;
+			$this->remoteDelete($dmc);
+		}
+
+		$status->delete();
+		$dm->delete();
+
+		return [200];
+	}
+
+	public function get(Request $request, $id)
+	{
+		$pid = $request->user()->profile_id;
+		$dm = DirectMessage::whereStatusId($id)->firstOrFail();
+		abort_if($pid !== $dm->to_id && $pid !== $dm->from_id, 404);
+		return response()->json($dm, 200, [], JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES);
+	}
+
+	public function mediaUpload(Request $request)
+	{
+		$this->validate($request, [
+			'file'      => function() {
+				return [
+					'required',
+					'mimes:' . config('pixelfed.media_types'),
+					'max:' . config('pixelfed.max_photo_size'),
+				];
+			},
+			'to_id'     => 'required'
+		]);
+
+		$user = $request->user();
+		$profile = $user->profile;
+		$recipient = Profile::where('id', '!=', $profile->id)->findOrFail($request->input('to_id'));
+		abort_if(in_array($profile->id, $recipient->blockedIds()->toArray()), 403);
+
+		if((!$recipient->domain && $recipient->user->settings->public_dm == false) || $recipient->is_private) {
+			if($recipient->follows($profile) == true) {
+				$hidden = false;
+			} else {
+				$hidden = true;
+			}
+		} else {
+			$hidden = false;
+		}
+
+		if(config('pixelfed.enforce_account_limit') == true) {
+			$size = Cache::remember($user->storageUsedKey(), now()->addDays(3), function() use($user) {
+				return Media::whereUserId($user->id)->sum('size') / 1000;
+			}); 
+			$limit = (int) config('pixelfed.max_account_size');
+			if ($size >= $limit) {
+				abort(403, 'Account size limit reached.');
+			}
+		}
+		$photo = $request->file('file');
+
+		$mimes = explode(',', config('pixelfed.media_types'));
+		if(in_array($photo->getMimeType(), $mimes) == false) {
+			abort(403, 'Invalid or unsupported mime type.');
+		}
+
+		$storagePath = MediaPathService::get($user, 2) . Str::random(8);
+		$path = $photo->store($storagePath);
+		$hash = \hash_file('sha256', $photo);
+
+		abort_if(MediaBlocklistService::exists($hash) == true, 451);
+
+		$status = new Status;
+		$status->profile_id = $profile->id;
+		$status->caption = null;
+		$status->rendered = null;
+		$status->visibility = 'direct';
+		$status->scope = 'direct';
+		$status->in_reply_to_profile_id = $recipient->id;
+		$status->save();
+
+		$media = new Media();
+		$media->status_id = $status->id;
+		$media->profile_id = $profile->id;
+		$media->user_id = $user->id;
+		$media->media_path = $path;
+		$media->original_sha256 = $hash;
+		$media->size = $photo->getSize();
+		$media->mime = $photo->getMimeType();
+		$media->caption = null;
+		$media->filter_class = null;
+		$media->filter_name = null;
+		$media->save();
+
+		$dm = new DirectMessage;
+		$dm->to_id = $recipient->id;
+		$dm->from_id = $profile->id;
+		$dm->status_id = $status->id;
+		$dm->type = array_first(explode('/', $media->mime)) == 'video' ? 'video' : 'photo';
+		$dm->is_hidden = $hidden;
+		$dm->save();
+
+		if($recipient->domain) {
+			$this->remoteDeliver($dm);
+		}
+
+		return [
+			'type' => $dm->type,
+			'url' => $media->url()
+		];
+	}
+
+	public function composeLookup(Request $request)
+	{
+		$this->validate($request, [
+			'username' => 'required'
+		]);
+		$username = $request->input('username');
+		$profile = Profile::whereUsername($username)->firstOrFail();
+
+		return ['id' => (string)$profile->id];
+	}
+
+	public function read(Request $request)
+	{
+		$this->validate($request, [
+			'pid' => 'required',
+			'sid' => 'required'
+		]);
+
+		$pid = $request->input('pid');
+		$sid = $request->input('sid');
+
+		$dms = DirectMessage::whereToId($request->user()->profile_id)
+		->whereFromId($pid)
+		->where('status_id', '>=', $sid)
+		->get();
+
+		$now = now();
+		foreach($dms as $dm) {
+			$dm->read_at = $now;
+			$dm->save();
+		}
+
+		return response()->json($dms->pluck('id'));
+	}
+
+	public function mute(Request $request)
+	{
+		$this->validate($request, [
+			'id' => 'required'
+		]);
+
+		$fid = $request->input('id');
+		$pid = $request->user()->profile_id;
+
+		UserFilter::firstOrCreate(
+			[
+				'user_id' => $pid,
+				'filterable_id' => $fid,
+				'filterable_type' => 'App\Profile',
+				'filter_type' => 'dm.mute'
+			]
+		);
+		
+		return [200];
+	}
+
+	public function unmute(Request $request)
+	{
+		$this->validate($request, [
+			'id' => 'required'
+		]);
+
+		$fid = $request->input('id');
+		$pid = $request->user()->profile_id;
+
+		$f = UserFilter::whereUserId($pid)
+		->whereFilterableId($fid)
+		->whereFilterableType('App\Profile')
+		->whereFilterType('dm.mute')
+		->firstOrFail();
+
+		$f->delete();
+		return [200];
+	}
+
+	public function remoteDeliver($dm)
+	{
+		$profile = $dm->author;
+		$url = $dm->recipient->inbox_url;
+		$tags = [
+			[
+				'type' => 'Mention',
+				'href' => $dm->recipient->permalink(),
+				'name' => $dm->recipient->emailUrl(),
+			]
+		];
+		$body = [
+			'@context' => [
+				'https://www.w3.org/ns/activitystreams',
+				'https://w3id.org/security/v1',
+				[
+					'sc'                => 'http://schema.org#',
+					'Hashtag'           => 'as:Hashtag',
+					'sensitive'         => 'as:sensitive',
+				]
+			],
+			'id'                    => $dm->status->permalink(),
+			'type'                  => 'Create',
+			'actor'                 => $dm->status->profile->permalink(),
+			'published'             => $dm->status->created_at->toAtomString(),
+			'to'                    => [$dm->recipient->permalink()],
+			'cc'                    => [],
+			'object' => [
+				'id'                => $dm->status->url(),
+				'type'              => 'Note',
+				'summary'           => null,
+				'content'           => $dm->status->rendered ?? $dm->status->caption,
+				'inReplyTo'         => null,
+				'published'         => $dm->status->created_at->toAtomString(),
+				'url'               => $dm->status->url(),
+				'attributedTo'      => $dm->status->profile->permalink(),
+				'to'                => [$dm->recipient->permalink()],
+				'cc'                => [],
+				'sensitive'         => (bool) $dm->status->is_nsfw,
+				'attachment'        => $dm->status->media()->orderBy('order')->get()->map(function ($media) {
+					return [
+						'type'      => $media->activityVerb(),
+						'mediaType' => $media->mime,
+						'url'       => $media->url(),
+						'name'      => $media->caption,
+					];
+				})->toArray(),
+				'tag'               => $tags,
+			]
+		];
+
+		Helpers::sendSignedObject($profile, $url, $body);
+	}
+
+	public function remoteDelete($dm)
+	{
+		$profile = $dm->author;
+		$url = $dm->recipient->inbox_url;
+		
+		$body = [
+			'@context' => [
+				'https://www.w3.org/ns/activitystreams',
+				'https://w3id.org/security/v1',
+				[
+					'sc'				=> 'http://schema.org#',
+					'Hashtag'			=> 'as:Hashtag',
+					'sensitive'			=> 'as:sensitive',
+				]
+			],
+			'id' => $dm->status->permalink('#delete'),
+			'to' => [
+				'https://www.w3.org/ns/activitystreams#Public'
+			],
+			'type' => 'Delete',
+			'actor' => $dm->status->profile->permalink(),
+			'object' => [
+				'id' => $dm->status->url(),
+				'type' => 'Tombstone'
+			]
+		];
+
+		Helpers::sendSignedObject($profile, $url, $body);
+	}
 }

+ 4 - 2
app/Http/Controllers/FederationController.php

@@ -34,7 +34,8 @@ class FederationController extends Controller
     public function nodeinfoWellKnown()
     {
         abort_if(!config('federation.nodeinfo.enabled'), 404);
-        return response()->json(Nodeinfo::wellKnown());
+        return response()->json(Nodeinfo::wellKnown())
+            ->header('Access-Control-Allow-Origin','*');
     }
 
     public function nodeinfo()
@@ -62,7 +63,8 @@ class FederationController extends Controller
         }
         $webfinger = (new Webfinger($profile))->generate();
 
-        return response()->json($webfinger, 200, [], JSON_PRETTY_PRINT);
+        return response()->json($webfinger, 200, [], JSON_PRETTY_PRINT)
+            ->header('Access-Control-Allow-Origin','*');
     }
 
     public function hostMeta(Request $request)

+ 7 - 0
app/Http/Controllers/Settings/PrivacySettings.php

@@ -34,6 +34,7 @@ trait PrivacySettings
         $fields = [
           'is_private',
           'crawlable',
+          'public_dm',
           'show_profile_follower_count',
           'show_profile_following_count',
         ];
@@ -56,6 +57,12 @@ trait PrivacySettings
                 } else {
                     $settings->{$field} = true;
                 }
+             } elseif ($field == 'public_dm') {
+                if ($form == 'on') {
+                    $settings->{$field} = true;
+                } else {
+                    $settings->{$field} = false;
+                }
             } else {
                 if ($form == 'on') {
                     $settings->{$field} = true;

+ 1 - 0
app/Transformer/Api/NotificationTransformer.php

@@ -50,6 +50,7 @@ class NotificationTransformer extends Fractal\TransformerAbstract
 	public function replaceTypeVerb($verb)
 	{
 		$verbs = [
+			'dm'	=> 'direct',
 			'follow' => 'follow',
 			'mention' => 'mention',
 			'reblog' => 'share',

+ 134 - 4
app/Util/ActivityPub/Inbox.php

@@ -19,6 +19,7 @@ use App\Jobs\LikePipeline\LikePipeline;
 use App\Jobs\FollowPipeline\FollowPipeline;
 
 use App\Util\ActivityPub\Validator\Accept as AcceptValidator;
+use App\Util\ActivityPub\Validator\Add as AddValidator;
 use App\Util\ActivityPub\Validator\Announce as AnnounceValidator;
 use App\Util\ActivityPub\Validator\Follow as FollowValidator;
 use App\Util\ActivityPub\Validator\Like as LikeValidator;
@@ -57,6 +58,12 @@ class Inbox
     {
         $verb = (string) $this->payload['type'];
         switch ($verb) {
+
+            case 'Add':
+                if(AddValidator::validate($this->payload) == false) { return; }
+                $this->handleAddActivity();
+                break;
+
             case 'Create':
                 $this->handleCreateActivity();
                 break;
@@ -121,6 +128,11 @@ class Inbox
         return Helpers::profileFetch($actorUrl);
     }
 
+    public function handleAddActivity()
+    {
+        // stories ;)
+    }
+
     public function handleCreateActivity()
     {
         $activity = $this->payload['object'];
@@ -157,6 +169,15 @@ class Inbox
         if(!$actor || $actor->domain == null) {
             return;
         }
+        $to = $activity['to'];
+        $cc = $activity['cc'];
+        if(count($to) == 1 && 
+           count($cc) == 0 && 
+           parse_url($to[0], PHP_URL_HOST) == config('pixelfed.domain.app')
+        ) {
+            $this->handleDirectMessage();
+            return;
+        }
 
         if($actor->followers()->count() == 0) {
             return;
@@ -170,6 +191,103 @@ class Inbox
         return;
     }
 
+    public function handleDirectMessage()
+    {
+        $activity = $this->payload['object'];
+        $actor = $this->actorFirstOrCreate($this->payload['actor']);
+        $profile = Profile::whereNull('domain')
+            ->whereUsername(array_last(explode('/', $activity['to'][0])))
+            ->firstOrFail();
+
+        if(in_array($actor->id, $profile->blockedIds()->toArray())) {
+            return;
+        }
+
+        $msg = $activity['content'];
+        $msgText = strip_tags($activity['content']);
+
+        if($profile->user->settings->public_dm == false || $profile->is_private) {
+            if($profile->follows($actor) == true) {
+                $hidden = false;
+            } else {
+                $hidden = true;
+            }
+        } else {
+            $hidden = false;
+        }
+
+        $status = new Status;
+        $status->profile_id = $actor->id;
+        $status->caption = $msgText;
+        $status->rendered = $msg;
+        $status->visibility = 'direct';
+        $status->scope = 'direct';
+        $status->in_reply_to_profile_id = $profile->id;
+        $status->save();
+
+        $dm = new DirectMessage;
+        $dm->to_id = $profile->id;
+        $dm->from_id = $actor->id;
+        $dm->status_id = $status->id;
+        $dm->is_hidden = $hidden;
+        $dm->type = $request->input('type');
+        $dm->save();
+
+        if(count($activity['attachment'])) {
+            $allowed = explode(',', config('pixelfed.media_types'));
+            foreach($activity['attachment'] as $a) {
+                $type = $a['mediaType'];
+                $url = $a['url'];
+                $valid = self::validateUrl($url);
+                if(in_array($type, $allowed) == false || $valid == false) {
+                    continue;
+                }
+
+                $media = new Media();
+                $media->remote_media = true;
+                $media->status_id = $status->id;
+                $media->profile_id = $status->profile_id;
+                $media->user_id = null;
+                $media->media_path = $url;
+                $media->remote_url = $url;
+                $media->mime = $type;
+                $media->save();
+            }
+        }
+
+        if(filter_var($msg, FILTER_VALIDATE_URL)) {
+            if(Helpers::validateUrl($msg)) {
+                $dm->type = 'link';
+                $dm->meta = [
+                    'domain' => parse_url($msg, PHP_URL_HOST),
+                    'local' => parse_url($msg, PHP_URL_HOST) == 
+                        parse_url(config('app.url'), PHP_URL_HOST)
+                ];
+                $dm->save();
+            }
+        }
+
+        $nf = UserFilter::whereUserId($profile->id)
+            ->whereFilterableId($actor->id)
+            ->whereFilterableType('App\Profile')
+            ->whereFilterType('dm.mute')
+            ->exists();
+
+        if($profile->domain == null && $hidden == false && !$nf) {
+            $notification = new Notification();
+            $notification->profile_id = $profile->id;
+            $notification->actor_id = $actor->id;
+            $notification->action = 'dm';
+            $notification->message = $dm->toText();
+            $notification->rendered = $dm->toHtml();
+            $notification->item_id = $dm->id;
+            $notification->item_type = "App\DirectMessage";
+            $notification->save();
+        }
+
+        return;
+    }
+
     public function handleFollowActivity()
     {
         $actor = $this->actorFirstOrCreate($this->payload['actor']);
@@ -305,7 +423,20 @@ class Inbox
         }
         $actor = $this->payload['actor'];
         $obj = $this->payload['object'];
-        if(is_string($obj) == true) {
+        if(is_string($obj) == true && $actor == $obj && Helpers::validateUrl($obj)) {
+            $profile = Profile::whereRemoteUrl($obj)->first();
+            if(!$profile || $profile->private_key != null) {
+                return;
+            }
+            Notification::whereActorId($profile->id)->delete();
+            $profile->avatar()->delete();
+            $profile->followers()->delete();
+            $profile->following()->delete();
+            $profile->likes()->delete();
+            $profile->media()->delete();
+            $profile->hashtags()->delete();
+            $profile->statuses()->delete();
+            $profile->delete();
             return;
         }
         $type = $this->payload['object']['type'];
@@ -319,9 +450,7 @@ class Inbox
         $id = $this->payload['object']['id'];
         switch ($type) {
             case 'Person':
-                    // todo: fix race condition
-                    return; 
-                    $profile = Helpers::profileFetch($actor);
+                    $profile = Profile::whereRemoteUrl($actor)->first();
                     if(!$profile || $profile->private_key != null) {
                         return;
                     }
@@ -331,6 +460,7 @@ class Inbox
                     $profile->following()->delete();
                     $profile->likes()->delete();
                     $profile->media()->delete();
+                    $profile->hashtags()->delete();
                     $profile->statuses()->delete();
                     $profile->delete();
                 return;

+ 41 - 0
app/Util/ActivityPub/Validator/Add.php

@@ -0,0 +1,41 @@
+<?php
+
+namespace App\Util\ActivityPub\Validator;
+
+use Validator;
+use Illuminate\Validation\Rule;
+
+class Add {
+
+	public static function validate($payload)
+	{
+		$valid = Validator::make($payload, [
+			'@context' => 'required',
+			'id' => 'required|string',
+			'type' => [
+				'required',
+				Rule::in(['Add'])
+			],
+			'actor' => 'required|url',
+			'object' => 'required',
+			'object.id' => 'required|url',
+			'object.type' => [
+				'required',
+				Rule::in(['Story'])
+			],
+			'object.attributedTo' => 'required|url|same:actor',
+			'object.attachment' => 'required',
+			'object.attachment.type' => [
+				'required',
+				Rule::in(['Image'])
+			],
+			'object.attachment.url' => 'required|url',
+			'object.attachment.mediaType' => [
+				'required',
+				Rule::in(['image/jpeg', 'image/png'])
+			]
+		])->passes();
+
+		return $valid;
+	}
+}

+ 11 - 7
app/Util/Lexer/PrettyNumber.php

@@ -4,19 +4,23 @@ namespace App\Util\Lexer;
 
 class PrettyNumber
 {
-    public static function convert($expression)
+    public static function convert($number)
     {
+        if(!is_integer($number)) {
+            return $number;
+        }
+
         $abbrevs = [12 => 'T', 9 => 'B', 6 => 'M', 3 => 'K', 0 => ''];
         foreach ($abbrevs as $exponent => $abbrev) {
-            if ($expression >= pow(10, $exponent)) {
-                $display_num = $expression / pow(10, $exponent);
-                $num = number_format($display_num, 0).$abbrev;
-
-                return $num;
+            if(abs($number) >= pow(10, $exponent)) {
+                $display = $number / pow(10, $exponent);
+                $decimals = ($exponent >= 3 && round($display) < 100) ? 1 : 0;
+                $number = number_format($display, $decimals).$abbrev;
+                break;
             }
         }
 
-        return $expression;
+        return $number;
     }
 
     public static function size($expression, $kb = false)

+ 3 - 0
app/Util/Media/Image.php

@@ -165,6 +165,8 @@ class Image
 
 			$quality = config('pixelfed.image_quality');
 			$img->save($newPath, $quality);
+			$media->width = $img->width();
+			$media->height = $img->height();
 			$img->destroy();
 			if (!$thumbnail) {
 				$media->orientation = $orientation;
@@ -178,6 +180,7 @@ class Image
 				$media->mime = $img->mime;
 			}
 
+
 			$media->save();
 			Cache::forget('status:transformer:media:attachments:'.$media->status_id);
 			Cache::forget('status:thumb:'.$media->status_id);

+ 3 - 1
config/cors.php

@@ -21,7 +21,9 @@ return [
      * You can enable CORS for 1 or multiple paths.
      * Example: ['api/*']
      */
-    'paths' => [],
+    'paths' => [
+        '.well-known/*'
+    ],
 
     /*
     * Matches the request method. `[*]` allows all methods.

+ 8 - 0
resources/assets/js/components/Activity.vue

@@ -52,6 +52,11 @@
 									<a :href="n.account.url" class="font-weight-bold text-dark word-break" :title="n.account.username">{{truncate(n.account.username)}}</a> tagged you in a <a class="font-weight-bold" v-bind:href="n.tagged.post_url">post</a>.
 								</p>
 							</div>
+							<div v-else-if="n.type == 'direct'">
+							<p class="my-0">
+								<a :href="n.account.url" class="font-weight-bold text-dark word-break" :title="n.account.username">{{truncate(n.account.username)}}</a> sent a <a class="font-weight-bold" v-bind:href="'/account/direct/t/'+n.account.id">dm</a>.
+							</p>
+						</div>
 							<div class="align-items-center">
 								<span class="small text-muted" data-toggle="tooltip" data-placement="bottom" :title="n.created_at">{{timeAgo(n.created_at)}}</span>
 							</div>
@@ -249,6 +254,9 @@ export default {
 				case 'tagged':
 					return n.tagged.post_url;
 				break;
+				case 'direct':
+					return '/account/direct/t/'+n.account.id;
+				break
 			}
 			return '/';
 		},

+ 9 - 5
resources/assets/js/components/ComposeModal.vue

@@ -237,7 +237,7 @@
 								<div class="media-body">
 									<div class="form-group">
 										<label class="font-weight-bold text-muted small d-none">Caption</label>
-										<textarea class="form-control border-0 rounded-0 no-focus" rows="2" placeholder="Write a caption..." style="resize:none" v-model="composeText" v-on:keyup="composeTextLength = composeText.length"></textarea>
+										<textarea class="form-control border-0 rounded-0 no-focus" rows="3" placeholder="Write a caption..." style="" v-model="composeText" v-on:keyup="composeTextLength = composeText.length"></textarea>
 										<p class="help-text small text-right text-muted mb-0">{{composeTextLength}}/{{config.uploader.max_caption_length}}</p>
 									</div>
 								</div>
@@ -271,7 +271,7 @@
 							<p class="px-4 mb-0 py-2">
 								<span>Audience</span>
 								<span class="float-right">
-									<a v-if="profile.locked == false" href="#" @click.prevent="showVisibilityCard()" class="btn btn-outline-secondary btn-sm small mr-3 mt-n1 disabled" style="font-size:10px;padding:3px;text-transform: uppercase" disabled>{{visibilityTag}}</a>
+									<a href="#" @click.prevent="showVisibilityCard()" class="btn btn-outline-secondary btn-sm small mr-3 mt-n1 disabled" style="font-size:10px;padding:3px;text-transform: uppercase" disabled>{{visibilityTag}}</a>
 									<a href="#" @click.prevent="showVisibilityCard()" class="text-decoration-none"><i class="fas fa-chevron-right fa-lg text-lighter"></i></a>
 								</span>
 							</p>
@@ -632,12 +632,13 @@ export default {
 
 	methods: {
 		fetchProfile() {
+			let self = this;
 			axios.get('/api/pixelfed/v1/accounts/verify_credentials').then(res => {
-				this.profile = res.data;
+				self.profile = res.data;
 				window.pixelfed.currentUser = res.data;
 				if(res.data.locked == true) {
-					this.visibility = 'private';
-					this.visibilityTag = 'Followers Only';
+					self.visibility = 'private';
+					self.visibilityTag = 'Followers Only';
 				}
 			}).catch(err => {
 			});
@@ -663,6 +664,9 @@ export default {
 			let self = this;
 			self.uploading = true;
 			let io = document.querySelector('#pf-dz');
+			if(!io.files.length) {
+				self.uploading = false;
+			}
 			Array.prototype.forEach.call(io.files, function(io, i) {
 				if(self.media && self.media.length + i >= self.config.uploader.album_limit) {
 					swal('Error', 'You can only upload ' + self.config.uploader.album_limit + ' photos per album', 'error');

+ 400 - 0
resources/assets/js/components/Direct.vue

@@ -0,0 +1,400 @@
+<template>
+<div>
+	<div v-if="loaded && page == 'browse'" class="container messages-page p-0 p-md-2 mt-n4" style="min-height: 50vh;">
+		<div class="col-12 col-md-8 offset-md-2 p-0 px-md-2">
+			<div class="card shadow-none border mt-4">
+				<div class="card-header bg-white py-4">
+					<span class="h4 font-weight-bold mb-0">Direct Messages</span>
+					<span class="float-right">
+						<a class="btn btn-outline-primary font-weight-bold py-0" href="#" @click.prevent="goto('add')">New Message</a>
+					</span>
+				</div>
+				<div class="card-header bg-white">
+					<ul class="nav nav-pills nav-fill">
+						<li class="nav-item">
+							<a :class="[tab == 'inbox' ? 'nav-link px-4 font-weight-bold rounded-pill active' : 'nav-link px-4 font-weight-bold rounded-pill']" @click.prevent="switchTab('inbox')" href="#">Inbox</a>
+						</li>
+						<li class="nav-item">
+							<a :class="[tab == 'sent' ? 'nav-link px-4 font-weight-bold rounded-pill active' : 'nav-link px-4 font-weight-bold rounded-pill']" @click.prevent="switchTab('sent')" href="#">Sent</a>
+						</li>
+						<li class="nav-item">
+							<a :class="[tab == 'filtered' ? 'nav-link px-4 font-weight-bold rounded-pill active' : 'nav-link px-4 font-weight-bold rounded-pill']" @click.prevent="switchTab('filtered')" href="#">Filtered</a>
+						</li>
+					</ul>
+				</div>
+				<ul v-if="tab == 'inbox'" class="list-group list-group-flush">
+					<div v-if="!messages.inbox.length" class="list-group-item d-flex justify-content-center align-items-center" style="min-height: 40vh;">
+						<p class="lead mb-0">No messages found :(</p>
+					</div>
+					<div v-else v-for="(thread, index) in messages.inbox">
+						<a class="list-group-item text-dark text-decoration-none border-left-0 border-right-0 border-top-0" :href="'/account/direct/t/'+thread.id">
+						<div class="media d-flex align-items-center">
+							<img class="mr-3 rounded-circle img-thumbnail" :src="thread.avatar" alt="Generic placeholder image" width="32px">
+							<div class="media-body">
+								<p class="mb-0">
+									<span class="font-weight-bold text-truncate" :title="[thread.isLocal ? '@' + thread.username : thread.username]" data-toggle="tooltip" data-placement="bottom">
+										{{thread.name}}
+									</span>
+								</p>
+								<p class="text-muted mb-0" style="font-size:13px;font-weight: 500;">
+									<span>
+										<i class="far fa-comment text-primary"></i> 
+									</span>
+									<span class="pl-1 pr-3">
+										Received
+									</span>
+									<span>
+										{{thread.timeAgo}}
+									</span>
+								</p>
+							</div>
+							<span class="float-right">
+								<i class="fas fa-chevron-right fa-lg text-lighter"></i>
+							</span>
+						</div>
+						</a>
+					</div>
+				</ul>
+				<ul v-if="tab == 'sent'" class="list-group list-group-flush">
+					<div v-if="!messages.sent.length" class="list-group-item d-flex justify-content-center align-items-center" style="min-height: 40vh;">
+						<p class="lead mb-0">No messages found :(</p>
+					</div>
+					<div v-else v-for="(thread, index) in messages.sent">
+						<a class="list-group-item text-dark text-decoration-none border-left-0 border-right-0 border-top-0" href="#" @click.prevent="loadMessage(thread.id)">
+						<div class="media d-flex align-items-center">
+							<img class="mr-3 rounded-circle img-thumbnail" :src="thread.avatar" alt="Generic placeholder image" width="32px">
+							<div class="media-body">
+								<p class="mb-0">
+									<span class="font-weight-bold text-truncate" :title="[thread.isLocal ? '@' + thread.username : thread.username]" data-toggle="tooltip" data-placement="bottom">
+										{{thread.name}}
+									</span>
+								</p>
+								<p class="text-muted mb-0" style="font-size:13px;font-weight: 500;">
+									<span>
+										<i class="far fa-paper-plane text-primary"></i> 
+									</span>
+									<span class="pl-1 pr-3">
+										Delivered
+									</span>
+									<span>
+										{{thread.timeAgo}}
+									</span>
+								</p>
+							</div>
+							<span class="float-right">
+								<i class="fas fa-chevron-right fa-lg text-lighter"></i>
+							</span>
+						</div>
+						</a>
+					</div>
+				</ul>
+				<ul v-if="tab == 'filtered'" class="list-group list-group-flush">
+					<div v-if="!messages.filtered.length" class="list-group-item d-flex justify-content-center align-items-center" style="min-height: 40vh;">
+						<p class="lead mb-0">No messages found :(</p>
+					</div>
+					<div v-else v-for="(thread, index) in messages.filtered">
+						<a class="list-group-item text-dark text-decoration-none border-left-0 border-right-0 border-top-0" href="#" @click.prevent="loadMessage(thread.id)">
+						<div class="media d-flex align-items-center">
+							<img class="mr-3 rounded-circle img-thumbnail" :src="thread.avatar" alt="Generic placeholder image" width="32px">
+							<div class="media-body">
+								<p class="mb-0">
+									<span class="font-weight-bold text-truncate" :title="[thread.isLocal ? '@' + thread.username : thread.username]" data-toggle="tooltip" data-placement="bottom">
+										{{thread.name}}
+									</span>
+								</p>
+								<p class="text-muted mb-0" style="font-size:13px;font-weight: 500;">
+									<span>
+										<i class="fas fa-shield-alt" style="color:#fd9426"></i> 
+									</span>
+									<span class="pl-1 pr-3">
+										Filtered
+									</span>
+									<span>
+										{{thread.timeAgo}}
+									</span>
+								</p>
+							</div>
+							<span class="float-right">
+								<i class="fas fa-chevron-right fa-lg text-lighter"></i>
+							</span>
+						</div>
+						</a>
+					</div>
+				</ul>
+			</div>
+			<div v-if="tab == 'inbox'" class="mt-3 text-center">
+				<button class="btn btn-outline-primary rounded-pill btn-sm" :disabled="inboxPage == 1" @click="messagePagination('inbox', 'prev')">Prev</button>
+				<button class="btn btn-outline-primary rounded-pill btn-sm" :disabled="messages.inbox.length != 8" @click="messagePagination('inbox', 'next')">Next</button>
+			</div>
+			<div v-if="tab == 'sent'" class="mt-3 text-center">
+				<button class="btn btn-outline-primary rounded-pill btn-sm" :disabled="sentPage == 1" @click="messagePagination('sent', 'prev')">Prev</button>
+				<button class="btn btn-outline-primary rounded-pill btn-sm" :disabled="messages.sent.length != 8" @click="messagePagination('sent', 'next')">Next</button>
+			</div>
+			<div v-if="tab == 'filtered'" class="mt-3 text-center">
+				<button class="btn btn-outline-primary rounded-pill btn-sm" :disabled="filteredPage == 1" @click="messagePagination('filtered', 'prev')">Prev</button>
+				<button class="btn btn-outline-primary rounded-pill btn-sm" :disabled="messages.filtered.length != 8" @click="messagePagination('filtered', 'next')">Next</button>
+			</div>
+		</div>
+	</div>
+
+	<div v-if="loaded && page == 'add'" class="container messages-page p-0 p-md-2 mt-n4" style="min-height: 60vh;">
+		<div class="col-12 col-md-8 offset-md-2 p-0 px-md-2">
+			<div class="card shadow-none border mt-4">
+				<div class="card-header bg-white py-4 d-flex justify-content-between">
+					<span class="cursor-pointer px-3" @click="goto('browse')"><i class="fas fa-chevron-left"></i></span>
+					<span class="h4 font-weight-bold mb-0">New Direct Message</span>
+					<span><i class="fas fa-chevron-right text-white"></i></span>
+				</div>
+				<div class="card-body d-flex align-items-center justify-content-center" style="height: 60vh;">
+					<div class="">
+						<p class="form-group">
+							<label>To:</label>
+							<!-- <div class="input-group pt-0">
+								<div class="input-group-prepend">
+									<span class="input-group-text" id="basic-addon1">@</span>
+								</div>
+								<input v-model="composeUsername" type="text" class="form-control" placeholder="dansup">
+							</div> -->
+							<autocomplete 
+							v-show="true"
+							:search="composeSearch"
+							placeholder="@dansup"
+							aria-label="Search usernames"
+							:get-result-value="getTagResultValue"
+							@submit="onTagSubmitLocation"
+							ref="autocomplete"
+						>
+						</autocomplete>
+							<span class="help-text small text-muted">Select a username to send a message to.</span>
+						</p>
+						<hr>
+						<!-- <p>
+							<button type="button" class="btn btn-primary font-weight-bold btn-block" @click="composeUsernameSelect()" :disabled="!composeUsername.length">Next</button>
+						</p> -->
+						<ul class="text-muted">
+							<li>You cannot message remote accounts yet.</li>
+						</ul>
+					</div>
+				</div>
+			</div>
+		</div>
+	</div>
+</div>
+</template>
+
+<style type="text/css" scoped>
+</style>
+
+<script type="text/javascript">
+export default {
+	data() {
+		return {
+			config: window.App.config,
+			loaded: false,
+			profile: {},
+			page: 'browse',
+			pages: ['browse', 'add', 'read'],
+			tab: 'inbox',
+			tabs: ['inbox', 'sent', 'filtered'],
+			inboxPage: 1,
+			sentPage: 1,
+			filteredPage: 1,
+			threads: [],
+			thread: false,
+			threadIndex: false,
+
+			replyText: '',
+			composeUsername: '',
+
+			ctxContext: null,
+			ctxIndex: null,
+
+			uploading: false,
+			uploadProgress: null,
+
+			messages: {
+				inbox: [],
+				sent: [],
+				filtered: []
+			}
+		}
+	},
+
+	mounted() {
+		this.fetchProfile();
+		let self = this;
+		axios.get('/api/pixelfed/v1/direct/browse', {
+			params: {
+				a: 'inbox'
+			}
+		})
+		.then(res => {
+			self.loaded = true;
+			this.threads = res.data
+			this.messages.inbox = res.data;
+		});
+	},
+
+	updated() {
+		$('[data-toggle="tooltip"]').tooltip();
+	},
+
+	methods: {
+		fetchProfile() {
+			axios.get('/api/pixelfed/v1/accounts/verify_credentials').then(res => {
+				this.profile = res.data;
+				window._sharedData.curUser = res.data;
+			});
+		},
+		goto(l = 'browse') {
+			this.page = l;
+			let url = '/account/direct';
+			switch(l) {
+				case 'read':
+				url = '/account/direct/t/' + this.thread.id;
+				break;
+				case 'add':
+					url += '#/new';
+				break;
+			}
+			window.history.pushState({},'',url);
+		},
+
+		loadMessage(id) {
+			let url = '/account/direct/t/' + id;
+			window.location.href = url;
+			return;
+		},
+
+		composeUsernameSelect() {
+			if(this.profile.username == this.composeUsername) {
+				swal('Ooops!', 'You cannot send a direct message to yourself.', 'error');
+				this.composeUsername = '';
+				return;
+			}
+			axios.post('/api/direct/lookup', {
+				username: this.composeUsername
+			}).then(res => {
+				let url = '/account/direct/t/' + res.data.id;
+				window.location.href = url;
+			}).catch(err => {
+				let msg = 'The username you entered is incorrect. Please try again';
+				swal('Ooops!', msg, 'error');
+				this.composeUsername = '';
+			});
+		},
+
+		truncate(t) {
+			return _.truncate(t);
+		},
+
+		switchTab(tab) {
+			let self = this;
+			switch(tab) {
+				case 'inbox':
+					if(this.messages.inbox.length == 0) {
+						// fetch
+					}
+				break;
+				case 'sent':
+				if(this.messages.sent.length == 0) {
+					axios.get('/api/pixelfed/v1/direct/browse', {
+						params: {
+							a: 'sent'
+						}
+					})
+					.then(res => {
+						self.loaded = true;
+						self.threads = res.data
+						self.messages.sent = res.data;
+					});
+				}
+				break;
+				case 'filtered':
+					if(this.messages.filtered.length == 0) {
+						axios.get('/api/pixelfed/v1/direct/browse', {
+							params: {
+								a: 'filtered'
+							}
+						})
+						.then(res => {
+							self.loaded = true;
+							self.threads = res.data
+							self.messages.filtered = res.data;
+						});
+					}
+				break;
+			}
+			this.tab = tab;
+		},
+
+		composeSearch(input) {
+			if (input.length < 1) { return []; };
+			let self = this;
+			let results = [];
+			return axios.get('/api/local/compose/tag/search', {
+				params: {
+					q: input
+				}
+			}).then(res => {
+				return res.data;
+			});
+		},
+
+		getTagResultValue(result) {
+			return '@' + result.name;
+		},
+		
+		onTagSubmitLocation(result) {
+			//this.$refs.autocomplete.value = '';
+			window.location.href = '/account/direct/t/' + result.id;
+			return;
+		},
+
+		messagePagination(tab, dir) {
+			if(tab == 'inbox') {
+				this.inboxPage = dir == 'prev' ? this.inboxPage - 1 : this.inboxPage + 1;
+				axios.get('/api/pixelfed/v1/direct/browse', {
+					params: {
+						a: 'inbox',
+						page: this.inboxPage
+					}
+				})
+				.then(res => {
+					self.loaded = true;
+					this.threads = res.data
+					this.messages.inbox = res.data;
+				});
+			}
+			if(tab == 'sent') {
+				this.sentPage = dir == 'prev' ? this.sentPage - 1 : this.sentPage + 1;
+				axios.get('/api/pixelfed/v1/direct/browse', {
+					params: {
+						a: 'sent',
+						page: this.sentPage
+					}
+				})
+				.then(res => {
+					self.loaded = true;
+					this.threads = res.data
+					this.messages.sent = res.data;
+				});
+			}
+			if(tab == 'filtered') {
+				this.filteredPage = dir == 'prev' ? this.filteredPage - 1 : this.filteredPage + 1;
+				axios.get('/api/pixelfed/v1/direct/browse', {
+					params: {
+						a: 'filtered',
+						page: this.filteredPage
+					}
+				})
+				.then(res => {
+					self.loaded = true;
+					this.threads = res.data
+					this.messages.filtered = res.data;
+				});
+			}
+		}
+	}
+}
+</script>

+ 648 - 0
resources/assets/js/components/DirectMessage.vue

@@ -0,0 +1,648 @@
+<template>
+<div>
+	<div v-if="loaded && page == 'read'" class="container messages-page p-0 p-md-2 mt-n4" style="min-height: 60vh;">
+		<div class="col-12 col-md-8 offset-md-2 p-0 px-md-2">
+			<div class="card shadow-none border mt-4">
+				<div class="card-header bg-white d-flex justify-content-between align-items-center">
+					<span>
+						<a href="/account/direct" class="text-muted">
+							<i class="fas fa-chevron-left fa-lg"></i>
+						</a>
+					</span>
+					<span>
+						<div class="media">
+							<img class="mr-3 rounded-circle img-thumbnail" :src="thread.avatar" alt="Generic placeholder image" width="40px">
+							<div class="media-body">
+								<p class="mb-0">
+									<span class="font-weight-bold">{{thread.name}}</span>
+								</p>
+								<p class="mb-0">
+									<a v-if="!thread.isLocal" :href="'/'+thread.username" class="text-decoration-none text-muted">{{thread.username}}</a>
+									<a v-else :href="'/'+thread.username" class="text-decoration-none text-muted">&commat;{{thread.username}}</a>
+								</p>
+							</div>
+						</div>   
+					</span>
+					<span><a href="#" class="text-muted" @click.prevent="showOptions()"><i class="fas fa-cog fa-lg"></i></a></span>
+				</div>
+				<ul class="list-group list-group-flush dm-wrapper" style="height:60vh;overflow-y: scroll;">
+					<li class="list-group-item border-0">
+						<p class="text-center small text-muted">
+							Conversation with <span class="font-weight-bold">{{thread.username}}</span>
+						</p>
+						<hr>
+					</li>
+					<li v-if="showLoadMore && thread.messages && thread.messages.length > 5" class="list-group-item border-0 mt-n4">
+						<p class="text-center small text-muted">
+							<button v-if="!loadingMessages" class="btn btn-primary font-weight-bold rounded-pill btn-sm px-3" @click="loadOlderMessages()">Load Older Messages</button>
+							<button v-else class="btn btn-primary font-weight-bold rounded-pill btn-sm px-3" disabled>Loading...</button>
+						</p>
+					</li> 
+					<li v-for="(convo, index) in thread.messages" class="list-group-item border-0 chat-msg cursor-pointer" @click="openCtxMenu(convo, index)">
+						<div v-if="!convo.isAuthor" class="media d-inline-flex mb-0">
+							<img v-if="!hideAvatars" class="mr-3 mt-2 rounded-circle img-thumbnail" :src="thread.avatar" alt="avatar" width="32px">
+							<div class="media-body">
+								<p v-if="convo.type == 'photo'" class="pill-to p-0 shadow">
+									<img :src="convo.media" width="140px" style="border-radius:20px;">
+								</p>
+								<div v-else-if="convo.type == 'link'" class="media d-inline-flex mb-0 cursor-pointer">
+									<div class="media-body">
+										<div class="card mb-2 rounded border shadow" style="width:240px;" :title="convo.text">
+											<div class="card-body p-0">
+												<div class="media d-flex align-items-center">
+													<div v-if="convo.meta.local" class="bg-primary mr-3 border-right p-3">
+														<i class="fas fa-link text-white fa-2x"></i>
+													</div>
+													<div v-else class="bg-light mr-3 border-right p-3">
+														<i class="fas fa-link text-lighter fa-2x"></i>
+													</div>
+													<div class="media-body text-muted small text-truncate pr-2 font-weight-bold">
+														{{convo.meta.local ? convo.text.substr(8) : convo.meta.domain}}
+													</div>
+												</div>
+											</div>
+										</div>
+									</div>
+								</div>
+								<p v-else-if="convo.type == 'video'" class="pill-to p-0 shadow">
+									<!-- <video :src="convo.media" width="140px" style="border-radius:20px;"></video> -->
+									<span class="d-block bg-primary d-flex align-items-center justify-content-center" style="width:200px;height: 110px;border-radius: 20px;">
+										<div class="text-center">
+											<p class="mb-1">
+												<i class="fas fa-play fa-2x text-white"></i>
+											</p>
+											<p class="mb-0 small font-weight-bold text-white">
+												Play
+											</p>
+										</div>
+									</span>
+								</p>
+								<p v-else-if="convo.type == 'emoji'" class="p-0 emoji-msg">
+									{{convo.text}}
+								</p>
+								<p v-else :class="[largerText ? 'pill-to shadow larger-text text-break':'pill-to shadow text-break']">
+									{{convo.text}}
+								</p>
+								<p v-if="!hideTimestamps" class="small text-muted font-weight-bold ml-2 d-flex align-items-center justify-content-start" data-timestamp="timestamp"> <span v-if="convo.hidden" class="mr-2 small" title="Filtered Message" data-toggle="tooltip" data-placement="bottom"><i class="fas fa-lock"></i></span> {{convo.timeAgo}}</p>
+							</div>
+						</div>
+						<div v-else class="media d-inline-flex float-right mb-0">
+							<div class="media-body">
+								<p v-if="convo.type == 'photo'" class="pill-from p-0 shadow">
+									<img :src="convo.media" width="140px" style="border-radius:20px;">
+								</p>
+								<div v-else-if="convo.type == 'link'" class="media d-inline-flex float-right mb-0 cursor-pointer">
+									<div class="media-body">
+										<div class="card mb-2 rounded border shadow" style="width:240px;" :title="convo.text">
+											<div class="card-body p-0">
+												<div class="media d-flex align-items-center">
+													<div v-if="convo.meta.local" class="bg-primary mr-3 border-right p-3">
+														<i class="fas fa-link text-white fa-2x"></i>
+													</div>
+													<div v-else class="bg-light mr-3 border-right p-3">
+														<i class="fas fa-link text-lighter fa-2x"></i>
+													</div>
+													<div class="media-body text-muted small text-truncate pr-2 font-weight-bold">
+														{{convo.meta.local ? convo.text.substr(8) : convo.meta.domain}}
+													</div>
+												</div>
+											</div>
+										</div>
+									</div>
+								</div>
+								<p v-else-if="convo.type == 'video'" class="pill-from p-0 shadow">
+									<!-- <video :src="convo.media" width="140px" style="border-radius:20px;"></video> -->
+									<span class="rounded-pill bg-primary d-flex align-items-center justify-content-center" style="width:200px;height: 110px">
+										<div class="text-center">
+											<p class="mb-1">
+												<i class="fas fa-play fa-2x text-white"></i>
+											</p>
+											<p class="mb-0 small font-weight-bold">
+												Play
+											</p>
+										</div>
+									</span>
+								</p>
+								<p v-else-if="convo.type == 'emoji'" class="p-0 emoji-msg">
+									{{convo.text}}
+								</p>
+								<p v-else :class="[largerText ? 'pill-from shadow larger-text text-break':'pill-from shadow text-break']">
+									{{convo.text}}
+								</p>
+								<p v-if="!hideTimestamps" class="small text-muted font-weight-bold text-right mr-2"> <span v-if="convo.hidden" class="mr-2 small" title="Filtered Message" data-toggle="tooltip" data-placement="bottom"><i class="fas fa-lock"></i></span> {{convo.timeAgo}}
+								</p>
+							</div>
+							<img v-if="!hideAvatars" class="ml-3 mt-2 rounded-circle img-thumbnail" :src="profile.avatar" alt="avatar" width="32px">
+						</div>
+					</li>
+
+				</ul>
+				<div class="card-footer bg-white p-0">
+					<form class="border-0 rounded-0 align-middle" method="post" action="#">
+						<textarea class="form-control border-0 rounded-0 no-focus" name="comment" placeholder="Reply ..." autocomplete="off" autocorrect="off" style="height:86px;line-height: 18px;max-height:80px;resize: none; padding-right:115.22px;" v-model="replyText" :disabled="blocked"></textarea>
+						<input type="button" value="Send" :class="[replyText.length ? 'd-inline-block btn btn-sm btn-primary rounded-pill font-weight-bold reply-btn text-decoration-none text-uppercase' : 'd-inline-block btn btn-sm btn-primary rounded-pill font-weight-bold reply-btn text-decoration-none text-uppercase disabled']" :disabled="replyText.length == 0" @click.prevent="sendMessage"/>
+					</form>
+				</div>
+				<div class="card-footer p-0">
+					<p class="d-flex justify-content-between align-items-center mb-0 px-3 py-1 small">
+						<!-- <span class="font-weight-bold" style="color: #D69E2E">
+						<i class="fas fa-circle mr-1"></i>
+						Typing ...
+						</span> -->
+						<span>
+							<!-- <span class="btn btn-primary btn-sm font-weight-bold py-0 px-3 rounded-pill" @click="uploadMedia">
+							<i class="fas fa-share mr-1"></i>
+							Share
+							</span> -->
+							<span class="btn btn-primary btn-sm font-weight-bold py-0 px-3 rounded-pill" @click="uploadMedia">
+								<i class="fas fa-upload mr-1"></i>
+								Add Photo/Video
+							</span>
+						</span>
+						<input type="file" id="uploadMedia" class="d-none" name="uploadMedia" accept="image/jpeg,image/png,image/gif,video/mp4" >
+						<span class="text-muted font-weight-bold">{{replyText.length}}/600</span>
+					</p>
+				</div>
+			</div>
+		</div>
+	</div>
+	<div v-if="loaded && page == 'options'" class="container messages-page p-0 p-md-2 mt-n4" style="min-height: 60vh;">
+		<div class="col-12 col-md-8 offset-md-2 p-0 px-md-2">
+			<div class="card shadow-none border mt-4">
+				<div class="card-header bg-white d-flex justify-content-between align-items-center">
+					<span>
+						<a href="#" class="text-muted" @click.prevent="page='read'">
+							<i class="fas fa-chevron-left fa-lg"></i>
+						</a>
+					</span>
+					<span>
+						<p class="mb-0 lead font-weight-bold py-2">Message Settings</p>
+					</span>
+					<span class="text-lighter" data-toggle="tooltip" data-placement="bottom" title="Have a nice day!"><i class="far fa-smile fa-lg"></i></span>
+				</div>
+				<ul class="list-group list-group-flush dm-wrapper" style="height: 698px;">
+					<div class="list-group-item media border-bottom">
+						<div class="d-inline-block custom-control custom-switch ml-3">
+							<input type="checkbox" class="custom-control-input" id="customSwitch0" v-model="hideAvatars">
+							<label class="custom-control-label" for="customSwitch0"></label>
+						</div>
+						<div class="d-inline-block ml-3 font-weight-bold">
+							Hide Avatars
+						</div>
+					</div>
+					<div class="list-group-item media border-bottom">
+						<div class="d-inline-block custom-control custom-switch ml-3">
+							<input type="checkbox" class="custom-control-input" id="customSwitch1" v-model="hideTimestamps">
+							<label class="custom-control-label" for="customSwitch1"></label>
+						</div>
+						<div class="d-inline-block ml-3 font-weight-bold">
+							Hide Timestamps
+						</div>
+					</div>
+					<div class="list-group-item media border-bottom">
+						<div class="d-inline-block custom-control custom-switch ml-3">
+							<input type="checkbox" class="custom-control-input" id="customSwitch2" v-model="largerText">
+							<label class="custom-control-label" for="customSwitch2"></label>
+						</div>
+						<div class="d-inline-block ml-3 font-weight-bold">
+							Larger Text
+						</div>
+					</div>
+					<!-- <div class="list-group-item media border-bottom">
+					<div class="d-inline-block custom-control custom-switch ml-3">
+					<input type="checkbox" class="custom-control-input" id="customSwitch3" v-model="autoRefresh">
+					<label class="custom-control-label" for="customSwitch3"></label>
+					</div>
+					<div class="d-inline-block ml-3 font-weight-bold">
+					Auto Refresh
+					</div>
+					</div> -->
+					<div class="list-group-item media border-bottom d-flex align-items-center">
+						<div class="d-inline-block custom-control custom-switch ml-3">
+							<input type="checkbox" class="custom-control-input" id="customSwitch4" v-model="mutedNotifications">
+							<label class="custom-control-label" for="customSwitch4"></label>
+						</div>
+						<div class="d-inline-block ml-3 font-weight-bold">
+							Mute Notifications 
+							<p class="small mb-0">You will not receive any direct message notifications from <strong>{{thread.username}}</strong>.</p>
+						</div>
+					</div>
+				</ul>
+			</div>
+		</div>
+	</div>
+	<b-modal ref="ctxModal"
+	id="ctx-modal"
+	hide-header
+	hide-footer
+	centered
+	rounded
+	size="sm"
+	body-class="list-group-flush p-0 rounded">
+	<div class="list-group text-center">
+		<div v-if="ctxContext && ctxContext.type == 'photo'" class="list-group-item rounded cursor-pointer font-weight-bold text-dark" @click="viewOriginal()">View Original</div>
+		<div v-if="ctxContext && ctxContext.type == 'video'" class="list-group-item rounded cursor-pointer font-weight-bold text-dark" @click="viewOriginal()">Play</div>
+		<div v-if="ctxContext && ctxContext.type == 'link'" class="list-group-item rounded cursor-pointer" @click="clickLink()">
+			<p class="mb-0" style="font-size:12px;">
+				Navigate to 
+			</p>
+			<p class="mb-0 font-weight-bold text-dark">
+				{{this.ctxContext.meta.domain}}
+			</p>
+		</div>
+		<div v-if="ctxContext && (ctxContext.type == 'text' || ctxContext.type == 'emoji' || ctxContext.type == 'link')" class="list-group-item rounded cursor-pointer text-dark" @click="copyText()">Copy</div>
+		<div v-if="ctxContext && !ctxContext.isAuthor" class="list-group-item rounded cursor-pointer text-muted" @click="reportMessage()">Report</div>
+		<div v-if="ctxContext && ctxContext.isAuthor" class="list-group-item rounded cursor-pointer text-muted" @click="deleteMessage()">Delete</div>
+		<div class="list-group-item rounded cursor-pointer text-lighter" @click="closeCtxMenu()">Cancel</div>
+	</div>
+	</b-modal>
+</div>
+</template>
+
+<style type="text/css" scoped>
+.reply-btn {
+	position: absolute;
+	bottom: 54px;
+	right: 20px;
+	width: 90px;
+	text-align: center;
+	border-radius: 0 3px 3px 0;
+}
+.media-body .bg-primary {
+	background: linear-gradient(135deg, #2EA2F4 0%, #0B93F6 100%) !important;
+}
+.pill-to {
+	background:#EDF2F7;
+	font-weight: 500;
+	border-radius: 20px !important;
+	padding-left: 1rem;
+	padding-right: 1rem;
+	padding-top: 0.5rem;
+	padding-bottom: 0.5rem;
+	margin-right: 3rem;
+	margin-bottom: 0.25rem;
+}
+.pill-from {
+	color: white !important;
+	text-align: right !important;
+	/*background: #53d769;*/
+	background: linear-gradient(135deg, #2EA2F4 0%, #0B93F6 100%) !important;
+	font-weight: 500;
+	border-radius: 20px !important;
+	padding-left: 1rem;
+	padding-right: 1rem;
+	padding-top: 0.5rem;
+	padding-bottom: 0.5rem;
+	margin-left: 3rem;
+	margin-bottom: 0.25rem;
+}
+.chat-msg:hover {
+	background: #f7fbfd;
+}
+.no-focus:focus {
+	outline: none !important;
+	outline-width: 0 !important;
+	box-shadow: none;
+	-moz-box-shadow: none;
+	-webkit-box-shadow: none;
+}
+.emoji-msg {
+	font-size: 4rem !important;
+	line-height: 30px !important;
+	margin-top: 10px !important;
+}
+.larger-text {
+	font-size: 22px;
+}
+</style>
+
+<script type="text/javascript">
+	export default {
+		props: ['accountId'],
+		data() {
+			return {
+				config: window.App.config,
+				hideAvatars: true,
+				hideTimestamps: false,
+				largerText: false,
+				autoRefresh: false,
+				mutedNotifications: false,
+				blocked: false,
+				loaded: false,
+				profile: {},
+				page: 'read',
+				pages: ['browse', 'add', 'read'],
+				threads: [],
+				thread: false,
+				threadIndex: false,
+
+				replyText: '',
+				composeUsername: '',
+
+				ctxContext: null,
+				ctxIndex: null,
+
+				uploading: false,
+				uploadProgress: null,
+
+				min_id: null,
+				max_id: null,
+				loadingMessages: false,
+				showLoadMore: true,
+			}
+		},
+
+		mounted() {
+			this.fetchProfile();
+			let self = this;
+			axios.get('/api/pixelfed/v1/direct/thread', {
+				params: {
+					pid: self.accountId
+				}
+			})
+			.then(res => {
+				self.loaded = true;
+				let d = res.data;
+				d.messages.reverse();
+				this.thread = d;
+				this.threads = [d];
+				this.threadIndex = 0;
+				let mids = d.messages.map(m => m.id);
+				this.max_id = Math.max(...mids);
+				this.min_id = Math.min(...mids);
+				this.mutedNotifications = d.muted;
+				this.markAsRead();
+				//this.messagePoll();
+				setTimeout(function() {
+					let objDiv = document.querySelector('.dm-wrapper');
+					objDiv.scrollTop = objDiv.scrollHeight;
+				}, 300);
+			});
+		},
+
+		watch: {
+			mutedNotifications: function(v) {
+				if(v) {
+					axios.post('/api/pixelfed/v1/direct/mute', {
+						id: this.accountId
+					}).then(res => {
+
+					});
+				} else {
+					axios.post('/api/pixelfed/v1/direct/unmute', {
+						id: this.accountId
+					}).then(res => {
+
+					});
+				}
+				this.mutedNotifications = v;
+			},
+		},
+
+		updated() {
+			$('[data-toggle="tooltip"]').tooltip();
+		},
+
+		methods: {
+			fetchProfile() {
+				axios.get('/api/pixelfed/v1/accounts/verify_credentials').then(res => {
+					this.profile = res.data;
+					window._sharedData.curUser = res.data;
+				});
+			},
+
+			sendMessage() {
+				let self = this;
+				let rt = this.replyText;
+				axios.post('/api/pixelfed/v1/direct/create', {
+					'to_id': this.threads[this.threadIndex].id,
+					'message': rt,
+					'type': self.isEmoji(rt) && rt.length < 10 ? 'emoji' : 'text'
+				}).then(res => {
+					let msg = res.data;
+					self.threads[self.threadIndex].messages.push(msg);
+					let mids = self.threads[self.threadIndex].messages.map(m => m.id);
+					this.max_id = Math.max(...mids)
+					this.min_id = Math.min(...mids)
+					setTimeout(function() {
+						var objDiv = document.querySelector('.dm-wrapper');
+						objDiv.scrollTop = objDiv.scrollHeight;
+					}, 300);
+				}).catch(err => {
+					if(err.response.status == 403) {
+						self.blocked = true;
+						swal('Profile Unavailable', 'You cannot message this profile at this time.', 'error');
+					}
+				})
+				this.replyText = '';
+			},
+
+			openCtxMenu(r, i) {
+				this.ctxIndex = i;
+				this.ctxContext = r;
+				this.$refs.ctxModal.show();
+			},
+
+			closeCtxMenu() {
+				this.$refs.ctxModal.hide();
+			},
+
+			truncate(t) {
+				return _.truncate(t);
+			},
+
+			deleteMessage() {
+				let self = this;
+				let c = window.confirm('Are you sure you want to delete this message?');
+				if(c) {
+					axios.delete('/api/direct/message', {
+						params: {
+							id: self.ctxContext.id
+						}
+					}).then(res => {
+						self.threads[self.threadIndex].messages.splice(self.ctxIndex,1);
+						self.closeCtxMenu();
+					});
+				} else {
+					self.closeCtxMenu();
+				}
+			},
+
+			reportMessage() {
+				this.closeCtxMenu();
+				let url = '/i/report?type=post&id=' + this.ctxContext.id;
+				window.location.href = url;
+				return;
+			},
+
+			uploadMedia(event) {
+				let self = this;
+				$(document).on('change', '#uploadMedia', function(e) {
+					self.handleUpload();
+				});
+				let el = $(event.target);
+				el.attr('disabled', '');
+				$('#uploadMedia').click();
+				el.blur();
+				el.removeAttr('disabled');
+			},
+
+			handleUpload() {
+				let self = this;
+				self.uploading = true;
+				let io = document.querySelector('#uploadMedia');
+				if(!io.files.length) {
+					this.uploading = false;
+				}
+				Array.prototype.forEach.call(io.files, function(io, i) {
+					let type = io.type;
+					let acceptedMimes = self.config.uploader.media_types.split(',');
+					let validated = $.inArray(type, acceptedMimes);
+					if(validated == -1) {
+						swal('Invalid File Type', 'The file you are trying to add is not a valid mime type. Please upload a '+self.config.uploader.media_types+' only.', 'error');
+						self.uploading = false;
+						return;
+					}
+
+					let form = new FormData();
+					form.append('file', io);
+					form.append('to_id', self.threads[self.threadIndex].id);
+
+					let xhrConfig = {
+						onUploadProgress: function(e) {
+							let progress = Math.round( (e.loaded * 100) / e.total );
+							self.uploadProgress = progress;
+						}
+					};
+
+					axios.post('/api/direct/media', form, xhrConfig)
+					.then(function(e) {
+						self.uploadProgress = 100;
+						self.uploading = false;
+						let msg = {
+							id: Date.now(),
+							type: e.data.type,
+							isAuthor: true,
+							text: null,
+							media: e.data.url,
+							timeAgo: '1s',
+							seen: null
+						};
+						self.threads[self.threadIndex].messages.push(msg);
+						setTimeout(function() {
+							var objDiv = document.querySelector('.dm-wrapper');
+							objDiv.scrollTop = objDiv.scrollHeight;
+						}, 300);
+
+					}).catch(function(e) {
+						switch(e.response.status) {
+							case 451:
+							self.uploading = false;
+							io.value = null;
+							swal('Banned Content', 'This content has been banned and cannot be uploaded.', 'error');
+							break;
+
+							default:
+							self.uploading = false;
+							io.value = null;
+							swal('Oops, something went wrong!', 'An unexpected error occurred.', 'error');
+							break;
+						}
+					});
+					io.value = null;
+					self.uploadProgress = 0;
+				});
+			},
+
+			viewOriginal() {
+				let url = this.ctxContext.media;
+				window.location.href = url;
+				return;
+			},
+
+			isEmoji(text) {
+				const onlyEmojis = text.replace(new RegExp('[\u0000-\u1eeff]', 'g'), '')
+				const visibleChars = text.replace(new RegExp('[\n\r\s]+|( )+', 'g'), '')
+				return onlyEmojis.length === visibleChars.length
+			},
+
+			copyText() {
+				window.App.util.clipboard(this.ctxContext.text);
+				this.closeCtxMenu();
+				return;
+			},
+
+			clickLink() {
+				let url = this.ctxContext.text;
+				if(this.ctxContext.meta.local != true) {
+					url = '/i/redirect?url=' + encodeURI(this.ctxContext.text);
+				}
+				window.location.href = url;
+			},
+
+			markAsRead() {
+				return;
+				axios.post('/api/direct/read', {
+					pid: this.accountId,
+					sid: this.max_id
+				}).then(res => {
+				}).catch(err => {
+				});
+			},
+
+			loadOlderMessages() {
+				let self = this;
+				this.loadingMessages = true;
+
+				axios.get('/api/pixelfed/v1/direct/thread', {
+					params: {
+						pid: this.accountId,
+						max_id: this.min_id,
+					}
+				}).then(res => {
+					let d = res.data;
+					if(!d.messages.length) {
+						this.showLoadMore = false;
+						this.loadingMessages = false;
+						return;
+					}
+					let cids = this.thread.messages.map(m => m.id);
+					let m = d.messages.filter(m => {
+						return cids.indexOf(m.id) == -1;
+					}).reverse();
+					let mids = m.map(m => m.id);
+					let min_id = Math.min(...mids);
+					if(min_id == this.min_id) {
+						this.showLoadMore = false;
+						this.loadingMessages = false;
+						return;
+					}
+					this.min_id = min_id;
+					this.thread.messages.unshift(...m);
+					setTimeout(function() {
+						self.loadingMessages = false;
+					}, 500);
+				}).catch(err => {
+					this.loadingMessages = false;
+				})
+			},
+
+			messagePoll() {
+				let self = this;
+				setInterval(function() {
+					axios.get('/api/pixelfed/v1/direct/thread', {
+						params: {
+							pid: self.accountId,
+							min_id: self.thread.messages[self.thread.messages.length - 1].id
+						}
+					}).then(res => {
+					});
+				}, 5000);
+			},
+
+			showOptions() {
+				this.page = 'options';
+			}
+		}
+	}
+</script>

+ 5 - 0
resources/assets/js/components/NotificationCard.vue

@@ -62,6 +62,11 @@
 								<a :href="n.account.url" class="font-weight-bold text-dark word-break" :title="n.account.username">{{truncate(n.account.username)}}</a> tagged you in a <a class="font-weight-bold" v-bind:href="n.tagged.post_url">post</a>.
 							</p>
 						</div>
+						<div v-else-if="n.type == 'direct'">
+							<p class="my-0">
+								<a :href="n.account.url" class="font-weight-bold text-dark word-break" :title="n.account.username">{{truncate(n.account.username)}}</a> sent a <a class="font-weight-bold" v-bind:href="'/account/direct/t/'+n.account.id">dm</a>.
+							</p>
+						</div>
 						<div v-else>
 							<p class="my-0">
 								We cannot display this notification at this time.

+ 10 - 3
resources/assets/js/components/Profile.vue

@@ -102,17 +102,18 @@
 									</span>
 									<span v-if="profile.id != user.id && user.hasOwnProperty('id')">
 										<span class="pl-4" v-if="relationship.following == true">
-											<button type="button"  class="btn btn-outline-secondary font-weight-bold btn-sm py-1" v-on:click="followProfile" data-toggle="tooltip" title="Unfollow">FOLLOWING</button>
+											<a :href="'/account/direct/t/'+profile.id"  class="btn btn-outline-secondary font-weight-bold btn-sm py-1 text-dark mr-2 px-3 btn-sec-alt" style="border:1px solid #dbdbdb;" data-toggle="tooltip" title="Message">Message</a>
+											<button type="button"  class="btn btn-outline-secondary font-weight-bold btn-sm py-1 text-dark btn-sec-alt" style="border:1px solid #dbdbdb;" v-on:click="followProfile" data-toggle="tooltip" title="Unfollow"><i class="fas fa-user-check mx-3"></i></button>
 										</span>
 										<span class="pl-4" v-if="!relationship.following">
-											<button type="button" class="btn btn-primary font-weight-bold btn-sm py-1" v-on:click="followProfile" data-toggle="tooltip" title="Follow">FOLLOW</button>
+											<button type="button" class="btn btn-primary font-weight-bold btn-sm py-1 px-3" v-on:click="followProfile" data-toggle="tooltip" title="Follow">Follow</button>
 										</span>
 									</span>
 									<span class="pl-4" v-if="owner && user.hasOwnProperty('id')">
 										<a class="btn btn-outline-secondary btn-sm" href="/settings/home" style="font-weight: 600;">Edit Profile</a>
 									</span>
 									<span class="pl-4">
-										<a class="fas fa-ellipsis-h fa-lg text-muted text-decoration-none" href="#" @click.prevent="visitorMenu"></a>
+										<a class="fas fa-ellipsis-h fa-lg text-dark text-decoration-none" href="#" @click.prevent="visitorMenu"></a>
 									</span> 
 								</div>
 								<div class="font-size-16px">
@@ -614,6 +615,12 @@
 	.modal-tab-active {
 		border-bottom: 1px solid #08d;
 	}
+	.btn-sec-alt:hover {
+		color: #ccc;
+		opacity: .7;
+		background-color: transparent;
+		border-color: #6c757d;
+	}
 </style>
 <script type="text/javascript">
 	import VueMasonry from 'vue-masonry-css'

+ 1 - 1
resources/assets/js/components/SearchResults.vue

@@ -24,7 +24,7 @@
 			<div class="col-12 mb-5">
 				<hr>
 			</div>
-			<div v-if="placesSearchEnabled && showPlaces" class="col-12">
+			<div v-if="placesSearchEnabled && showPlaces" class="col-12 mb-4">
 				<div class="mb-4">
 					<p class="text-secondary small font-weight-bold">PLACES <span class="pl-1 text-lighter">({{results.placesPagination.total}})</span></p>
 				</div>

+ 5 - 0
resources/assets/js/direct.js

@@ -1,4 +1,9 @@
 Vue.component(
     'direct-component',
     require('./components/Direct.vue').default
+);
+
+Vue.component(
+    'direct-message',
+    require('./components/DirectMessage.vue').default
 );

+ 1 - 6
resources/views/account/direct.blade.php

@@ -7,11 +7,6 @@
 @endsection
 
 @push('scripts')
-<script type="text/javascript" src="{{ mix('js/compose.js') }}"></script>
 <script type="text/javascript" src="{{ mix('js/direct.js') }}"></script>
-<script type="text/javascript">
-  new Vue({
-    el: '#content'
-  });
-</script>
+<script type="text/javascript">App.boot();</script>
 @endpush

+ 12 - 0
resources/views/account/directmessage.blade.php

@@ -0,0 +1,12 @@
+@extends('layouts.app')
+
+@section('content')
+<div>
+  <direct-message account-id="{{$id}}"></direct-message>
+</div>
+@endsection
+
+@push('scripts')
+<script type="text/javascript" src="{{ mix('js/direct.js') }}"></script>
+<script type="text/javascript">App.boot();</script>
+@endpush

+ 6 - 0
resources/views/layouts/partial/nav.blade.php

@@ -38,6 +38,12 @@
                                 <span class="sr-only">Discover</span>
                             </a>
                         </li>
+                        <li class="nav-item px-md-2">
+                            <a class="nav-link font-weight-bold text-muted" href="/account/direct" title="Direct" data-toggle="tooltip" data-placement="bottom">
+                                <i class="far fa-comment-dots fa-lg"></i>
+                                <span class="sr-only">Direct</span>
+                            </a>
+                        </li>
                         <li class="nav-item px-md-2 d-none d-md-block">
                             <a class="nav-link font-weight-bold text-muted" href="/account/activity" title="Notifications" data-toggle="tooltip" data-placement="bottom">
                                 <i class="far fa-bell fa-lg"></i>

+ 5 - 5
resources/views/settings/privacy.blade.php

@@ -40,13 +40,13 @@
       </label>
       <p class="text-muted small help-text">When this option is enabled, your profile and posts are used for discover recommendations. Only public profiles and posts are used.</p>
     </div> --}}
-    {{--<div class="form-check pb-3">
-      <input class="form-check-input" type="checkbox" value="" id="dm">
-      <label class="form-check-label font-weight-bold" for="dm">
+    <div class="form-check pb-3">
+      <input class="form-check-input" type="checkbox" id="public_dm" {{$settings->public_dm ? 'checked=""':''}} name="public_dm">
+      <label class="form-check-label font-weight-bold" for="public_dm">
         {{__('Receive Direct Messages from anyone')}}
       </label>
-      <p class="text-muted small help-text">If selected, you will be able to receive messages from any user even if you do not follow them.</p>
-    </div>--}}
+      <p class="text-muted small help-text">If selected, you will be able to receive messages and notifications from any user even if you do not follow them.</p>
+    </div>
     {{-- <div class="form-check pb-3">
       <input class="form-check-input" type="checkbox" value="" id="srs" checked="">
       <label class="form-check-label font-weight-bold" for="srs">

+ 1 - 1
webpack.mix.js

@@ -35,7 +35,7 @@ mix.js('resources/assets/js/app.js', 'public/js')
 .js('resources/assets/js/profile-directory.js', 'public/js')
 .js('resources/assets/js/story-compose.js', 'public/js')
 // .js('resources/assets/js/embed.js', 'public')
-// .js('resources/assets/js/direct.js', 'public/js')
+ .js('resources/assets/js/direct.js', 'public/js')
 // .js('resources/assets/js/admin.js', 'public/js')
 // .js('resources/assets/js/micro.js', 'public/js')
 .js('resources/assets/js/rempro.js', 'public/js')